import { ChartwerkPod, VueChartwerkPodMixin, TimeFormat, yAxisOrientation, CrosshairOrientation } from '@chartwerk/core'; import { ScatterData, ScatterOptions, PointType, LineType, HighlightedData, MouseMoveEvent, DelaunayDataRow } from './types'; import { DelaunayDiagram } from './models/delaunay'; import { ScatterSeries } from './models/scatter_series'; import * as d3 from 'd3'; import * as _ from 'lodash'; const POINT_HIGHLIGHT_DIAMETER = 4; const CROSSHAIR_BACKGROUND_OPACITY = 0.3; const DEFAULT_LINE_DASHED_AMOUNT = 4; export class ChartwerkScatterPod extends ChartwerkPod { metricContainer: any; _delaunayDiagram: DelaunayDiagram; series: ScatterSeries; constructor(el: HTMLElement, _series: ScatterData[] = [], _options: ScatterOptions = {}) { super(el, _series, _options); this.series = new ScatterSeries(_series); } renderMetrics(): void { if(!this.series.isSeriesAvailable) { this.renderNoDataPointsMessage(); return; } this.updateCrosshair(); this._delaunayDiagram = new DelaunayDiagram(this.series, this.state.xScale, this.getYScale.bind(this)); this.renderLines(); this.renderPoints(); } clearAllMetrics(): void { // TODO: temporary hack before it will be implemented in core. this.chartContainer.selectAll('.metric-el').remove(); } protected updateCrosshair(): void { this.crosshair.selectAll('circle').remove(); // TODO: Crosshair class, which can be used as Pod this.appendCrosshairPoints(); } appendCrosshairPoints(): void { this.series.visibleSeries.forEach((serie: ScatterData) => { this.appendCrosshairPoint(serie.idx, serie.pointType, serie.pointSize); }); } protected appendCrosshairPoint(serieIdx: number, pointType: PointType, size: number): void { // TODO: add Crosshair type options switch(pointType) { case PointType.NONE: return; case PointType.CIRCLE: this.crosshair.append('circle') .attr('class', `crosshair-point crosshair-point-${serieIdx} crosshair-background`) .attr('r', this.getCrosshairCircleBackgroundSize(size, pointType)) .attr('clip-path', `url(#${this.rectClipId})`) .style('opacity', CROSSHAIR_BACKGROUND_OPACITY) .style('pointer-events', 'none') .style('display', 'none'); return; case PointType.RECTANGLE: this.crosshair.append('rect') .attr('class', `crosshair-point crosshair-point-${serieIdx} crosshair-background`) .attr('width', this.getCrosshairCircleBackgroundSize(size, pointType)) .attr('height', this.getCrosshairCircleBackgroundSize(size, pointType)) .attr('clip-path', `url(#${this.rectClipId})`) .style('opacity', CROSSHAIR_BACKGROUND_OPACITY) .style('pointer-events', 'none') .style('display', 'none'); return; default: throw new Error(`Unknown render point type: ${pointType}`); } } protected renderLines(): void { this.series.visibleSeries.forEach(serie => { this.renderLine(serie); }); } renderLine(serie: ScatterData): void { if(serie.lineType === LineType.NONE) { return; } let strokeDasharray; // TODO: move to option if(serie.lineType === LineType.DASHED) { strokeDasharray = DEFAULT_LINE_DASHED_AMOUNT; } const lineGenerator = d3.line() .x((d: [number, number]) => this.state.xScale(d[0])) .y((d: [number, number]) => this.getYScale(serie.yOrientation)(d[1])); this.metricContainer .append('path') .datum(serie.datapoints) .attr('class', 'metric-path') .attr('d', lineGenerator) .attr('fill', 'none') .attr('stroke', serie.color) .attr('stroke-width', 1) .attr('stroke-opacity', 0.7) .attr('stroke-dasharray', strokeDasharray) .style('pointer-events', serie.clickCallback ? 'auto' : 'none') .style('cursor', serie.clickCallback ? 'pointer' : 'crosshair') .on('click', () => { serie.clickCallback({ target: serie.target, class: serie.class, alias: serie.alias }) }); } protected renderPoints(): void { if(!this._delaunayDiagram.data) { return; } this.metricContainer.selectAll(null) .data(this._delaunayDiagram.data) .enter() .append('circle') .filter((d: DelaunayDataRow) => this.series.getSerieByTarget(d[4])?.pointType !== PointType.RECTANGLE) .attr('class', (d, i: number) => `metric-element metric-circle point-${i}`) .attr('r', (d: DelaunayDataRow) => this.series.getSerieByTarget(d[4])?.pointSize) .attr('cx', (d: DelaunayDataRow) => this.state.xScale(d[0])) .attr('cy', (d: DelaunayDataRow) => this.getYScale(this.series.getSerieByTarget(d[4])?.yOrientation)(d[1])) .style('fill', (d: DelaunayDataRow, i: number) => this.getSeriesColorFromDataRow(d, i)) .style('pointer-events', (d: DelaunayDataRow) => this.series.getSerieByTarget(d[4])?.clickCallback ? 'auto' : 'none') .style('cursor', (d: DelaunayDataRow) => this.series.getSerieByTarget(d[4])?.clickCallback ? 'pointer' : 'crosshair') .on('click', (d: DelaunayDataRow) => { d3.event.stopPropagation(); const serie = this.series.getSerieByTarget(d[4]); const serieData = { target: serie?.target, class: serie?.class, alias: serie?.alias }; serie?.clickCallback(serieData, d); }); this.metricContainer.selectAll(null) .data(this._delaunayDiagram.data) .enter() .append('rect') .filter((d: DelaunayDataRow) => this.series.getSerieByTarget(d[4])?.pointType === PointType.RECTANGLE) .attr('class', (d, i: number) => `metric-element metric-circle point-${i}`) .attr('r', (d: DelaunayDataRow) => this.series.getSerieByTarget(d[4])?.pointSize) .attr('x', (d: DelaunayDataRow) => this.state.xScale(d[0]) - this.series.getSerieByTarget(d[4])?.pointSize / 2) .attr('y', (d: DelaunayDataRow) => this.getYScale(this.series.getSerieByTarget(d[4])?.yOrientation)(d[1]) - this.series.getSerieByTarget(d[4])?.pointSize / 2) .attr('width', (d: DelaunayDataRow) => this.series.getSerieByTarget(d[4])?.pointSize) .attr('height', (d: DelaunayDataRow) => this.series.getSerieByTarget(d[4])?.pointSize) .style('fill', (d: DelaunayDataRow) => this.series.getSerieByTarget(d[4])?.color) .style('pointer-events', (d: DelaunayDataRow) => this.series.getSerieByTarget(d[4])?.clickCallback ? 'auto' : 'none') .style('cursor', (d: DelaunayDataRow) => this.series.getSerieByTarget(d[4])?.clickCallback ? 'pointer' : 'crosshair') .on('click', (d: DelaunayDataRow) => { d3.event.stopPropagation(); const serie = this.series.getSerieByTarget(d[4]); const serieData = { target: serie?.target, class: serie?.class, alias: serie?.alias }; serie?.clickCallback(serieData, d); }); } getSeriesColorFromDataRow(values: DelaunayDataRow, rowIdx: number): string { const serie = this.series.getSerieByTarget(values[4]); if(serie?.colorFormatter) { return serie.colorFormatter(values, rowIdx); } return serie.color; } onPanningEnd(): void { this.isPanning = false; this.onMouseOut(); this._delaunayDiagram.setDelaunayDiagram(this.state.xScale, this.getYScale.bind(this)); this.options.callbackPanningEnd([this.state.xValueRange, this.state.yValueRange, this.state.y1ValueRange]); } unhighlight(): void { this.crosshair.selectAll('.crosshair-point').style('display', 'none'); } highlight(pointIdx: number): void { this.unhighlight(); const row = this._delaunayDiagram.getDataRowByIndex(pointIdx); if(row === undefined || row === null) { return; } const serie = this.series.getSerieByTarget(row[4]); const size = this.getCrosshairCircleBackgroundSize(serie.pointSize, serie.pointType); this.crosshair.selectAll(`.crosshair-point-${serie.idx}`) .attr('cx', this.state.xScale(row[0])) .attr('cy', this.getYScale(serie.yOrientation)(row[1])) .attr('x', this.state.xScale(row[0]) - size / 2) .attr('y', this.getYScale(serie.yOrientation)(row[1]) - size / 2) .attr('fill', this.getSeriesColorFromDataRow(row, pointIdx)) .style('display', null); } protected getCrosshairCircleBackgroundSize(pointSize: number, pointType: PointType): number { let highlightDiameter = POINT_HIGHLIGHT_DIAMETER; if(pointType === PointType.RECTANGLE) { highlightDiameter = highlightDiameter * 2; } return pointSize + highlightDiameter; } 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, eventX, eventY }); } 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}`); } } findAndHighlightDatapoints(eventX: number, eventY: number): { xValue: number, yValue: number, customValue: number, pointIdx: number, totalPointIdx: number, serieInfo: { target: string, alias?: string, class?: string, idx?: number } } | null { if(!this.series.isSeriesAvailable) { return null; } const pointIndex = this._delaunayDiagram.findPointIndex(eventX, eventY); if(pointIndex === undefined) { this.unhighlight(); return null; } this.highlight(pointIndex); const row = this._delaunayDiagram.data[pointIndex]; const serie = this.series.getSerieByTarget(row[4]); return { xValue: row[0], yValue: row[1], customValue: row[2], pointIdx: row[3], serieInfo: { target: serie.target, alias: serie.alias, class: serie.class, idx: serie.idx, }, totalPointIdx: pointIndex, }; } protected getYScale(orientation: yAxisOrientation): d3.ScaleLinear { switch(orientation) { case yAxisOrientation.LEFT: return this.state.yScale; case yAxisOrientation.RIGHT: return this.state.y1Scale; default: throw new Error(`Unknown type of y axis orientation: ${orientation}`) } } public hideSharedCrosshair(): void { this.crosshair.style('display', 'none'); } onMouseMove(): void { const mousePosition = d3.mouse(this.chartContainer.node()); const eventX = mousePosition[0]; const eventY = mousePosition[1]; if(this.isPanning === true || this.isBrushing === true) { this.crosshair.style('display', 'none'); return; } else { this.crosshair.style('display', null); } this.moveCrosshairLine(eventX, eventY); // TOOD: it should be two different methods const highlighted = this.findAndHighlightDatapoints(eventX, eventY); this.options.callbackMouseMove({ bbox: { clientX: d3.event.clientX, clientY: d3.event.clientY, x: eventX, y: eventY, chartWidth: this.width, chartHeight: this.height, }, data: { xval: this.state.xScale.invert(eventX), yval: this.state.xScale.invert(eventY), highlighted, } }); } onMouseOver(): void { if(this.isPanning === true || this.isBrushing === true) { this.crosshair.style('display', 'none'); return; } this.crosshair.style('display', null); } onMouseOut(): void { this.options.callbackMouseOut(); this.crosshair.style('display', 'none'); } } // it is used with Vue.component, e.g.: Vue.component('chartwerk-scatter-pod', VueChartwerkScatterPodObject) export const VueChartwerkScatterPodObject = { // alternative to `template: '
'` render(createElement) { return createElement( 'div', { class: { 'chartwerk-scatter-pod': true }, attrs: { id: this.id } } ); }, mixins: [VueChartwerkPodMixin], methods: { render() { if(this.pod === undefined) { this.pod = new ChartwerkScatterPod(document.getElementById(this.id), this.series, this.options); this.pod.render(); } else { this.pod.updateData(this.series, this.options); } }, } }; export { ScatterData, ScatterOptions, TimeFormat, PointType, LineType, HighlightedData, MouseMoveEvent };