FarmMapsLib/projects/common/src/fm/components/timespan/timespan.component.ts

584 lines
22 KiB
TypeScript

import { Component, OnInit,Input,ViewChild,OnChanges,ChangeDetectorRef,Output, EventEmitter,SimpleChanges } from '@angular/core';
import { DatePipe } from '@angular/common';
import {NgbPopover} from '@ng-bootstrap/ng-bootstrap';
export interface TimeSpan {
startDate:Date;
endDate:Date;
}
@Component({
selector: 'fm-timespan',
templateUrl: './timespan.component.html',
styleUrls: ['./timespan.component.css']
})
export class TimespanComponent implements OnInit, OnChanges {
scale:number = 1000 * 60 * 60 ; // milliseconds / pixel ( 1 hour )
unitScales:number[] = [1,1000,1000*60,1000*60*60,1000*60*60*24,1000*60*60*24*7,1000*60*60*24*31,1000*60*60*24*31*3,1000*60*60*24*365.25];
units:string[] = [ 'millisecond','second','minute','hour','day','week','month','quarter','year'];
quarters:string[] = ['KW1','KW2','KW3','KW4'];
unitScale = 3;
viewMinDate:Date;
viewMaxDate:Date;
extentMinDate:Date;
extentMaxDate:Date;
cursorDate:Date;
leftGripMove = false;
rightGripMove = false;
rangeGripMove = false;
viewPan = false;
downX = -1;
mouseX = -1;
mouseY = -1;
elementWidth:number;
elementHeight:number;
lastOffsetInPixels=0;
@ViewChild('timeLine', { static: true }) canvasRef;
@ViewChild('popoverStart', { static: true }) public popoverStart:NgbPopover;
@ViewChild('popoverEnd', { static: true }) public popoverEnd:NgbPopover;
@Input() collapsed = true;
@Input() startDate: Date = new Date(2018,1,3);
@Input() endDate: Date = new Date(2018,1,5);
@Input() unit:string;
@Input() color = '#000000';
@Input() background = '#ffffff';
@Input() hoverColor ='#ffffff';
@Input() hoverBackground ='#0000ff';
@Input() lineColor='#000000';
@Input() lineWidth=1;
@Input() padding = 4;
@Output() change:EventEmitter<TimeSpan> = new EventEmitter();
public caption = "2016/2017";
public marginLeft = 100;
public startPopoverLeft=110;
public endPopoverLeft=120;
public rangeWidth =75;
public startCaption={};
public endCaption={};
private ratio=1;
private initialized=false;
private ctx:CanvasRenderingContext2D;
public posibleUnits:number[] = [];
public height = 0;
public lineHeight = 0;
constructor(private changeDetectorRef: ChangeDetectorRef,private datePipe: DatePipe) { }
setCanvasSize() {
const canvas = this.canvasRef.nativeElement;
this.elementWidth = canvas.offsetWidth;
this.elementHeight = canvas.offsetHeight;
canvas.height = this.elementHeight * this.ratio;
canvas.width = this.elementWidth * this.ratio;
}
getPosibleUnits(scale:number):number[] {
const posibleUnits = [];
for(const u of [3,4,6,8]) {
if((this.unitScale <=u) )
posibleUnits.push(u);
}
return posibleUnits;
}
getLineHeight():number {
return (parseInt(this.ctx.font.match(/\d+/)[0], 10)/ this.ratio) + (2*this.padding) ;
}
getHeight():number {
return (this.posibleUnits.length * this.getLineHeight());
}
ngOnInit() {
this.ratio = 2;
this.unitScale = this.getUnitScale(this.unit);
const canvas:HTMLCanvasElement = this.canvasRef.nativeElement;
this.ctx = canvas.getContext('2d');
this.elementWidth = canvas.offsetWidth;
this.elementHeight = canvas.offsetHeight;
this.ctx.font=`normal ${this.ratio*10}pt Sans-serif`;
this.startDate = new Date(this.startDate.getTime() + this.getUnitDateOffset(this.startDate,this.unitScale,0));
this.endDate = new Date(this.endDate.getTime() + this.getUnitDateOffset(this.endDate,this.unitScale,1));
this.change.emit({startDate:this.startDate,endDate:this.endDate});
const rangeInMilliseconds = this.endDate.getTime() - this.startDate.getTime();
this.scale = this.getFitScale(rangeInMilliseconds,this.elementWidth);
this.posibleUnits=this.getPosibleUnits(this.scale);
this.height=this.getHeight();
this.lineHeight= this.getLineHeight();
this.setCanvasSize();
const center = (this.startDate.getTime()+this.endDate.getTime())/2;
this.viewMinDate = new Date(center - (this.elementWidth/2* this.scale));
this.viewMaxDate = new Date(center + (this.elementWidth/2* this.scale));
this.updateStyle(this.startDate,this.endDate);
this.startCaption={popoverCaption:this.getStartCaption(this.startDate,this.unitScale,true)};
this.endCaption={popoverCaption:this.getEndCaption(this.endDate,this.unitScale,true)};
this.redraw();
this.initialized=true;
}
getStartEndCaption(date:Date,otherDate:Date,unitScale:number,suffix = false,extended=true):string {
const showSuffix = false;
otherDate=new Date(otherDate.getTime()-1); // fix year edge case
if(unitScale == 3) {
let format="HH:00";
if(extended) {
if(suffix || date.getFullYear() != otherDate.getFullYear())
format="d MMM yyyy:HH:00";
else if(date.getMonth() !== otherDate.getMonth())
format="d MMM HH:00";
}
return this.datePipe.transform(date,format);
}
if(unitScale == 4) {
let format="d";
if(extended) {
if(suffix || date.getFullYear() != otherDate.getFullYear())
format="d MMM yyyy";
else if(date.getMonth() !== otherDate.getMonth())
format="d MMM"
}
return this.datePipe.transform(date,format);
}
if(unitScale == 6) {
let format = "MMM";
if(extended) {
if(suffix || date.getFullYear() != otherDate.getFullYear())
format="MMM yyyy";
}
return this.datePipe.transform(date,format);
}
if(unitScale == 7) {
const q = Math.trunc(date.getMonth() /3 );
return this.quarters[q];
}
if(unitScale == 8) {
return this.datePipe.transform(date,"yyyy");
}
return "";
}
getStartCaption(startDate:Date,unitScale:number,suffix=false,extended=true):string {
return this.getStartEndCaption(new Date(startDate.getTime() + (this.unitScales[unitScale]/2)), this.endDate,unitScale,suffix,extended);
}
getEndCaption(endDate:Date,unitScale:number,suffix=true):string {
return this.getStartEndCaption(new Date(endDate.getTime() - (this.unitScales[unitScale]/2)),this.startDate, unitScale,suffix);
}
getCaption(startDate:Date,endDate:Date,unitScale:number):string {
const startCaption=this.getStartCaption(startDate,unitScale);
const endCaption=this.getEndCaption(endDate,unitScale);
if((endDate.getTime() - startDate.getTime()) < (1.5*this.unitScales[this.unitScale]))
return endCaption;
return `${startCaption}-${endCaption}`;
}
public updatePopoverText(popover:NgbPopover, text:string): void {
const isOpen = popover.isOpen();
if (isOpen) {
popover.close();
popover.open({popoverCaption:text});
}
}
getFitScale(rangeInMilliSeconds:number,elementWidth:number):number {
const width = elementWidth*0.33;
return rangeInMilliSeconds/width;
}
getUnitScale(unit:string):number {
if(!unit) return 3; // hour
for(let _i=0;_i<this.units.length;_i++) {
if(this.units[_i]==unit.toLowerCase()) return _i;
}
throw new Error(`Invalid unit : {{unit}} `);
}
getUnitDateOffset(date:Date, unitScale:number,tick:number):number {
let offsetDate:Date;
if(unitScale==0)
offsetDate = new Date(date.getFullYear(),date.getMonth(),date.getDate() ,date.getHours() ,date.getMinutes(),date.getSeconds(),date.getMilliseconds()+ tick);
if(unitScale==1)
offsetDate = new Date(date.getFullYear(),date.getMonth(),date.getDate() ,date.getHours() ,date.getMinutes(),date.getSeconds() + tick,0);
if(unitScale==2)
offsetDate = new Date(date.getFullYear(),date.getMonth(),date.getDate() ,date.getHours() ,date.getMinutes() + tick,0,0);
if(unitScale==3)
offsetDate = new Date(date.getFullYear(),date.getMonth(),date.getDate() ,date.getHours()+ tick ,0,0,0);
if(unitScale==4)
offsetDate = new Date(date.getFullYear(),date.getMonth(),date.getDate() + tick ,0,0,0,0);
if(unitScale==6)
offsetDate = new Date(date.getFullYear(),date.getMonth()+tick,1,0,0,0,0);
if(unitScale==7) {
const month = (tick * 3);
offsetDate = new Date(date.getFullYear(),month,1,0,0,0,0);
}
if(unitScale==8)
offsetDate = new Date(date.getFullYear()+tick,0,1,0,0,0,0);
return offsetDate.getTime()-date.getTime();
}
getUnitTextWidth(unitScale:number):number {
switch(unitScale) {
case 3:return this.ctx.measureText("88:88").width/this.ratio;
case 4:return this.ctx.measureText("88").width/this.ratio;
case 5:return this.ctx.measureText("WWW").width/this.ratio;
case 6:return this.ctx.measureText("WW").width/this.ratio;
case 7:return this.ctx.measureText("WW").width/this.ratio;
case 8:return this.ctx.measureText("8888").width/this.ratio;
default: return this.ctx.measureText("WW").width/this.ratio;
}
}
getNextTick(viewStartDate:Date, tick:number,step:number,unitScale:number):number {
const unitTextWidth = this.getUnitTextWidth(unitScale);
const dateOffset =this.getUnitDateOffset(viewStartDate,unitScale,tick);
const date = new Date(viewStartDate.getTime() + dateOffset);
let nextTick=tick+step+Math.trunc(step/2);
const nextDateOffset =this.getUnitDateOffset(viewStartDate,unitScale,nextTick);
const nextDate = new Date(viewStartDate.getTime() + nextDateOffset);
let n=1;
switch(unitScale) {
case 4:n=nextDate.getDate()-1;break;
case 6:n=nextDate.getMonth();break;
case 8:n=nextDate.getFullYear();break;
default: n = 1;break;
}
const a = Math.trunc(n / step)*step;
nextTick=nextTick-n+a;
if(nextTick<=tick) return tick+step;
return nextTick;
}
getSteps(unitScale:number):number[] {
if(unitScale==4)
return [1,14];
if(unitScale==6)
return [1,3,6];
return [1,2,3,4,5];
}
drawUnits(yOffset:number,width:number,viewStartDate:Date,unitScale:number):number {
const oneUnit = (this.getUnitDateOffset(viewStartDate,unitScale,1)- this.getUnitDateOffset(viewStartDate,unitScale,0)) / this.scale;
this.ctx.font=`normal ${this.ratio*10}pt Sans-serif`;
const lineHeight = this.getLineHeight();
let dateOffset = this.getUnitDateOffset(viewStartDate,unitScale,0);
let pixelOffset = (dateOffset / this.scale);
let caption = this.getStartCaption(new Date(viewStartDate.getTime()+dateOffset),unitScale,false,false);
const unitTextWidth=this.getUnitTextWidth(unitScale);
this.ctx.beginPath();
this.ctx.strokeStyle=this.lineColor;
const steps=this.getSteps(unitScale);
let s=0;
let step=steps[s];
let steppedOneUnit=oneUnit*step;
while(unitTextWidth > (steppedOneUnit-(2*this.padding)) && s < steps.length -1) {
step=steps[++s];
steppedOneUnit=oneUnit*step;
}
if(steppedOneUnit - (2*this.padding) < unitTextWidth) return yOffset;
this.ctx.moveTo(0,yOffset*this.ratio);
this.ctx.lineTo(width*this.ratio,yOffset*this.ratio);
this.ctx.stroke();
let x:number = pixelOffset;
let nextDateOffset = this.getUnitDateOffset(viewStartDate,unitScale,1);
let nextX:number = (nextDateOffset / this.scale);
let n=0;
while(x < width) {
this.ctx.fillStyle=this.color;
//mouseover
if(this.mouseX> x && this.mouseX <nextX && this.mouseY > yOffset && this.mouseY <( yOffset + lineHeight) && !this.leftGripMove && !this.rightGripMove && !this.rangeGripMove&& !this.viewPan) {
this.ctx.fillStyle=this.hoverBackground;
this.ctx.fillRect((x+0.5)*this.ratio,(yOffset+0.5)*this.ratio,(nextX-x)*this.ratio,lineHeight*this.ratio);
this.ctx.fillStyle=this.hoverColor;
}
this.ctx.moveTo((x+0.5)*this.ratio,(yOffset+0.5)*this.ratio);
this.ctx.lineTo((x+0.5)*this.ratio,(yOffset+lineHeight+0.5)*this.ratio);
this.ctx.stroke();
if(unitTextWidth < steppedOneUnit - (2*this.padding) && x > 0) {
this.ctx.fillText(caption,(x+this.padding)*this.ratio,(yOffset+lineHeight-this.padding)*this.ratio);
} else if((unitTextWidth < (steppedOneUnit - (2*this.padding) +pixelOffset)) && (unitTextWidth < (steppedOneUnit-(2*this.padding)))) {
this.ctx.fillText(caption, (this.padding*this.ratio),(yOffset+lineHeight-this.padding)*this.ratio);
} else if(x < 0 && (unitTextWidth <steppedOneUnit - (2*this.padding))) {
this.ctx.fillText(caption, ((x+steppedOneUnit-this.padding-unitTextWidth) *this.ratio),(yOffset+ lineHeight-this.padding)*this.ratio);
}
n=this.getNextTick(viewStartDate,n,step,unitScale);
dateOffset = this.getUnitDateOffset(viewStartDate,unitScale,n);
nextDateOffset = this.getUnitDateOffset(viewStartDate,unitScale,this.getNextTick(viewStartDate,n,step,unitScale));
nextX = (nextDateOffset / this.scale);
pixelOffset = (dateOffset / this.scale);
caption= this.getStartCaption(new Date(viewStartDate.getTime()+dateOffset),unitScale,false,false);
x=pixelOffset;
}
return lineHeight;
}
redraw() {
let yOffset=0;
const canvas = this.canvasRef.nativeElement;
const height = canvas.offsetHeight;
const width = canvas.offsetWidth;
this.ctx.lineWidth = this.lineWidth;// *this.ratio;
this.ctx.clearRect(0,0,width *this.ratio,height*this.ratio);
for(const unit of this.posibleUnits) {
if(this.unitScale <=unit) yOffset+=this.drawUnits(yOffset,width,this.viewMinDate,unit);
}
}
handleClick() {
this.collapsed = !this.collapsed;
}
updateStyle(startDate:Date,endDate:Date) {
const rangeInMilliseconds = endDate.getTime() - startDate.getTime();
const range = rangeInMilliseconds / this.scale;
const left = (startDate.getTime() - this.viewMinDate.getTime()) / this.scale;
this.startPopoverLeft=(left-10);
this.endPopoverLeft=(left+range+10);
this.marginLeft = (left - 15);
this.rangeWidth = range;
this.updatePopoverText(this.popoverStart,this.getStartCaption(startDate,this.unitScale,true));
this.updatePopoverText(this.popoverEnd,this.getEndCaption(endDate,this.unitScale,true));
this.caption=this.getCaption(startDate,endDate,this.unitScale);
}
snapToUnit(date:Date,unitScale:number):Date {
const d = new Date(date.getTime() + (this.unitScales[this.unitScale]/2));
const offsetInMilliseconds =this.getUnitDateOffset(d,this.unitScale,0)
return new Date(d.getTime()+offsetInMilliseconds);
}
getEndDate(offsetInPixels:number):Date {
const oneUnit = this.unitScales[this.unitScale];
const offsetInMilliseconds = offsetInPixels * this.scale;
if(this.leftGripMove) {
if(this.startDate.getTime() + offsetInMilliseconds > this.endDate.getTime() - oneUnit) {
return this.snapToUnit(new Date(this.startDate.getTime() + offsetInMilliseconds + oneUnit),this.unitScale);
}
} else if(this.rightGripMove || this.rangeGripMove) {
return this.snapToUnit(new Date(this.endDate.getTime() + offsetInMilliseconds),this.unitScale);
}
return this.endDate;
}
getStartDate(offsetInPixels:number):Date {
const oneUnit = this.unitScales[this.unitScale];
const offsetInMilliseconds = offsetInPixels * this.scale;
if(this.leftGripMove || this.rangeGripMove) {
return this.snapToUnit(new Date(this.startDate.getTime() + offsetInMilliseconds),this.unitScale);
} else if(this.rightGripMove) {
if(this.endDate.getTime() + offsetInMilliseconds < this.startDate.getTime() + oneUnit) {
return this.snapToUnit(new Date(this.endDate.getTime() + offsetInMilliseconds - oneUnit),this.unitScale);
}
}
return this.startDate;
}
updateControl(event:MouseEvent|TouchEvent) {
const offsetInPixels = this.getClientX(event) - this.downX;
if(this.leftGripMove || this.rightGripMove || this.rangeGripMove) {
const startDate = this.getStartDate(offsetInPixels);
const endDate = this.getEndDate(offsetInPixels);
this.updateStyle(startDate,endDate)
this.changeDetectorRef.detectChanges();
} else if(this.viewPan) {
const offsetInMilliseconds = offsetInPixels*this.scale;
this.viewMinDate = new Date(this.viewMinDate.getTime()-offsetInMilliseconds);
this.viewMaxDate = new Date(this.viewMaxDate.getTime()-offsetInMilliseconds);
this.updateStyle(this.startDate,this.endDate);
this.redraw();
this.changeDetectorRef.detectChanges();
this.downX=this.getClientX(event);
}
this.lastOffsetInPixels=offsetInPixels
}
isMouseEvent(arg: any): arg is MouseEvent {
return arg.clientX !== undefined;
}
getClientX(event:MouseEvent|TouchEvent) {
if(this.isMouseEvent(event)) {
return (event as MouseEvent).clientX;
} else {
return (event as TouchEvent).touches[0].clientX;
}
}
handleRightGripMouseDown(event:MouseEvent) {
this.rightGripMove=true;
this.downX = this.getClientX(event);
this.popoverEnd.open(this.endCaption);
event.preventDefault();
}
handleRightGripMouseEnter(event:MouseEvent) {
this.mouseX=-1;
this.mouseY=-1;
this.redraw();
if(!this.rangeGripMove && !this.leftGripMove && !this.rightGripMove) this.popoverEnd.open(this.endCaption);
}
handleRightGripMouseLeave(event:MouseEvent) {
if(!this.rightGripMove) this.popoverEnd.close();
}
handleLeftGripMouseDown(event:MouseEvent|TouchEvent) {
this.leftGripMove=true;
this.downX = this.getClientX(event);
this.popoverStart.open(this.startCaption);
event.preventDefault();
}
handleLeftGripMouseEnter(event:MouseEvent|TouchEvent) {
this.mouseX=-1;
this.mouseY=-1;
this.redraw();
if(!this.rangeGripMove && !this.leftGripMove && !this.rightGripMove) this.popoverStart.open(this.startCaption);
}
handleLeftGripMouseLeave(event:MouseEvent) {
if(!this.leftGripMove) this.popoverStart.close();
}
handleRangeGripMouseEnter(event:MouseEvent) {
this.mouseX=-1;
this.mouseY=-1;
this.redraw();
}
handleRangeGripMouseDown(event:MouseEvent|TouchEvent) {
this.rangeGripMove=true;
this.downX = this.getClientX(event);
event.preventDefault();
}
handleViewPanMouseDown(event:MouseEvent|TouchEvent) {
this.viewPan=true;
this.downX =this.getClientX(event);
event.preventDefault();
}
handleMouseUp(event:MouseEvent|TouchEvent) {
//this.updateControl(event);
this.startDate = this.getStartDate(this.lastOffsetInPixels);
this.endDate = this.getEndDate(this.lastOffsetInPixels);
this.popoverStart.close();
this.popoverEnd.close();
this.startCaption={popoverCaption:this.getStartCaption(this.startDate,this.unitScale,true)};
this.endCaption={popoverCaption:this.getEndCaption(this.endDate,this.unitScale,true)};
if(this.leftGripMove || this.rightGripMove || this.rangeGripMove) {
this.change.emit({ startDate:this.startDate,endDate:this.endDate});
}
this.rightGripMove=false;
this.leftGripMove=false;
this.rangeGripMove=false;
this.viewPan = false;
this.lastOffsetInPixels=0;
}
handleMouseMove(event:MouseEvent) {
this.mouseX = -1;
this.mouseY = -1;
if(!this.leftGripMove && ! this.rightGripMove && !this.rangeGripMove && !this.viewPan) {
return;
} else {
this.updateControl(event);
}
}
handleCanvasMouseMove(event:MouseEvent) {
this.mouseX = event.offsetX;
this.mouseY = event.offsetY;
this.redraw();
}
handleCanvasMouseLeave(event:MouseEvent) {
this.mouseX = -1;
this.mouseY = -1;
this.redraw();
}
canZoom(currentScale:number, direction:number):boolean {
let nextScale=currentScale;
if(direction<0 ) {
return true;
} else {
nextScale*=1.1;
const canZoom=false;
const oneUnit = (this.getUnitDateOffset(this.viewMinDate,8,1)- this.getUnitDateOffset(this.viewMinDate,8,0)) / nextScale;
const unitTextWidth=this.getUnitTextWidth(8);
const steps=this.getSteps(8);
let s=0;
let step=steps[s];
let steppedOneUnit=oneUnit*step;
while(unitTextWidth > (steppedOneUnit-(2*this.padding)) && s < steps.length -1) {
step=steps[++s];
steppedOneUnit=oneUnit*step;
}
return unitTextWidth < (steppedOneUnit-(2*this.padding)) && s < steps.length;
}
}
handleMouseWheel(event:WheelEvent) {
if(!this.canZoom(this.scale,event.deltaY)) return;
const oldOffsetInMilliseconds = event.clientX * this.scale;
if(event.deltaY>=0)
this.scale*=1.1;
else
this.scale/=1.1;
this.posibleUnits=this.getPosibleUnits(this.scale);
this.height=this.getHeight();
this.changeDetectorRef.detectChanges();
this.setCanvasSize();
const newOffsetInMilliseconds = event.clientX * this.scale;
const offsetInMilliseconds = newOffsetInMilliseconds-oldOffsetInMilliseconds;
this.viewMinDate = new Date(this.viewMinDate.getTime()-offsetInMilliseconds);
this.viewMaxDate = new Date(this.viewMaxDate.getTime()-offsetInMilliseconds);
this.updateStyle(this.startDate,this.endDate);
this.redraw();
this.changeDetectorRef.detectChanges();
}
handleZoomOut() {
if(!this.canZoom(this.scale,1)) return;
this.scale*=1.1;
this.posibleUnits=this.getPosibleUnits(this.scale);
this.height=this.getHeight();
this.setCanvasSize();
this.redraw();
this.updateStyle(this.startDate,this.endDate);
}
handleZoomIn() {
if(!this.canZoom(this.scale,-1)) return;
this.scale/=1.1;
this.posibleUnits=this.getPosibleUnits(this.scale);
this.height=this.getHeight();
this.setCanvasSize();
this.redraw();
this.updateStyle(this.startDate,this.endDate);
}
handleResize(event:any) {
if(this.initialized) {
this.setCanvasSize();
this.updateStyle(this.startDate,this.endDate);
this.redraw();
}
}
ngOnChanges (changes: SimpleChanges) {
if(this.initialized) {
this.setCanvasSize();
this.updateStyle(this.startDate,this.endDate);
this.redraw();
}
}
}