diff --git a/examples/01-basic.html b/examples/01-basic.html index a6e1ebc..9b0e831 100644 --- a/examples/01-basic.html +++ b/examples/01-basic.html @@ -30,6 +30,7 @@ icons: [ { src: 'https://cityhost.ua/upload_img/blog5ef308ea5529c_trash2-01.jpg', position: 'middle', size: 30 }], enableExtremumLabels: true, enableThresholdLabels: true, + enableThresholdDrag: true, } ); pod.render(); diff --git a/src/index.ts b/src/index.ts index e9cee5c..b0a1636 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { GaugeTimeSerie, GaugeOptions, Stat, Stop, IconConfig, IconPosition } from './types'; +import { GaugeTimeSerie, GaugeOptions, Stat, Stop, IconConfig, IconPosition, PointCoordinate } from './types'; import { ChartwerkPod, VueChartwerkPodMixin, AxisFormat, CrosshairOrientation } from '@chartwerk/core'; @@ -73,9 +73,15 @@ const DEFAULT_GAUGE_OPTIONS: GaugeOptions = { reversed: false, enableExtremumLabels: false, enableThresholdLabels: false, + enableThresholdDrag: false, }; export class ChartwerkGaugePod extends ChartwerkPod { + _draggableLines: any[] = []; + _draggedThresholdValues: number[] = []; // threshold values after dragging + _thresholdArc: any | null = null; + _thresholdTextLabels: any[] = []; + constructor(el: HTMLElement, _series: GaugeTimeSerie[] = [], _options: GaugeOptions = {}) { super( d3, el, _series, @@ -91,6 +97,7 @@ export class ChartwerkGaugePod extends ChartwerkPod { + const spaceBetweenCircles = this.rescaleSpace(SPACE_BETWEEN_CIRCLES); + const thresholdInnerRadius = this._outerRadius + spaceBetweenCircles; + const stopCircleWidth = this.rescaleWith(STOPS_CIRCLE_WIDTH); + // TODO: move to options + const thresholdOuterRadius = thresholdInnerRadius + stopCircleWidth; + const arc = d3.arc() + .innerRadius(thresholdInnerRadius) + .outerRadius(thresholdOuterRadius) + .padAngle(0); + return arc; + } + + get arcScale(): d3.ScaleLinear { + return this.d3.scaleLinear() + .domain([this.options.minValue, this.options.maxValue]) + .range([(-1 * Math.PI) / 2 - CIRCLES_ROUNDING, Math.PI / 2 + CIRCLES_ROUNDING]); + } + + protected _renderDraggableLines(): void { + if(this.options.enableThresholdDrag === false) { + return; + } + + this._stopsValues.forEach((stopValue, stopIdx) => this._renderDraggableLine(stopValue, stopIdx)); + this._draggedThresholdValues = _.clone(this._stopsValues); + } + + protected _renderDraggableLine(stopValue: number, idx: number): void { + const arc = this._getThresholdArc(); + const draggableSize = 0.025; + const thresholdAngle = this.arcScale(stopValue); + + const pie = d3.pie() + .startAngle(thresholdAngle - draggableSize) + .endAngle(thresholdAngle + draggableSize) + .sort(null); + + const drag = this.d3.drag() + .on('drag', () => this.onDrag(idx)) + .on('end', () => this.onDragEnd(idx)); + + const dragLine = this.svg.selectAll(null) + .data(pie([1])) + .enter() + .append('path') + .attr('class', 'drag-line') + .style('fill', 'black') + .attr('d', arc as any) + .attr('transform', this._gaugeTransform) + .style('cursor', 'grab') + .attr('pointer-events', 'all'); + dragLine.call(drag); + this._draggableLines.push(dragLine); + } + + onDrag(idx: number): void { + const angle = this.getAngleFromCoordinates(this.d3.event.x, this.d3.event.y); + const restrictedAngle = this.restrictAngle(angle, idx); + this.updateDraggableLineByAngle(restrictedAngle, idx); + const value = _.ceil(this.arcScale.invert(restrictedAngle), 1); + this._draggedThresholdValues[idx] = value; + this.updateThresholdArcByNewValues(this._draggedThresholdValues); + this.updateThresholdLabel(value, idx); + if(this.options.dragCallback) { + this.options.dragCallback({ value, idx }); + } + } + + updateThresholdArcByNewValues(stops: number[]): void { + const thresholdArc = this._getThresholdArc(); + const stopArcs = this._d3Pie(this._getStopsRange(stops)); + this._thresholdArc + .data(stopArcs) + .attr('d', thresholdArc as any); + } + + updateThresholdLabel(value: number, idx: number): void { + if(_.isEmpty(this._thresholdTextLabels) || !this._thresholdTextLabels[idx]) { + return; + } + this._thresholdTextLabels[idx].text(value); + } + + updateDraggableLineByAngle(angle: number, idx: number): void { + const arc = this._getThresholdArc(); + const draggableSize = 0.025; + const pie = d3.pie() + .startAngle(angle - draggableSize) + .endAngle(angle + draggableSize) + .sort(null); + + this._draggableLines[idx].data(pie([1])).attr('d', arc); + } + + onDragEnd(idx: number): void { + if(this.options.dragEndCallback) { + this.options.dragEndCallback({ idx }); + } + } + + getAngleFromCoordinates(x: number, y: number): number { + const vector1 = { start: this._gaugeCenterCoordinate, end: { x: this._gaugeCenterCoordinate.x, y: 0 } }; + const vector2 = { start: this._gaugeCenterCoordinate, end: { x, y } }; + let a1 = this.getAngleBetween2Vectors(vector1, vector2); + if(x < this.width / 2) { // angle < 0 degree + return -a1; + } + return a1; + } + + getAngleBetween2Vectors( + vector1: { start: PointCoordinate, end: PointCoordinate }, + vector2: { start: PointCoordinate, end: PointCoordinate } + ): number { + const x1 = vector1.start.x; const y1 = vector1.start.y; + const x2 = vector1.end.x; const y2 = vector1.end.y; + const x3 = vector2.start.x; const y3 = vector2.start.y; + const x4 = vector2.end.x; const y4 = vector2.end.y; + return Math.acos( + ((x2 - x1) * (x4 - x3) + (y2 - y1) * (y4 - y3)) / + (Math.sqrt( (x2 - x1)**2 + (y2 - y1)**2 ) * + Math.sqrt( (x4 - x3)**2 + (y4 - y3)**2 )) + ) + } + + restrictAngle(angle: number, idx: number): number { + return _.clamp(angle, -1.8, 1.8); + } + protected _renderLabels(): void { const yOffset = this._valueTextFontSize + 8; if(this.options.enableThresholdLabels) { if(this._stopsValues && this._stopsValues[0]) { this.renderLabelBackground(0, yOffset / 2); - this.renderLabelText(this.width / 6, yOffset, String(this._stopsValues[0])); + this._thresholdTextLabels.push(this.renderLabelText(this.width / 6, yOffset, String(this._stopsValues[0]))); } if(this._stopsValues && this._stopsValues[1]) { this.renderLabelBackground(this.width * 2 / 3, yOffset / 2); - this.renderLabelText(this.width * 5 / 6, yOffset, String(this._stopsValues[1])); + this._thresholdTextLabels.push(this.renderLabelText(this.width * 5 / 6, yOffset, String(this._stopsValues[1]))); } } if(this.options.enableExtremumLabels) { @@ -299,8 +428,8 @@ export class ChartwerkGaugePod extends ChartwerkPod { + return this.svg .append('text') .attr('x', x) .attr('y', y) @@ -344,10 +473,10 @@ export class ChartwerkGaugePod extends ChartwerkPod string; export type GaugeTimeSerie = TimeSerie; @@ -43,5 +47,8 @@ export type GaugeOptionsParams = { reversed: boolean; enableThresholdLabels: boolean; // render threshold values as a text under the gauge enableExtremumLabels: boolean; // render min/max values as a text above the gauge + enableThresholdDrag: boolean; // drag threshold arcs to change stops values + dragCallback: (event: any) => void; + dragEndCallback: (event: any) => void; } export type GaugeOptions = Options & Partial;