Scatter Pod
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

364 lines
13 KiB

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<ScatterData, ScatterOptions> {
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<number, number> {
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: '<div class="chartwerk-scatter-pod" :id="id" />'`
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 };