import { ChartwerkPod, VueChartwerkPodMixin, TimeFormat, CrosshairOrientation, BrushOrientation, yAxisOrientation } from '@chartwerk/core'; import { LineTimeSerie, LineOptions } from './types'; import { LineSeries } from './models/line_series'; 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; areaGenerator = null; lineGeneratorY1 = null; areaGeneratorY1 = null; constructor(_el: HTMLElement, _series: LineTimeSerie[] = [], _options: LineOptions = {}) { super(_el, _series, _options); this.series = new LineSeries(_series); } renderMetrics(): void { this.clearAllMetrics(); this.updateCrosshair(); this.initLineGenerator(); this.initAreaGenerator(); if(!this.series.isSeriesAvailable) { this.renderNoDataPointsMessage(); return; } for(const serie of this.series.visibleSeries) { this._renderMetric(serie); } } clearAllMetrics(): void { // TODO: temporary hack before it will be implemented in core. this.chartContainer.selectAll('.metric-el').remove(); } initLineGenerator(): void { this.lineGenerator = d3.line() .x(d => this.state.xScale(d[0])) .y(d => this.state.yScale(d[1])); this.lineGeneratorY1 = d3.line() .x(d => this.state.xScale(d[0])) .y(d => this.state.y1Scale(d[1])); } initAreaGenerator(): void { this.areaGenerator = d3.area() .x(d => this.state.xScale(d[0])) .y1(d => this.state.yScale(d[1])) .y0(d => this.height); this.areaGeneratorY1 = d3.area() .x(d => this.state.xScale(d[0])) .y1(d => this.state.y1Scale(d[1])) .y0(d => this.height); } getRenderGenerator(renderArea: boolean, yOrientation: yAxisOrientation): any { if(renderArea) { return yOrientation === yAxisOrientation.LEFT ? this.areaGenerator : this.areaGeneratorY1; } return yOrientation === yAxisOrientation.LEFT ? this.lineGenerator : this.areaGeneratorY1; } _renderDots(serie: LineTimeSerie): void { this.metricContainer.selectAll(null) .data(serie.datapoints) .enter() .append('circle') .attr('class', `metric-circle-${serie.idx} metric-el ${serie.class}`) .attr('fill', serie.color) .attr('r', METRIC_CIRCLE_RADIUS) .style('pointer-events', 'none') .attr('cx', d => this.state.xScale(d[0])) .attr('cy', d => this.state.yScale(d[1])); } _renderLines(serie: LineTimeSerie): void { const fillColor = serie.renderArea ? serie.color : 'none'; const fillOpacity = serie.renderArea ? 0.5 : 'none'; this.metricContainer .append('path') .datum(serie.datapoints) .attr('class', `metric-path-${serie.idx} metric-el ${serie.class}`) .attr('fill', fillColor) .attr('fill-opacity', fillOpacity) .attr('stroke', serie.color) .attr('stroke-width', 1) .attr('stroke-opacity', 0.7) .attr('pointer-events', 'none') .style('stroke-dasharray', serie.dashArray) .attr('d', this.getRenderGenerator(serie.renderArea, serie.yOrientation)); } _renderMetric(serie: LineTimeSerie): void { if(serie.renderLines === true) { this._renderLines(serie); } if(serie.renderDots === true) { this._renderDots(serie); } } 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.visibleSeries.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.series.visibleSeries[serieIdx].color) .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.series.visibleSeries[serieIdx].color) .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.state.xScale(values.x); const eventY = this.state.yScale(values.y); this.moveCrosshairLine(eventX, eventY); const datapoints = this.findAndHighlightDatapoints(values.x, values.y); this.options.callbackSharedCrosshairMove({ 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 = 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.visibleSeries, 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 = d3.mouse(this.chartContainer.node())[0]; const eventY = d3.mouse(this.chartContainer.node())[1]; const xValue = this.state.xScale.invert(eventX); // mouse x position in xScale const yValue = this.state.yScale.invert(eventY); this.moveCrosshairLine(eventX, eventY); const datapoints = this.findAndHighlightDatapoints(xValue, yValue); // TDOO: is shift key pressed // TODO: need to refactor this object this.options.callbackMouseMove({ x: d3.event.pageX, y: 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.isSeriesAvailable) { return []; } let points = []; // datapoints in each metric that is closest to xValue/yValue position this.series.visibleSeries.forEach((serie: LineTimeSerie) => { const closestDatapoint = this.getClosestDatapoint(serie, xValue, yValue); if(_.isNil(closestDatapoint) || _.isNil(closestDatapoint[0])) { this.hideCrosshairCircle(serie.idx); } else { const xPosition = this.state.xScale(closestDatapoint[0]); const yPosition = this.state.yScale(closestDatapoint[1]); this.moveCrosshairCircle(xPosition, yPosition, serie.idx); } points.push({ value: closestDatapoint, color: serie.color, label: serie.alias || serie.target }); }); return points; } onMouseOver(): void { this.crosshair.style('display', null); this.crosshair.selectAll('.crosshair-circle') .style('display', null); } onMouseOut(): void { this.options.callbackMouseOut(); this.crosshair.style('display', 'none'); } isDoubleClickActive(): boolean { return this.options.doubleClickEvent.isActive; } // methods below rewrite s, (move more methods here) protected zoomOut(): void { if(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.mouseZoomEvent.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(); let xAxisMiddleValue: number = this.state.xScale.invert(this.width / 2); let yAxisMiddleValue: number = this.state.yScale.invert(this.height / 2); const centers = { x: xAxisMiddleValue, y: yAxisMiddleValue } this.options.callbackZoomOut(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, TimeFormat };