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.
473 lines
16 KiB
473 lines
16 KiB
import { ChartwerkPod, VueChartwerkPodMixin, TimeFormat, CrosshairOrientation, BrushOrientation, yAxisOrientation } from '@chartwerk/core'; |
|
import { LineTimeSerie, LineOptions, MouseObj } from './types'; |
|
import { Markers } from './components/markers'; |
|
import { Segments } from './components/segments'; |
|
|
|
import { LineSeries } from './models/line_series'; |
|
import { MarkersConf } from './models/marker'; |
|
|
|
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; |
|
|
|
|
|
class LinePod extends ChartwerkPod<LineTimeSerie, LineOptions> { |
|
lineGenerator = null; |
|
areaGenerator = null; |
|
lineGeneratorY1 = null; |
|
areaGeneratorY1 = null; |
|
|
|
private _markersLayer: Markers = null; |
|
private _segmentsLayer: Segments = null; |
|
|
|
constructor( |
|
_el: HTMLElement, |
|
_series: LineTimeSerie[] = [], |
|
_options: LineOptions = {}, |
|
private _markersConf?: MarkersConf, |
|
private _segmentSeries = [], |
|
) { |
|
super(_el, _series, _options); |
|
this.series = new LineSeries(_series); |
|
} |
|
|
|
override 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); |
|
} |
|
if(this._markersConf !== undefined) { |
|
this._markersLayer = new Markers(this._markersConf, this.state); |
|
this._markersLayer.render(this.metricContainer); |
|
} |
|
|
|
this._segmentsLayer = new Segments(this._segmentSeries, this.state); |
|
this._segmentsLayer.render(this.metricContainer); |
|
} |
|
|
|
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 hideSharedCrosshair(): void { |
|
this.crosshair.style('display', 'none'); |
|
} |
|
|
|
// TODO: refactor to make xPosition and yPosition optional |
|
// and trough error if they are provided for wrong orientation |
|
moveCrosshairLine(xPosition: number, yPosition: number): void { |
|
this.crosshair.style('display', null); |
|
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); |
|
} |
|
|
|
getMouseObj(): MouseObj { |
|
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); |
|
|
|
const datapoints = this.findAndHighlightDatapoints(xValue, yValue); |
|
|
|
// TODO: is shift key pressed |
|
// TODO: need to refactor this object |
|
return { |
|
x: d3.event.pageX, |
|
y: d3.event.pageY, |
|
xVal: xValue, |
|
yVal: yValue, |
|
series: datapoints, |
|
chartX: eventX, |
|
chartWidth: this.width |
|
}; |
|
} |
|
|
|
override onMouseMove(): void { |
|
const obj = this.getMouseObj(); |
|
const eventX = d3.mouse(this.chartContainer.node())[0]; |
|
const eventY = d3.mouse(this.chartContainer.node())[1]; |
|
|
|
this.moveCrosshairLine(eventX, eventY); |
|
|
|
// TODO: is shift key pressed |
|
// TODO: need to refactor this object |
|
this.options.callbackMouseMove(obj); |
|
} |
|
|
|
public renderSharedCrosshair(values: { x?: number, y?: number }): void { |
|
this.showCrosshair(); |
|
this.moveCrosshairLine( |
|
values.x ? this.state.xScale(values.x) : 0, |
|
values.y ? this.state.yScale(values.y) : 0 |
|
); |
|
const datapoints = this.findAndHighlightDatapoints(values.x, values.y); |
|
|
|
this.options.callbackSharedCrosshairMove({ |
|
datapoints: datapoints, |
|
eventX: values.x ? this.state.xScale(values.x) : 0, |
|
eventY: values.y ? this.state.yScale(values.y) : 0 |
|
}); |
|
} |
|
|
|
override onMouseClick(): void { |
|
this.options.callbackMouseClick(this.getMouseObj()); |
|
} |
|
|
|
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]); |
|
let yPosition; |
|
if(serie.yOrientation === yAxisOrientation.RIGHT) { |
|
yPosition = this.state.y1Scale(closestDatapoint[1]); |
|
} else { |
|
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; |
|
} |
|
|
|
showCrosshair() { |
|
this.crosshair.style('display', null); |
|
this.crosshair.selectAll('.crosshair-circle') |
|
.style('display', null); |
|
} |
|
|
|
hideCrosshair() { |
|
this.crosshair.style('display', 'none'); |
|
this.crosshair.selectAll('.crosshair-circle') |
|
.style('display', 'none'); |
|
} |
|
|
|
override onMouseOver(): void { |
|
this.showCrosshair(); |
|
this.onMouseMove(); |
|
} |
|
|
|
override onMouseOut(): void { |
|
this.hideCrosshair(); |
|
this.options.callbackMouseOut(); |
|
} |
|
|
|
isDoubleClickActive(): boolean { |
|
return this.options.doubleClickEvent.isActive; |
|
} |
|
|
|
protected onBrushEnd(): void { |
|
super.onBrushEnd(); |
|
this.onMouseOver(); |
|
} |
|
|
|
// 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); |
|
} |
|
} |
|
|
|
// TODO: it should be moved to VUE folder |
|
// it is used with Vue.component, e.g.: Vue.component('chartwerk-line-pod', VueChartwerkLinePod) |
|
export const VueChartwerkLinePod = { |
|
// alternative to `template: '<div class="chartwerk-line-pod" :id="id" />'` |
|
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, LinePod };
|
|
|