import { Component, HostListener, ViewChild, ElementRef, Input, Output, EventEmitter, AfterViewInit, OnChanges, } from '@angular/core'; const VIEW_BOX_SIZE = 300; @Component({ selector: 'ngx-temperature-dragger', templateUrl: './temperature-dragger.component.html', styleUrls: ['./temperature-dragger.component.scss'], }) export class TemperatureDraggerComponent implements AfterViewInit, OnChanges { @ViewChild('svgRoot') svgRoot: ElementRef; @Input() fillColors: string|string[] = '#2ec6ff'; @Input() disableArcColor: string = '#999999'; @Input() bottomAngle: number = 90; @Input() arcThickness: number = 6; // CSS pixels @Input() knobRadius: number = 10; // CSS pixels value: number = 0.5; @Output('valueChange') valueChange = new EventEmitter(); @Input('value') set setValue(value) { this.value = value; } @HostListener('mouseup', ['$event']) onMouseUp(event) { this.recalculateValue(event); this.isMouseDown = false; } @HostListener('mousemove', ['$event']) onMouseMove(event: MouseEvent) { this.recalculateValue(event); } @HostListener('window:resize', ['$event']) onResize(event) { this.invalidate(); } scaleFactor: number = 1; bottomAngleRad = 0; radius: number = 100; translateXValue = 0; translateYValue = 0; thickness = 6; pinRadius = 10; colors: any = []; styles = { viewBox: '0 0 300 300', arcTranslateStr: 'translate(0, 0)', clipPathStr: '', gradArcs: [], nonSelectedArc: {}, knobPosition: { x: 0, y: 0 }, blurRadius: 15, }; private isMouseDown = false; private init = false; constructor() { } ngAfterViewInit(): void { this.invalidate(); this.init = true; } ngOnChanges(): void { if (this.init) { this.invalidate(); } } mouseDown(event) { this.isMouseDown = true; this.recalculateValue(event); } private invalidate(): void { this.bottomAngleRad = TemperatureDraggerComponent.toRad(this.bottomAngle); this.calculateVars(); this.invalidateClipPathStr(); this.invalidateGradientArcs(); this.invalidatePinPosition(); } private calculateVars() { this.bottomAngleRad = TemperatureDraggerComponent.toRad(this.bottomAngle); this.colors = (typeof this.fillColors === 'string') ? [this.fillColors] : this.fillColors; const baseRadius: number = VIEW_BOX_SIZE / 2; const halfAngle = this.bottomAngleRad / 2; const svgBoundingRect = this.svgRoot.nativeElement.getBoundingClientRect(); const svgAreaFactor = svgBoundingRect.width / svgBoundingRect.height; const svgHeight = VIEW_BOX_SIZE / svgAreaFactor; const knobMargin = this.knobRadius > this.arcThickness ? (this.knobRadius - this.arcThickness / 2) / this.scaleFactor : 0; this.scaleFactor = svgBoundingRect.width / VIEW_BOX_SIZE; this.styles.viewBox = `0 0 ${VIEW_BOX_SIZE} ${svgHeight}`; const circleFactor = this.bottomAngleRad <= Math.PI ? ( 2 / (1 + Math.cos(halfAngle)) ) : ( 2 * Math.sin(halfAngle) / (1 + Math.cos(halfAngle)) ); if (circleFactor > svgAreaFactor) { if (this.bottomAngleRad > Math.PI) { this.radius = (VIEW_BOX_SIZE - 2 * knobMargin) / (2 * Math.sin(halfAngle)); } else { this.radius = VIEW_BOX_SIZE / 2 - knobMargin; } } else { this.radius = (svgHeight - 2 * knobMargin) / (1 + Math.cos(halfAngle)); } this.translateXValue = VIEW_BOX_SIZE / 2 - this.radius; this.translateYValue = (svgHeight) / 2 - this.radius * (1 + Math.cos(halfAngle)) / 2; this.styles.arcTranslateStr = `translate(${this.translateXValue}, ${this.translateYValue})`; this.thickness = this.arcThickness / this.scaleFactor; this.pinRadius = this.knobRadius / this.scaleFactor; } private calculateClipPathSettings() { const halfAngle = this.bottomAngleRad / 2; const innerRadius = this.radius - this.thickness; const xStartMultiplier = 1 - Math.sin(halfAngle); const yMultiplier = 1 + Math.cos(halfAngle); const xEndMultiplier = 1 + Math.sin(halfAngle); return { outer: { start: { x: xStartMultiplier * this.radius, y: yMultiplier * this.radius, }, end: { x: xEndMultiplier * this.radius, y: yMultiplier * this.radius, }, radius: this.radius, }, inner: { start: { x: xStartMultiplier * innerRadius + this.thickness, y: yMultiplier * innerRadius + this.thickness, }, end: { x: xEndMultiplier * innerRadius + this.thickness, y: yMultiplier * innerRadius + this.thickness, }, radius: innerRadius, }, thickness: this.thickness, big: this.bottomAngleRad < Math.PI ? '1' : '0', }; } private invalidateClipPathStr() { const s = this.calculateClipPathSettings(); let path = `M ${s.outer.start.x},${s.outer.start.y}`; // Start at startangle top // Outer arc // Draw an arc of radius 'radius' // Arc details... path += ` A ${s.outer.radius},${s.outer.radius} 0 ${s.big} 1 ${s.outer.end.x},${s.outer.end.y}`; // Arc goes to top end angle coordinate // Outer to inner connector path += ` A ${s.thickness / 2},${s.thickness / 2} 0 1 1 ${s.inner.end.x},${s.inner.end.y}`; // Inner arc path += ` A ${s.inner.radius},${s.inner.radius} 1 ${s.big} 0 ${s.inner.start.x},${s.inner.start.y}`; // Outer to inner connector path += ` A ${s.thickness / 2},${s.thickness / 2} 0 1 1 ${s.outer.start.x},${s.outer.start.y}`; // Close path path += ' Z'; this.styles.clipPathStr = path; } private calculateGradientConePaths(angleStep) { const radius = this.radius; function calcX(angle) { return radius * (1 - 2 * Math.sin(angle)); } function calcY(angle) { return radius * (1 + 2 * Math.cos(angle)); } const gradArray = []; for (let i = 0, currentAngle = this.bottomAngleRad / 2; i < this.colors.length; i++, currentAngle += angleStep) { gradArray.push({ start: { x: calcX(currentAngle), y: calcY(currentAngle) }, end: { x: calcX(currentAngle + angleStep), y: calcY(currentAngle + angleStep) }, big: Math.PI <= angleStep ? 1 : 0, }); } return gradArray; } private invalidateGradientArcs() { const radius = this.radius; function getArc(des) { return `M ${radius},${radius} L ${des.start.x},${des.start.y} A ${2 * radius},${2 * radius} 0 ${des.big} 1 ${des.end.x},${des.end.y} Z`; } const angleStep = (2 * Math.PI - this.bottomAngleRad) / this.colors.length; const s = this.calculateGradientConePaths(angleStep); this.styles.gradArcs = []; for (let i = 0; i < s.length; i++) { const si = s[i]; const arcValue = getArc(si); this.styles.gradArcs.push({ color: this.colors[i], d: arcValue, }); } this.styles.blurRadius = 2 * radius * Math.sin(angleStep / 6); } private invalidateNonSelectedArc() { const angle = this.bottomAngleRad / 2 + (1 - this.value) * (2 * Math.PI - this.bottomAngleRad); this.styles.nonSelectedArc = { color: this.disableArcColor, d: `M ${this.radius},${this.radius} L ${this.radius},${3 * this.radius} A ${2 * this.radius},${2 * this.radius} 1 ${angle > Math.PI ? '1' : '0'} 0 ${this.radius + this.radius * 2 * Math.sin(angle)},${this.radius + this.radius * 2 * Math.cos(angle)} Z`, }; } private invalidatePinPosition() { const radiusOffset = this.thickness / 2; const curveRadius = this.radius - radiusOffset; const actualAngle = (2 * Math.PI - this.bottomAngleRad) * this.value + this.bottomAngleRad / 2; this.styles.knobPosition = { x: curveRadius * (1 - Math.sin(actualAngle)) + radiusOffset, y: curveRadius * (1 + Math.cos(actualAngle)) + radiusOffset, }; this.invalidateNonSelectedArc(); } private recalculateValue(event) { if (this.isMouseDown) { const rect = this.svgRoot.nativeElement.getBoundingClientRect(); const center = { x: rect.left + VIEW_BOX_SIZE * this.scaleFactor / 2, y: rect.top + (this.translateYValue + this.radius) * this.scaleFactor, }; let actualAngle = Math.atan2(center.x - event.clientX, event.clientY - center.y); if (actualAngle < 0) { actualAngle = actualAngle + 2 * Math.PI; } let value = 0; if (actualAngle < this.bottomAngleRad / 2) { value = 0; } else if (actualAngle > 2 * Math.PI - this.bottomAngleRad / 2) { value = 1; } else { value = (actualAngle - this.bottomAngleRad / 2) / (2 * Math.PI - this.bottomAngleRad); } if (this.value !== value) { this.value = value; this.valueChange.emit(this.value); this.invalidatePinPosition(); } } } private static toRad(angle) { return Math.PI * angle / 180; } }