import { ChartwerkPod, VueChartwerkPodMixin, TickOrientation, TimeFormat, CrosshairOrientation, BrushOrientation } from '@chartwerk/core'; import { LineTimeSerie, LineOptions, Mode } from './types'; import * as d3 from 'd3'; import * as _ from 'lodash'; const METRIC_CIRCLE_RADIUS = 1.5; const CROSSHAIR_CIRCLE_RADIUS = 3; const CROSSHAIR_BACKGROUND_RAIDUS = 9; const CROSSHAIR_BACKGROUND_OPACITY = 0.3; export class LinePod extends ChartwerkPod { lineGenerator = null; constructor(_el: HTMLElement, _series: LineTimeSerie[] = [], _options: LineOptions = {}) { super(d3, _el, _series, _options); } renderMetrics(): void { this.clearAllMetrics(); this.updateCrosshair(); this.initLineGenerator(); // TODO: seems that renderMetrics is not correct name if(this.series.length === 0) { this.renderNoDataPointsMessage(); return; } for(let idx = 0; idx < this.series.length; ++idx) { if(this.series[idx].visible === false) { continue; } // TODO: use _.defaults same as in core const confidence = this.series[idx].confidence || 0; const mode = this.series[idx].mode || Mode.STANDARD; const target = this.series[idx].target; const renderDots = this.series[idx].renderDots !== undefined ? this.series[idx].renderDots : false; const renderLines = this.series[idx].renderLines !== undefined ? this.series[idx].renderLines : true; this._renderMetric( this.series[idx].datapoints, { color: this.getSerieColor(idx), confidence, target, mode, serieIdx: idx, renderDots, renderLines, } ); } } clearAllMetrics(): void { // TODO: temporary hack before it will be implemented in core. this.chartContainer.selectAll('.metric-el').remove(); } initLineGenerator(): void { this.lineGenerator = this.d3.line() .x(d => this.xScale(d[0])) .y(d => this.yScale(d[1])); } public appendData(data: [number, number][], shouldRerender = true): void { for(let idx = 0; idx < this.series.length; ++idx) { if(this.series[idx].visible === false) { continue; } this.series[idx].datapoints.push(data[idx]); const maxLength = this.series[idx].maxLength; if(maxLength !== undefined && this.series[idx].datapoints.length > maxLength) { this.series[idx].datapoints.shift(); } } for(let idx = 0; idx < this.series.length; ++idx) { this.metricContainer.select(`.metric-path-${idx}`) .datum(this.series[idx].datapoints) .attr('d', this.lineGenerator); if(this.series[idx].renderDots === true) { this.metricContainer.selectAll(`.metric-circle-${idx}`) .data(this.series[idx].datapoints) .attr('cx', d => this.xScale(d[0])) .attr('cy', d => this.yScale(d[1])); this._renderDots([data[idx]], idx); } } if(shouldRerender) { const rightBorder = _.last(data)[0]; this.state.xValueRange = [this.state.getMinValueX(), rightBorder]; this.renderXAxis(); this.renderYAxis(); this.renderGrid(); } } _renderDots(datapoints: number[][], serieIdx: number): void { const customClass = this.series[serieIdx].class || ''; this.metricContainer.selectAll(null) .data(datapoints) .enter() .append('circle') .attr('class', `metric-circle-${serieIdx} metric-el ${customClass}`) .attr('fill', this.getSerieColor(serieIdx)) .attr('r', METRIC_CIRCLE_RADIUS) .style('pointer-events', 'none') .attr('cx', d => this.xScale(d[0])) .attr('cy', d => this.yScale(d[1])); } _renderLines(datapoints: number[][], serieIdx: number): void { const dashArray = this.series[serieIdx].dashArray !== undefined ? this.series[serieIdx].dashArray : '0'; const customClass = this.series[serieIdx].class || '' this.metricContainer .append('path') .datum(datapoints) .attr('class', `metric-path-${serieIdx} metric-el ${customClass}`) .attr('fill', 'none') .attr('stroke', this.getSerieColor(serieIdx)) .attr('stroke-width', 1) .attr('stroke-opacity', 0.7) .attr('pointer-events', 'none') .style('stroke-dasharray', dashArray) .attr('d', this.lineGenerator); } _renderMetric( datapoints: number[][], metricOptions: { color: string, confidence: number, target: string, mode: Mode, serieIdx: number, renderDots: boolean, renderLines: boolean, } ): void { if(_.includes(this.seriesTargetsWithBounds, metricOptions.target)) { return; } if(metricOptions.mode === Mode.CHARGE) { const dataPairs = this.d3.pairs(datapoints); this.metricContainer.selectAll(null) .data(dataPairs) .enter() .append('line') .attr('x1', d => this.xScale(d[0][0])) .attr('x2', d => this.xScale(d[1][0])) .attr('y1', d => this.yScale(d[0][1])) .attr('y2', d => this.yScale(d[1][1])) .attr('stroke-opacity', 0.7) .style('stroke-width', 1) .style('stroke', d => { if(d[1][0] > d[0][0]) { return 'green'; } else if (d[1][0] < d[0][0]) { return 'red'; } else { return 'gray'; } }); return; } if(metricOptions.renderLines === true) { this._renderLines(datapoints, metricOptions.serieIdx); } if(metricOptions.renderDots === true) { this._renderDots(datapoints, metricOptions.serieIdx); } } updateCrosshair(): void { this.crosshair.selectAll('circle').remove(); // Core doesn't know anything about crosshair circles, It is only for line pod this.appendCrosshairCircles(); } appendCrosshairCircles(): void { // circle for each serie this.series.forEach((serie: LineTimeSerie, serieIdx: number) => { this.appendCrosshairCircle(serieIdx); }); } appendCrosshairCircle(serieIdx: number): void { // TODO: cx, cy - hacks to hide circles after zoom out(can be replaced with display: none) this.crosshair.append('circle') .attr('class', `crosshair-circle-${serieIdx} crosshair-background`) .attr('r', CROSSHAIR_BACKGROUND_RAIDUS) .attr('cx', -CROSSHAIR_BACKGROUND_RAIDUS) .attr('cy', -CROSSHAIR_BACKGROUND_RAIDUS) .attr('clip-path', `url(#${this.rectClipId})`) .attr('fill', this.getSerieColor(serieIdx)) .style('opacity', CROSSHAIR_BACKGROUND_OPACITY) .style('pointer-events', 'none'); this.crosshair .append('circle') .attr('cx', -CROSSHAIR_CIRCLE_RADIUS) .attr('cy', -CROSSHAIR_CIRCLE_RADIUS) .attr('class', `crosshair-circle-${serieIdx}`) .attr('clip-path', `url(#${this.rectClipId})`) .attr('fill', this.getSerieColor(serieIdx)) .attr('r', CROSSHAIR_CIRCLE_RADIUS) .style('pointer-events', 'none'); } public renderSharedCrosshair(values: { x?: number, y?: number }): void { this.onMouseOver(); // TODO: refactor to use it once const eventX = this.xScale(values.x); const eventY = this.yScale(values.y); this.moveCrosshairLine(eventX, eventY); const datapoints = this.findAndHighlightDatapoints(values.x, values.y); if(this.options.eventsCallbacks === undefined || this.options.eventsCallbacks.sharedCrosshairMove === undefined) { return; } this.options.eventsCallbacks.sharedCrosshairMove({ datapoints: datapoints, eventX, eventY }); } public hideSharedCrosshair(): void { this.crosshair.style('display', 'none'); } moveCrosshairLine(xPosition: number, yPosition: number): void { switch(this.options.crosshair.orientation) { case CrosshairOrientation.VERTICAL: this.crosshair.select('#crosshair-line-x') .attr('x1', xPosition) .attr('x2', xPosition); return; case CrosshairOrientation.HORIZONTAL: this.crosshair.select('#crosshair-line-y') .attr('y1', yPosition) .attr('y2', yPosition); return; case CrosshairOrientation.BOTH: this.crosshair.select('#crosshair-line-x') .attr('x1', xPosition) .attr('x2', xPosition); this.crosshair.select('#crosshair-line-y') .attr('y1', yPosition) .attr('y2', yPosition); return; default: throw new Error(`Unknown type of crosshair orientaion: ${this.options.crosshair.orientation}`); } } moveCrosshairCircle(xPosition: number, yPosition: number, serieIdx: number): void { this.crosshair.selectAll(`.crosshair-circle-${serieIdx}`) .attr('cx', xPosition) .attr('cy', yPosition) .style('display', null); } hideCrosshairCircle(serieIdx: number): void { // hide circle for singe serie this.crosshair.selectAll(`.crosshair-circle-${serieIdx}`) .style('display', 'none'); } getClosestDatapoint(serie: LineTimeSerie, xValue: number, yValue: number): [number, number] { // get closest datapoint to the "xValue"/"yValue" in the "serie" const datapoints = serie.datapoints; const closestIdx = this.getClosestIndex(datapoints, xValue, yValue); const datapoint = serie.datapoints[closestIdx]; return datapoint; } getClosestIndex(datapoints: [number, number][], xValue: number, yValue: number): number { let columnIdx; // 0 for x value, 1 for y value, let value; // xValue to y Value switch(this.options.crosshair.orientation) { case CrosshairOrientation.VERTICAL: columnIdx = 0; value = xValue; break; case CrosshairOrientation.HORIZONTAL: columnIdx = 1; value = yValue; break; case CrosshairOrientation.BOTH: // TODO: maybe use voronoi columnIdx = 1; value = yValue; default: throw new Error(`Unknown type of crosshair orientaion: ${this.options.crosshair.orientation}`); } // TODO: d3.bisect is not the best way. Use binary search const bisectIndex = this.d3.bisector((d: [number, number]) => d[columnIdx]).left; let closestIdx = bisectIndex(datapoints, value); // TODO: refactor corner cases if(closestIdx < 0) { return 0; } if(closestIdx >= datapoints.length) { return datapoints.length - 1; } // TODO: do we realy need it? Binary search should fix it if( closestIdx > 0 && Math.abs(value - datapoints[closestIdx - 1][columnIdx]) < Math.abs(value - datapoints[closestIdx][columnIdx]) ) { closestIdx -= 1; } return closestIdx; } getValueInterval(columnIdx: number): number | undefined { // columnIdx: 1 for y, 0 for x // inverval: x/y value interval between data points // TODO: move it to base/state instead of timeInterval const intervals = _.map(this.series, serie => { if(serie.datapoints.length < 2) { return undefined; } const start = _.head(serie.datapoints)[columnIdx]; const end = _.last(serie.datapoints)[columnIdx]; const range = Math.abs(end - start); const interval = range / (serie.datapoints.length - 1); return interval; }); return _.max(intervals); } onMouseMove(): void { const eventX = this.d3.mouse(this.chartContainer.node())[0]; const eventY = this.d3.mouse(this.chartContainer.node())[1]; const xValue = this.xScale.invert(eventX); // mouse x position in xScale const yValue = this.yScale.invert(eventY); // TODO: isOutOfChart is a hack, use clip path correctly if(this.isOutOfChart() === true) { this.crosshair.style('display', 'none'); return; } this.moveCrosshairLine(eventX, eventY); const datapoints = this.findAndHighlightDatapoints(xValue, yValue); if(this.options.eventsCallbacks === undefined || this.options.eventsCallbacks.mouseMove === undefined) { return; } // TDOO: is shift key pressed // TODO: need to refactor this object this.options.eventsCallbacks.mouseMove({ x: this.d3.event.pageX, y: this.d3.event.pageY, xVal: xValue, yVal: yValue, series: datapoints, chartX: eventX, chartWidth: this.width }); } findAndHighlightDatapoints(xValue: number, yValue: number): { value: [number, number], color: string, label: string }[] { if(this.series === undefined || this.series.length === 0) { return []; } let points = []; // datapoints in each metric that is closest to xValue/yValue position this.series.forEach((serie: LineTimeSerie, serieIdx: number) => { if( serie.visible === false || _.includes(this.seriesTargetsWithBounds, serie.target) ) { this.hideCrosshairCircle(serieIdx); return; } const closestDatapoint = this.getClosestDatapoint(serie, xValue, yValue); if(closestDatapoint === undefined || this.isOutOfRange(closestDatapoint, xValue, yValue, serie.useOutOfRange)) { this.hideCrosshairCircle(serieIdx); return; } const xPosition = this.xScale(closestDatapoint[0]); const yPosition = this.yScale(closestDatapoint[1]); this.moveCrosshairCircle(xPosition, yPosition, serieIdx); points.push({ value: closestDatapoint, color: this.getSerieColor(serieIdx), label: serie.alias || serie.target }); }); return points; } isOutOfRange(closestDatapoint: [number, number], xValue: number, yValue: number, useOutOfRange = true): boolean { // find is mouse position more than xRange/yRange from closest point // TODO: refactor getValueInterval to remove this! if(useOutOfRange === false) { return false; } let columnIdx; // 1 for y value, 0 for x value let value; // xValue ot y Value switch(this.options.crosshair.orientation) { case CrosshairOrientation.VERTICAL: columnIdx = 0; value = xValue; break; case CrosshairOrientation.HORIZONTAL: columnIdx = 1; value = yValue; break; case CrosshairOrientation.BOTH: // TODO: maybe use voronoi columnIdx = 1; value = yValue; default: throw new Error(`Unknown type of crosshair orientaion: ${this.options.crosshair.orientation}`); } const range = Math.abs(closestDatapoint[columnIdx] - value); const interval = this.getValueInterval(columnIdx); // interval between points // do not move crosshair circles, it mouse to far from closest point return interval === undefined || range > interval / 2; } onMouseOver(): void { this.crosshair.style('display', null); this.crosshair.selectAll('.crosshair-circle') .style('display', null); } onMouseOut(): void { if(this.options.eventsCallbacks !== undefined && this.options.eventsCallbacks.mouseOut !== undefined) { this.options.eventsCallbacks.mouseOut(); } this.crosshair.style('display', 'none'); } isDoubleClickActive(): boolean { if(this.options.zoomEvents.mouse.doubleClick === undefined) { return false; } return this.options.zoomEvents.mouse.doubleClick.isActive; } // methods below rewrite cores, (move more methods here) protected zoomOut(): void { // TODO: test to remove, seems its depricated if(this.isOutOfChart() === true) { return; } if(this.d3.event.type === 'dblclick' && !this.isDoubleClickActive()) { return; } // TODO: its not clear, why we use this orientation here. Mb its better to use separate option const orientation: BrushOrientation = this.options.zoomEvents.mouse.zoom.orientation; const xInterval = this.state.xValueRange[1] - this.state.xValueRange[0]; const yInterval = this.state.yValueRange[1] - this.state.yValueRange[0]; switch(orientation) { case BrushOrientation.HORIZONTAL: this.state.xValueRange = [this.state.xValueRange[0] - xInterval / 2, this.state.xValueRange[1] + xInterval / 2]; break; case BrushOrientation.VERTICAL: this.state.yValueRange = [this.state.yValueRange[0] - yInterval / 2, this.state.yValueRange[1] + yInterval / 2]; break; case BrushOrientation.RECTANGLE: this.state.xValueRange = [this.state.xValueRange[0] - xInterval / 2, this.state.xValueRange[1] + xInterval / 2]; this.state.yValueRange = [this.state.yValueRange[0] - yInterval / 2, this.state.yValueRange[1] + yInterval / 2]; break; case BrushOrientation.SQUARE: this.state.xValueRange = [this.state.xValueRange[0] - xInterval / 2, this.state.xValueRange[1] + xInterval / 2]; this.state.yValueRange = [this.state.yValueRange[0] - yInterval / 2, this.state.yValueRange[1] + yInterval / 2]; break; default: throw new Error(`Unknown type of orientation: ${orientation}, path: options.zoomEvents.mouse.zoom.orientation`);; } // TODO: it shouldn't be here. Add smth like vue watchers in core components. Optimize! this.renderMetrics(); this.renderXAxis(); this.renderYAxis(); this.renderGrid(); this.onMouseOver(); if(this.options.eventsCallbacks !== undefined && this.options.eventsCallbacks.zoomOut !== undefined) { let xAxisMiddleValue: number = this.xScale.invert(this.width / 2); let yAxisMiddleValue: number = this.yScale.invert(this.height / 2); const centers = { x: xAxisMiddleValue, y: yAxisMiddleValue } this.options.eventsCallbacks.zoomOut(centers); } } } // it is used with Vue.component, e.g.: Vue.component('chartwerk-line-pod', VueChartwerkLinePod) export const VueChartwerkLinePod = { // alternative to `template: '
'` render(createElement) { return createElement( 'div', { class: { 'chartwerk-line-pod': true }, attrs: { id: this.id } } ); }, mixins: [VueChartwerkPodMixin], methods: { render() { if(this.pod === undefined) { this.pod = new LinePod(document.getElementById(this.id), this.series, this.options); this.pod.render(); } else { this.pod.updateData(this.series, this.options); } }, renderSharedCrosshair(values) { this.pod.renderSharedCrosshair(values); }, hideSharedCrosshair() { this.pod.hideSharedCrosshair(); } } }; export { LineTimeSerie, LineOptions, Mode, TickOrientation, TimeFormat };