|
|
|
import { ChartwerkPod, VueChartwerkPodMixin, TimeFormat, yAxisOrientation, CrosshairOrientation } from '@chartwerk/core';
|
|
|
|
import { ScatterData, ScatterOptions, PointType, LineType } 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;
|
|
|
|
|
|
|
|
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.visibleSeries, 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('fill', 'none')
|
|
|
|
.style('pointer-events', 'none')
|
|
|
|
.attr('stroke', serie.color)
|
|
|
|
.attr('stroke-width', 1)
|
|
|
|
.attr('stroke-opacity', 0.7)
|
|
|
|
.attr('stroke-dasharray', strokeDasharray)
|
|
|
|
.attr('d', lineGenerator);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected renderPoints(): void {
|
|
|
|
if(!this._delaunayDiagram.data) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.metricContainer.selectAll(null)
|
|
|
|
.data(this._delaunayDiagram.data)
|
|
|
|
.enter()
|
|
|
|
.append('circle')
|
|
|
|
.filter((d: number[]) => this.getSerieByIdx(_.last(d))?.pointType !== PointType.RECTANGLE)
|
|
|
|
.attr('class', (d, i: number) => `metric-element metric-circle point-${i}`)
|
|
|
|
.attr('r', (d: number[]) => this.getSerieByIdx(_.last(d))?.pointSize)
|
|
|
|
.style('fill', (d: number[], i: number) => this.getSeriesColorFromDataRow(d, i))
|
|
|
|
.style('pointer-events', 'none')
|
|
|
|
.attr('cx', (d: any[]) => this.state.xScale(d[0]))
|
|
|
|
.attr('cy', (d: any[]) => this.getYScale(this.getSerieByIdx(_.last(d))?.yOrientation)(d[1]));
|
|
|
|
|
|
|
|
this.metricContainer.selectAll(null)
|
|
|
|
.data(this._delaunayDiagram.data)
|
|
|
|
.enter()
|
|
|
|
.append('rect')
|
|
|
|
.filter((d: number[]) => this.getSerieByIdx(_.last(d))?.pointType === PointType.RECTANGLE)
|
|
|
|
.attr('class', (d, i: number) => `metric-element metric-circle point-${i}`)
|
|
|
|
.attr('r', (d: number[]) => this.getSerieByIdx(_.last(d))?.pointSize)
|
|
|
|
.style('fill', (d: number[]) => this.getSerieByIdx(_.last(d))?.color)
|
|
|
|
.style('pointer-events', 'none')
|
|
|
|
.attr('x', (d: number[]) => this.state.xScale(d[0]) - this.getSerieByIdx(_.last(d))?.pointSize / 2)
|
|
|
|
.attr('y', (d: number[]) => this.getYScale(this.getSerieByIdx(_.last(d)).yOrientation)(d[1]) - this.getSerieByIdx(_.last(d))?.pointSize / 2)
|
|
|
|
.attr('width', (d: number[]) => this.getSerieByIdx(_.last(d))?.pointSize)
|
|
|
|
.attr('height', (d: number[]) => this.getSerieByIdx(_.last(d))?.pointSize);
|
|
|
|
}
|
|
|
|
|
|
|
|
getSerieByIdx(idx: number): ScatterData | undefined {
|
|
|
|
return _.find(this.series.visibleSeries, serie => serie.idx === idx);
|
|
|
|
}
|
|
|
|
|
|
|
|
getSeriesColorFromDataRow(values: number[], rowIdx: number): string {
|
|
|
|
const seriesIdx = _.last(values);
|
|
|
|
const serie = this.getSerieByIdx(seriesIdx);
|
|
|
|
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 datapoint = this._delaunayDiagram.getDataRowByIndex(pointIdx);
|
|
|
|
if(datapoint === undefined || datapoint === null) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const serieIdx = _.last(datapoint);
|
|
|
|
const serie = this.getSerieByIdx(serieIdx);
|
|
|
|
const size = this.getCrosshairCircleBackgroundSize(serie.pointSize, serie.pointType);
|
|
|
|
this.crosshair.selectAll(`.crosshair-point-${serieIdx}`)
|
|
|
|
.attr('cx', this.state.xScale(datapoint[0]))
|
|
|
|
.attr('cy', this.getYScale(serie.yOrientation)(datapoint[1]))
|
|
|
|
.attr('x', this.state.xScale(datapoint[0]) - size / 2)
|
|
|
|
.attr('y', this.getYScale(serie.yOrientation)(datapoint[1]) - size / 2)
|
|
|
|
.attr('fill', this.getSeriesColorFromDataRow(datapoint, 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): { values: any[], pointIdx: 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);
|
|
|
|
|
|
|
|
return {
|
|
|
|
values: this._delaunayDiagram.data[pointIndex],
|
|
|
|
pointIdx: 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);
|
|
|
|
|
|
|
|
// TODO: group fields
|
|
|
|
this.options.callbackMouseMove({
|
|
|
|
x: d3.event.clientX,
|
|
|
|
y: d3.event.clientY,
|
|
|
|
xval: this.state.xScale.invert(eventX),
|
|
|
|
yval: this.state.xScale.invert(eventY),
|
|
|
|
highlighted,
|
|
|
|
chartX: eventX,
|
|
|
|
chartWidth: this.width
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
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 };
|