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 = 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 (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 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 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(); } } }