|
|
|
import VueChartwerkPodMixin from './VueChartwerkPodMixin';
|
|
|
|
import { Grid } from './components/grid';
|
|
|
|
|
|
|
|
import { PodState } from './models/state';
|
|
|
|
import { CoreSeries, CORE_SERIE_DEFAULTS } from './models/series';
|
|
|
|
import { CoreOptions, CORE_DEFAULT_OPTIONS } from './models/options';
|
|
|
|
|
|
|
|
import styles from './css/style.css';
|
|
|
|
|
|
|
|
import {
|
|
|
|
Margin,
|
|
|
|
Serie,
|
|
|
|
Options,
|
|
|
|
TimeFormat,
|
|
|
|
BrushOrientation,
|
|
|
|
AxisFormat,
|
|
|
|
CrosshairOrientation,
|
|
|
|
SvgElementAttributes,
|
|
|
|
KeyEvent,
|
|
|
|
PanOrientation,
|
|
|
|
yAxisOrientation,
|
|
|
|
ScrollPanOrientation,
|
|
|
|
ScrollPanDirection,
|
|
|
|
AxisOption,
|
|
|
|
AxesOptions,
|
|
|
|
} from './types';
|
|
|
|
import { uid } from './utils';
|
|
|
|
import { palette } from './colors';
|
|
|
|
|
|
|
|
import * as d3 from 'd3';
|
|
|
|
|
|
|
|
import first from 'lodash/first';
|
|
|
|
import last from 'lodash/last';
|
|
|
|
import debounce from 'lodash/debounce';
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_TICK_COUNT = 4;
|
|
|
|
const DEFAULT_TICK_SIZE = 2;
|
|
|
|
const MILISECONDS_IN_MINUTE = 60 * 1000;
|
|
|
|
|
|
|
|
abstract class ChartwerkPod<T extends Serie, O extends Options> {
|
|
|
|
|
|
|
|
protected series: CoreSeries<T>;
|
|
|
|
protected options: CoreOptions<O>;
|
|
|
|
|
|
|
|
protected d3Node?: d3.Selection<HTMLElement, unknown, null, undefined>;
|
|
|
|
protected overlay?: d3.Selection<SVGRectElement, unknown, null, undefined>;
|
|
|
|
protected crosshair?: d3.Selection<SVGGElement, unknown, null, undefined>;
|
|
|
|
protected brush?: d3.BrushBehavior<unknown>;
|
|
|
|
protected zoom?: any;
|
|
|
|
protected svg?: d3.Selection<SVGElement, unknown, null, undefined>;
|
|
|
|
protected state: PodState<T, O>;
|
|
|
|
protected pan?: d3.ZoomBehavior<Element, unknown>;
|
|
|
|
protected clipPath?: any;
|
|
|
|
protected isPanning = false;
|
|
|
|
protected isBrushing = false;
|
|
|
|
protected brushStartSelection: [number, number] | null = null;
|
|
|
|
protected initScaleX?: d3.ScaleLinear<any, any>;
|
|
|
|
protected initScaleY?: d3.ScaleLinear<any, any>;
|
|
|
|
protected initScaleY1?: d3.ScaleLinear<any, any>;
|
|
|
|
protected xAxisElement?: d3.Selection<SVGGElement, unknown, null, undefined>;
|
|
|
|
protected yAxisElement?: d3.Selection<SVGGElement, unknown, null, undefined>;
|
|
|
|
protected y1AxisElement?: d3.Selection<SVGGElement, unknown, null, undefined>;
|
|
|
|
protected yAxisTicksColors?: string[] = [];
|
|
|
|
private _clipPathUID = '';
|
|
|
|
|
|
|
|
protected readonly d3: typeof d3;
|
|
|
|
protected deltaYTransform = 0;
|
|
|
|
// TODO: forceRerender is a hack, it will be remove someday. But we need to update state on resize
|
|
|
|
protected debouncedRender = debounce(this.forceRerender.bind(this), 100);
|
|
|
|
// containers
|
|
|
|
// TODO: add better names
|
|
|
|
protected chartContainer: d3.Selection<SVGGElement, unknown, null, undefined>;
|
|
|
|
protected metricContainer: d3.Selection<SVGGElement, unknown, null, undefined>;
|
|
|
|
// components
|
|
|
|
protected grid: Grid;
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
protected readonly el: HTMLElement,
|
|
|
|
_series: T[] = [],
|
|
|
|
_options: O
|
|
|
|
) {
|
|
|
|
// TODO: test if it's necessary
|
|
|
|
styles.use();
|
|
|
|
|
|
|
|
this.options = new CoreOptions(_options);
|
|
|
|
this.series = new CoreSeries(_series);
|
|
|
|
|
|
|
|
this.d3Node = d3.select(this.el);
|
|
|
|
this.addEventListeners();
|
|
|
|
|
|
|
|
this.createSvg();
|
|
|
|
this.initPodState();
|
|
|
|
this.initComponents();
|
|
|
|
}
|
|
|
|
|
|
|
|
protected addEventListeners(): void {
|
|
|
|
window.addEventListener('resize', this.debouncedRender);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected removeEventListeners(): void {
|
|
|
|
window.removeEventListener('resize', this.debouncedRender);
|
|
|
|
}
|
|
|
|
|
|
|
|
public render(): void {
|
|
|
|
this.options.callbackRenderStart();
|
|
|
|
|
|
|
|
this.renderClipPath();
|
|
|
|
this.addEvents();
|
|
|
|
|
|
|
|
this.renderAxes();
|
|
|
|
this.renderGrid();
|
|
|
|
|
|
|
|
this.renderCrosshair();
|
|
|
|
this.renderMetricsContainer();
|
|
|
|
this.renderMetrics();
|
|
|
|
|
|
|
|
this.renderLegend();
|
|
|
|
this.renderYLabel();
|
|
|
|
this.renderXLabel();
|
|
|
|
|
|
|
|
this.options.callbackRenderEnd();
|
|
|
|
}
|
|
|
|
|
|
|
|
public updateData(series?: T[], options?: O, shouldRerender = true): void {
|
|
|
|
this.updateSeries(series);
|
|
|
|
this.updateOptions(options);
|
|
|
|
if(shouldRerender) {
|
|
|
|
this.forceRerender();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public forceRerender(): void {
|
|
|
|
// TODO: it is a hack. It is not nessesary to recreate svg.
|
|
|
|
// the only purpose for this action, to clear d3.zoom.transform (see https://gitlab.com/chartwerk/core/-/issues/10)
|
|
|
|
this.createSvg();
|
|
|
|
this.initPodState();
|
|
|
|
this.initComponents();
|
|
|
|
this.render();
|
|
|
|
}
|
|
|
|
|
|
|
|
protected updateOptions(newOptions: O): void {
|
|
|
|
if(newOptions === undefined) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.options.updateOptions(newOptions);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected updateSeries(newSeries: T[]): void {
|
|
|
|
if(newSeries === undefined) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.series.updateSeries(newSeries);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected abstract renderMetrics(): void;
|
|
|
|
protected abstract onMouseOver(): void;
|
|
|
|
protected abstract onMouseOut(): void;
|
|
|
|
protected abstract onMouseMove(): void;
|
|
|
|
public abstract renderSharedCrosshair(values: { x?: number, y?: number }): void;
|
|
|
|
public abstract hideSharedCrosshair(): void;
|
|
|
|
|
|
|
|
protected initPodState(): void {
|
|
|
|
const boxPararms = {
|
|
|
|
height: this.height,
|
|
|
|
width: this.width,
|
|
|
|
}
|
|
|
|
this.state = new PodState(boxPararms, this.series, this.options);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected initComponents(): void {
|
|
|
|
// TODO: make chartContainer a separate class with SvgElParams inside to avoid duplication
|
|
|
|
// TODO: bad connection between State and Grid
|
|
|
|
const svgElParams = {
|
|
|
|
height: this.height,
|
|
|
|
width: this.width,
|
|
|
|
xScale: this.state.xScale,
|
|
|
|
yScale: this.state.yScale,
|
|
|
|
}
|
|
|
|
|
|
|
|
this.grid = new Grid(this.chartContainer, svgElParams, this.options.grid);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected renderMetricsContainer(): void {
|
|
|
|
this.chartContainer.select('.metrics-container').remove();
|
|
|
|
|
|
|
|
// TODO: it should be a class with svgElParams as fields
|
|
|
|
// container for clip path
|
|
|
|
const clipContatiner = this.chartContainer
|
|
|
|
.append('g')
|
|
|
|
.attr('clip-path', `url(#${this.rectClipId})`)
|
|
|
|
.attr('class', 'metrics-container');
|
|
|
|
|
|
|
|
// container for panning
|
|
|
|
this.metricContainer = clipContatiner
|
|
|
|
.append('g')
|
|
|
|
.attr('class', 'metrics-rect');
|
|
|
|
}
|
|
|
|
|
|
|
|
protected createSvg(): void {
|
|
|
|
this.d3Node.select('svg').remove();
|
|
|
|
this.svg = this.d3Node
|
|
|
|
.append('svg')
|
|
|
|
.style('width', '100%')
|
|
|
|
.style('height', '100%')
|
|
|
|
.style('backface-visibility', 'hidden');
|
|
|
|
this.chartContainer = this.svg
|
|
|
|
.append('g')
|
|
|
|
.attr('transform', `translate(${this.margin.left},${this.margin.top})`);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected renderGrid(): void {
|
|
|
|
this.grid.render();
|
|
|
|
}
|
|
|
|
|
|
|
|
protected renderAxes(): void {
|
|
|
|
// TODO: remove duplicates
|
|
|
|
this.renderXAxis();
|
|
|
|
this.renderYAxis();
|
|
|
|
this.renderY1Axis();
|
|
|
|
}
|
|
|
|
|
|
|
|
protected renderXAxis(): void {
|
|
|
|
if(this.options.axis.x.isActive === false) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.chartContainer.select('#x-axis-container').remove();
|
|
|
|
this.xAxisElement = this.chartContainer
|
|
|
|
.append('g')
|
|
|
|
.attr('transform', `translate(0,${this.height})`)
|
|
|
|
.attr('id', 'x-axis-container')
|
|
|
|
.style('pointer-events', 'none')
|
|
|
|
.call(
|
|
|
|
d3.axisBottom(this.state.xScale)
|
|
|
|
.ticks(this.options.axis.x.ticksCount)
|
|
|
|
.tickSize(DEFAULT_TICK_SIZE)
|
|
|
|
.tickFormat(this.getAxisTicksFormatter(this.options.axis.x))
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected renderYAxis(): void {
|
|
|
|
if(this.options.axis.y.isActive === false) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.chartContainer.select('#y-axis-container').remove();
|
|
|
|
this.yAxisTicksColors = [];
|
|
|
|
this.yAxisElement = this.chartContainer
|
|
|
|
.append('g')
|
|
|
|
.attr('id', 'y-axis-container')
|
|
|
|
.style('pointer-events', 'none')
|
|
|
|
// TODO: number of ticks shouldn't be hardcoded
|
|
|
|
.call(
|
|
|
|
d3.axisLeft(this.state.yScale)
|
|
|
|
.ticks(this.options.axis.y.ticksCount)
|
|
|
|
.tickSize(DEFAULT_TICK_SIZE)
|
|
|
|
.tickFormat(this.getAxisTicksFormatter(this.options.axis.y))
|
|
|
|
);
|
|
|
|
const ticks = this.yAxisElement.selectAll(`.tick`).select('text').nodes();
|
|
|
|
this.yAxisTicksColors.map((color, index) => {
|
|
|
|
if(ticks === undefined || ticks[index] === undefined) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
d3.select(ticks[index]).attr('color', color);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
protected renderY1Axis(): void {
|
|
|
|
if(this.options.axis.y1.isActive === false) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.chartContainer.select('#y1-axis-container').remove();
|
|
|
|
this.y1AxisElement = this.chartContainer
|
|
|
|
.append('g')
|
|
|
|
.attr('id', 'y1-axis-container')
|
|
|
|
.attr('transform', `translate(${this.width},0)`)
|
|
|
|
.style('pointer-events', 'none')
|
|
|
|
// TODO: number of ticks shouldn't be hardcoded
|
|
|
|
.call(
|
|
|
|
d3.axisRight(this.state.y1Scale)
|
|
|
|
.ticks(DEFAULT_TICK_COUNT)
|
|
|
|
.tickSize(DEFAULT_TICK_SIZE)
|
|
|
|
.tickFormat(this.getAxisTicksFormatter(this.options.axis.y1))
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected renderCrosshair(): void {
|
|
|
|
this.chartContainer.select('#crosshair-container').remove();
|
|
|
|
|
|
|
|
this.crosshair = this.chartContainer.append('g')
|
|
|
|
.attr('id', 'crosshair-container')
|
|
|
|
.style('display', 'none');
|
|
|
|
|
|
|
|
if(
|
|
|
|
this.options.crosshair.orientation === CrosshairOrientation.VERTICAL ||
|
|
|
|
this.options.crosshair.orientation === CrosshairOrientation.BOTH
|
|
|
|
) {
|
|
|
|
this.crosshair.append('line')
|
|
|
|
.attr('class', 'crosshair-line')
|
|
|
|
.attr('id', 'crosshair-line-x')
|
|
|
|
.attr('fill', this.options.crosshair.color)
|
|
|
|
.attr('stroke', this.options.crosshair.color)
|
|
|
|
.attr('stroke-width', '1px')
|
|
|
|
.attr('y1', 0)
|
|
|
|
.attr('y2', this.height)
|
|
|
|
.style('pointer-events', 'none');
|
|
|
|
}
|
|
|
|
if(
|
|
|
|
this.options.crosshair.orientation === CrosshairOrientation.HORIZONTAL ||
|
|
|
|
this.options.crosshair.orientation === CrosshairOrientation.BOTH
|
|
|
|
) {
|
|
|
|
this.crosshair.append('line')
|
|
|
|
.attr('class', 'crosshair-line')
|
|
|
|
.attr('id', 'crosshair-line-y')
|
|
|
|
.attr('fill', this.options.crosshair.color)
|
|
|
|
.attr('stroke', this.options.crosshair.color)
|
|
|
|
.attr('stroke-width', '1px')
|
|
|
|
.attr('x1', 0)
|
|
|
|
.attr('x2', this.width)
|
|
|
|
.style('pointer-events', 'none');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
protected addEvents(): void {
|
|
|
|
// TODO: refactor for a new mouse/scroll events
|
|
|
|
const panKeyEvent = this.options.mousePanEvent.keyEvent;
|
|
|
|
const isPanActive = this.options.mousePanEvent.isActive;
|
|
|
|
if(isPanActive === true && panKeyEvent === KeyEvent.MAIN) {
|
|
|
|
this.initPan();
|
|
|
|
this.initBrush();
|
|
|
|
} else {
|
|
|
|
this.initBrush();
|
|
|
|
this.initPan();
|
|
|
|
}
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
this.overlay = this.chartContainer.select('.overlay');
|
|
|
|
|
|
|
|
this.overlay
|
|
|
|
.on('mouseover', this.onMouseOver.bind(this))
|
|
|
|
.on('mouseout', this.onMouseOut.bind(this))
|
|
|
|
.on('mousemove', this.onMouseMove.bind(this))
|
|
|
|
.on('dblclick', () => {
|
|
|
|
d3.event.stopPropagation();
|
|
|
|
// TODO: add the same check as we have in line-pod
|
|
|
|
this.zoomOut();
|
|
|
|
})
|
|
|
|
.on('dblclick.zoom', () => {
|
|
|
|
d3.event.stopPropagation();
|
|
|
|
// TODO: add the same check as we have in line-pod
|
|
|
|
this.zoomOut();
|
|
|
|
});
|
|
|
|
}, 0);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected initBrush(): void {
|
|
|
|
const isBrushActive = this.options.mouseZoomEvent.isActive;
|
|
|
|
if(isBrushActive === false) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
switch(this.options.mouseZoomEvent.orientation) {
|
|
|
|
case BrushOrientation.VERTICAL:
|
|
|
|
this.brush = d3.brushY();
|
|
|
|
break;
|
|
|
|
case BrushOrientation.HORIZONTAL:
|
|
|
|
this.brush = d3.brushX();
|
|
|
|
break;
|
|
|
|
case BrushOrientation.SQUARE:
|
|
|
|
case BrushOrientation.RECTANGLE:
|
|
|
|
this.brush = d3.brush();
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
this.brush = d3.brushX();
|
|
|
|
}
|
|
|
|
const keyEvent = this.options.mouseZoomEvent.keyEvent;
|
|
|
|
this.brush.extent([
|
|
|
|
[0, 0],
|
|
|
|
[this.width, this.height]
|
|
|
|
])
|
|
|
|
.handleSize(20)
|
|
|
|
// TODO: brush selection is hidden if keyEvent is shift
|
|
|
|
.filter(this.filterByKeyEvent(keyEvent))
|
|
|
|
.on('start', this.onBrushStart.bind(this))
|
|
|
|
.on('brush', this.onBrush.bind(this))
|
|
|
|
.on('end', this.onBrushEnd.bind(this));
|
|
|
|
|
|
|
|
this.chartContainer.call(this.brush);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected filterByKeyEvent(key: KeyEvent): () => boolean {
|
|
|
|
// TODO: refactor
|
|
|
|
switch(key) {
|
|
|
|
case KeyEvent.MAIN:
|
|
|
|
return () => !d3.event.shiftKey;
|
|
|
|
case KeyEvent.SHIFT:
|
|
|
|
return () => d3.event.shiftKey;
|
|
|
|
default:
|
|
|
|
throw new Error(`Unknown type of KeyEvent: ${key}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
protected isD3EventKeyEqualOption(event: d3.D3ZoomEvent<any, any>, optionsKeyEvent: KeyEvent): boolean {
|
|
|
|
if(!event || !event.sourceEvent) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
const isShiftKey = event.sourceEvent.shiftKey;
|
|
|
|
const isOptionShift = optionsKeyEvent === KeyEvent.SHIFT;
|
|
|
|
return isShiftKey === isOptionShift;
|
|
|
|
}
|
|
|
|
|
|
|
|
protected initPan(): void {
|
|
|
|
if(
|
|
|
|
this.options.mousePanEvent.isActive === false &&
|
|
|
|
this.options.scrollPanEvent.isActive === false &&
|
|
|
|
this.options.scrollZoomEvent.isActive === false
|
|
|
|
) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if(this.options.mouseZoomEvent.isActive === false) {
|
|
|
|
// init cumstom overlay to handle all events
|
|
|
|
this.overlay = this.chartContainer.append('rect')
|
|
|
|
.attr('class', 'overlay')
|
|
|
|
.attr('width', this.width)
|
|
|
|
.attr('height', this.height)
|
|
|
|
.attr('x', 0)
|
|
|
|
.attr('y', 0)
|
|
|
|
.attr('pointer-events', 'all')
|
|
|
|
.attr('cursor', 'crosshair')
|
|
|
|
.attr('fill', 'none');
|
|
|
|
}
|
|
|
|
|
|
|
|
this.initScaleX = this.state.xScale.copy();
|
|
|
|
this.initScaleY = this.state.yScale.copy();
|
|
|
|
if(this.options.axis.y1.isActive) {
|
|
|
|
this.initScaleY1 = this.state.y1Scale.copy();
|
|
|
|
}
|
|
|
|
this.pan = d3.zoom()
|
|
|
|
.on('zoom', this.onPanning.bind(this))
|
|
|
|
.on('end', this.onPanningEnd.bind(this));
|
|
|
|
|
|
|
|
this.chartContainer.call(this.pan);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected renderClipPath(): void {
|
|
|
|
this.clipPath = this.chartContainer.append('defs').append('SVG:clipPath')
|
|
|
|
.attr('id', this.rectClipId)
|
|
|
|
.append('SVG:rect')
|
|
|
|
.attr('width', this.width)
|
|
|
|
.attr('height', this.height)
|
|
|
|
.attr('x', 0)
|
|
|
|
.attr('y', 0);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected renderLegend(): void {
|
|
|
|
if(this.options.allOptions.renderLegend === false) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if(!this.series.isSeriesAvailable) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
let legendRow = this.chartContainer
|
|
|
|
.append('g')
|
|
|
|
.attr('class', 'legend-row');
|
|
|
|
const series = this.series.allSeries;
|
|
|
|
for(let idx = 0; idx < series.length; idx++) {
|
|
|
|
let node = legendRow.selectAll('text').node();
|
|
|
|
let rowWidth = 0;
|
|
|
|
if(node !== null) {
|
|
|
|
rowWidth = legendRow.node().getBBox().width + 25;
|
|
|
|
}
|
|
|
|
|
|
|
|
const isChecked = series[idx].visible !== false;
|
|
|
|
legendRow.append('foreignObject')
|
|
|
|
.attr('x', rowWidth)
|
|
|
|
.attr('y', this.legendRowPositionY - 12)
|
|
|
|
.attr('width', 13)
|
|
|
|
.attr('height', 15)
|
|
|
|
.html(`<form><input type=checkbox ${isChecked? 'checked' : ''} /></form>`)
|
|
|
|
.on('click', () => this.options.callbackLegendClick(idx));
|
|
|
|
|
|
|
|
legendRow.append('text')
|
|
|
|
.attr('x', rowWidth + 20)
|
|
|
|
.attr('y', this.legendRowPositionY)
|
|
|
|
.attr('class', `metric-legend-${idx}`)
|
|
|
|
.style('font-size', '12px')
|
|
|
|
.style('fill', series[idx].color)
|
|
|
|
.text(series[idx].target)
|
|
|
|
.on('click', () => this.options.callbackLegendLabelClick(idx));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
protected renderYLabel(): void {
|
|
|
|
if(this.options.axis.y.label === undefined) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.chartContainer.append('text')
|
|
|
|
.attr('y', 0 - this.margin.left)
|
|
|
|
.attr('x', 0 - (this.height / 2))
|
|
|
|
.attr('dy', '1em')
|
|
|
|
.attr('class', 'y-axis-label')
|
|
|
|
.attr('transform', 'rotate(-90)')
|
|
|
|
.style('text-anchor', 'middle')
|
|
|
|
.style('font-size', '14px')
|
|
|
|
.style('fill', 'currentColor')
|
|
|
|
.text(this.options.axis.y.label);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected renderXLabel(): void {
|
|
|
|
if(this.options.axis.x.label === undefined) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
let yPosition = this.height + this.margin.top + this.margin.bottom - 35;
|
|
|
|
if(!this.series.isSeriesAvailable) {
|
|
|
|
yPosition += 20;
|
|
|
|
}
|
|
|
|
this.chartContainer.append('text')
|
|
|
|
.attr('class', 'x-axis-label')
|
|
|
|
.attr('x', this.width / 2)
|
|
|
|
.attr('y', yPosition)
|
|
|
|
.style('text-anchor', 'middle')
|
|
|
|
.style('font-size', '14px')
|
|
|
|
.style('fill', 'currentColor')
|
|
|
|
.text(this.options.axis.x.label);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected renderNoDataPointsMessage(): void {
|
|
|
|
this.chartContainer.append('text')
|
|
|
|
.attr('class', 'alert-text')
|
|
|
|
.attr('x', this.width / 2)
|
|
|
|
.attr('y', this.height / 2)
|
|
|
|
.style('text-anchor', 'middle')
|
|
|
|
.style('font-size', '14px')
|
|
|
|
.style('fill', 'currentColor')
|
|
|
|
.text('No data points');
|
|
|
|
}
|
|
|
|
|
|
|
|
private disableScrollForward(event: any): boolean {
|
|
|
|
return event.sourceEvent.wheelDelta > 0
|
|
|
|
&& this.options.scrollPanEvent.direction === ScrollPanDirection.FORWARD;
|
|
|
|
}
|
|
|
|
|
|
|
|
private disableScrollBackward(event: any): boolean {
|
|
|
|
return event.sourceEvent.wheelDelta < 0
|
|
|
|
&& this.options.scrollPanEvent.direction === ScrollPanDirection.BACKWARD;
|
|
|
|
}
|
|
|
|
|
|
|
|
protected onPanning(): void {
|
|
|
|
const event = d3.event;
|
|
|
|
if(event.sourceEvent === null || event.sourceEvent === undefined) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (event.sourceEvent.type === 'wheel'
|
|
|
|
&& (this.disableScrollBackward(event) || this.disableScrollForward(event))) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.rescaleMetricAndAxis(event);
|
|
|
|
|
|
|
|
this.options.callbackPanning({
|
|
|
|
ranges: [this.state.xValueRange, this.state.yValueRange, this.state.y1ValueRange],
|
|
|
|
d3Event: event
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
public rescaleMetricAndAxis(event: d3.D3ZoomEvent<any, any>): void {
|
|
|
|
this.isPanning = true;
|
|
|
|
this.onMouseOut();
|
|
|
|
this.onPanningRescale(event);
|
|
|
|
|
|
|
|
// TODO: check clear state for necessity
|
|
|
|
this.renderYAxis();
|
|
|
|
this.renderXAxis();
|
|
|
|
// metrics-rect wrapper is required for panning and any rescaling
|
|
|
|
this.chartContainer.select('.metrics-rect')
|
|
|
|
.attr('transform', `translate(${this.state.transform.x},${this.state.transform.y}), scale(${this.state.transform.k})`);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected onPanningRescale(event: d3.D3ZoomEvent<any, any>): void {
|
|
|
|
// rescale metrics and axis on mouse and scroll panning
|
|
|
|
const eventType = event.sourceEvent.type; // 'wheel' or 'mousemove'
|
|
|
|
const scrollPanOptions = this.options.scrollPanEvent;
|
|
|
|
const scrollZoomOptions = this.options.scrollZoomEvent;
|
|
|
|
// TODO: maybe use switch and move it to onPanning
|
|
|
|
if(eventType === 'wheel') {
|
|
|
|
if(scrollPanOptions.isActive === true && this.isD3EventKeyEqualOption(event, scrollPanOptions.keyEvent)) {
|
|
|
|
this.onScrollPanningRescale(event);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if(scrollZoomOptions.isActive === true && this.isD3EventKeyEqualOption(event, scrollZoomOptions.keyEvent)) {
|
|
|
|
const orientation = scrollZoomOptions.orientation;
|
|
|
|
let k;
|
|
|
|
switch(orientation) {
|
|
|
|
case PanOrientation.HORIZONTAL:
|
|
|
|
k = `${event.transform.k},1`;
|
|
|
|
this.rescaleAxisX(event.transform.x);
|
|
|
|
break;
|
|
|
|
case PanOrientation.VERTICAL:
|
|
|
|
k = `1,${event.transform.k}`;
|
|
|
|
this.rescaleAxisY(event.transform.y);
|
|
|
|
break;
|
|
|
|
case PanOrientation.BOTH:
|
|
|
|
k = event.transform.k;
|
|
|
|
this.rescaleAxisX(event.transform.x);
|
|
|
|
this.rescaleAxisY(event.transform.y);
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
throw new Error(`Unknown type of PanOrientation: ${orientation}`);
|
|
|
|
}
|
|
|
|
this.state.transform.k = k;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const panOrientation = this.options.mousePanEvent.orientation;
|
|
|
|
if(panOrientation === PanOrientation.HORIZONTAL || panOrientation === PanOrientation.BOTH) {
|
|
|
|
this.rescaleAxisX(event.transform.x);
|
|
|
|
}
|
|
|
|
if(panOrientation === PanOrientation.VERTICAL || panOrientation === PanOrientation.BOTH) {
|
|
|
|
this.rescaleAxisY(event.transform.y);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: refactor, divide between classes
|
|
|
|
rescaleAxisX(transformX: number): void {
|
|
|
|
this.state.transform = { x: transformX };
|
|
|
|
const rescaleX = d3.event.transform.rescaleX(this.initScaleX);
|
|
|
|
this.xAxisElement.call(d3.axisBottom(this.state.xScale).scale(rescaleX));
|
|
|
|
this.state.xValueRange = [rescaleX.invert(0), rescaleX.invert(this.width)];
|
|
|
|
}
|
|
|
|
|
|
|
|
rescaleAxisY(transformY: number): void {
|
|
|
|
this.state.transform = { y: transformY };
|
|
|
|
const rescaleY = d3.event.transform.rescaleY(this.initScaleY);
|
|
|
|
this.yAxisElement.call(d3.axisLeft(this.state.yScale).scale(rescaleY));
|
|
|
|
this.state.yValueRange = [rescaleY.invert(0), rescaleY.invert(this.height)];
|
|
|
|
if(this.y1AxisElement) {
|
|
|
|
const rescaleY1 = d3.event.transform.rescaleY(this.initScaleY1);
|
|
|
|
this.y1AxisElement.call(d3.axisLeft(this.state.y1Scale).scale(rescaleY1));
|
|
|
|
this.state.y1ValueRange = [rescaleY1.invert(0), rescaleY1.invert(this.height)];
|
|
|
|
// TODO: y1 axis jumps on panning
|
|
|
|
this.y1AxisElement.selectAll('line').attr('x2', 2);
|
|
|
|
this.y1AxisElement.selectAll('text').attr('x', 5);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
protected onScrollPanningRescale(event: d3.D3ZoomEvent<any, any>): void {
|
|
|
|
const scrollPanOptions = this.options.scrollPanEvent;
|
|
|
|
// TODO: event.transform.y / x depends on mouse position, so we use hardcoded const, which should be removed
|
|
|
|
const transformStep = scrollPanOptions.panStep;
|
|
|
|
const scrollPanOrientation = scrollPanOptions.orientation;
|
|
|
|
switch(scrollPanOrientation) {
|
|
|
|
case ScrollPanOrientation.HORIZONTAL:
|
|
|
|
// @ts-ignore
|
|
|
|
const signX = Math.sign(event.transform.x);
|
|
|
|
const transformX = this.state.absXScale.invert(Math.abs(transformStep));
|
|
|
|
let rangeX = this.state.xValueRange;
|
|
|
|
this.state.xValueRange = [rangeX[0] + signX * transformX, rangeX[1] + signX * transformX];
|
|
|
|
const translateX = this.state.transform.x + signX * transformStep;
|
|
|
|
this.state.transform = { x: translateX };
|
|
|
|
break;
|
|
|
|
case ScrollPanOrientation.VERTICAL:
|
|
|
|
// @ts-ignore
|
|
|
|
let signY = Math.sign(event.transform.y);
|
|
|
|
if(this.options.axis.y.invert === true) {
|
|
|
|
signY = -signY;
|
|
|
|
}
|
|
|
|
let rangeY = this.state.yValueRange;
|
|
|
|
const transformY = this.state.absYScale.invert(transformStep);
|
|
|
|
this.deltaYTransform = this.deltaYTransform + transformStep;
|
|
|
|
// TODO: not hardcoded bounds
|
|
|
|
if(this.deltaYTransform > this.height * 0.9) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.state.yValueRange = [rangeY[0] - signY * transformY, rangeY[1] - signY * transformY];
|
|
|
|
const translateY = this.state.transform.y + signY * transformStep;
|
|
|
|
this.state.transform = { y: translateY };
|
|
|
|
// TODO: add y1 rescale
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
throw new Error(`Unknown type of scroll pan orientation: ${scrollPanOrientation}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
protected onPanningEnd(): void {
|
|
|
|
this.isPanning = false;
|
|
|
|
this.deltaYTransform = 0;
|
|
|
|
this.onMouseOut();
|
|
|
|
this.options.callbackPanningEnd([this.state.xValueRange, this.state.yValueRange, this.state.y1ValueRange]);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected onBrush(): void {
|
|
|
|
const selection = d3.event.selection;
|
|
|
|
if(this.options.mouseZoomEvent.orientation !== BrushOrientation.SQUARE || selection === null) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const selectionAtts = this.getSelectionAttrs(selection);
|
|
|
|
if(selectionAtts === undefined) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.chartContainer.select('.selection')
|
|
|
|
.attr('x', selectionAtts.x)
|
|
|
|
.attr('y', selectionAtts.y)
|
|
|
|
.attr('width', selectionAtts.width)
|
|
|
|
.attr('height', selectionAtts.height);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected getSelectionAttrs(selection: number[][]): SvgElementAttributes | undefined {
|
|
|
|
if(this.brushStartSelection === null || selection === undefined || selection === null) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
const startX = this.brushStartSelection[0];
|
|
|
|
const startY = this.brushStartSelection[1];
|
|
|
|
const x0 = selection[0][0];
|
|
|
|
const x1 = selection[1][0];
|
|
|
|
const y0 = selection[0][1];
|
|
|
|
const y1 = selection[1][1];
|
|
|
|
const xRange = x1 - x0;
|
|
|
|
const yRange = y1 - y0;
|
|
|
|
const minWH = Math.min(xRange, yRange);
|
|
|
|
const x = x0 === startX ? startX : startX - minWH;
|
|
|
|
const y = y0 === startY ? startY : startY - minWH;
|
|
|
|
return {
|
|
|
|
x, y, width: minWH, height: minWH
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
protected onBrushStart(): void {
|
|
|
|
// TODO: move to state
|
|
|
|
this.isBrushing === true;
|
|
|
|
const selection = d3.event.selection;
|
|
|
|
if(selection !== null && selection.length > 0) {
|
|
|
|
this.brushStartSelection = d3.event.selection[0];
|
|
|
|
}
|
|
|
|
this.onMouseOut();
|
|
|
|
}
|
|
|
|
|
|
|
|
protected onBrushEnd(): void {
|
|
|
|
const extent = d3.event.selection;
|
|
|
|
this.isBrushing === false;
|
|
|
|
if(extent === undefined || extent === null || extent.length < 2) {
|
|
|
|
console.warn('Chartwerk Core: skip brush end (no extent)');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.chartContainer
|
|
|
|
.call(this.brush.move, null);
|
|
|
|
|
|
|
|
let xRange: [number, number];
|
|
|
|
let yRange: [number, number];
|
|
|
|
switch(this.options.mouseZoomEvent.orientation) {
|
|
|
|
case BrushOrientation.HORIZONTAL:
|
|
|
|
const startTimestamp = this.state.xScale.invert(extent[0]);
|
|
|
|
const endTimestamp = this.state.xScale.invert(extent[1]);
|
|
|
|
xRange = [startTimestamp, endTimestamp];
|
|
|
|
this.state.xValueRange = xRange;
|
|
|
|
break;
|
|
|
|
case BrushOrientation.VERTICAL:
|
|
|
|
const upperY = this.state.yScale.invert(extent[0]);
|
|
|
|
const bottomY = this.state.yScale.invert(extent[1]);
|
|
|
|
// TODO: add min zoom y
|
|
|
|
yRange = [upperY, bottomY];
|
|
|
|
this.state.yValueRange = yRange;
|
|
|
|
break;
|
|
|
|
case BrushOrientation.RECTANGLE:
|
|
|
|
const bothStartTimestamp = this.state.xScale.invert(extent[0][0]);
|
|
|
|
const bothEndTimestamp = this.state.xScale.invert(extent[1][0]);
|
|
|
|
const bothUpperY = this.state.yScale.invert(extent[0][1]);
|
|
|
|
const bothBottomY = this.state.yScale.invert(extent[1][1]);
|
|
|
|
xRange = [bothStartTimestamp, bothEndTimestamp];
|
|
|
|
yRange = [bothUpperY, bothBottomY];
|
|
|
|
this.state.xValueRange = xRange;
|
|
|
|
this.state.yValueRange = yRange;
|
|
|
|
break;
|
|
|
|
case BrushOrientation.SQUARE:
|
|
|
|
const selectionAtts = this.getSelectionAttrs(extent);
|
|
|
|
if(selectionAtts === undefined) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
const scaledX0 = this.state.xScale.invert(selectionAtts.x);
|
|
|
|
const scaledX1 = this.state.xScale.invert(selectionAtts.x + selectionAtts.width);
|
|
|
|
const scaledY0 = this.state.yScale.invert(selectionAtts.y);
|
|
|
|
const scaledY1 = this.state.yScale.invert(selectionAtts.y + selectionAtts.height);
|
|
|
|
xRange = [scaledX0, scaledX1];
|
|
|
|
yRange = [scaledY0, scaledY1];
|
|
|
|
this.state.xValueRange = xRange;
|
|
|
|
this.state.yValueRange = yRange;
|
|
|
|
this.brushStartSelection = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.options.callbackZoomIn([xRange, yRange]);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected zoomOut(): void {
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
|
|
|
getd3TimeRangeEvery(count: number): d3.TimeInterval {
|
|
|
|
if(this.options.allOptions.timeInterval.timeFormat === undefined) {
|
|
|
|
return d3.timeMinute.every(count);
|
|
|
|
}
|
|
|
|
switch(this.options.allOptions.timeInterval.timeFormat) {
|
|
|
|
case TimeFormat.SECOND:
|
|
|
|
return d3.utcSecond.every(count);
|
|
|
|
case TimeFormat.MINUTE:
|
|
|
|
return d3.utcMinute.every(count);
|
|
|
|
case TimeFormat.HOUR:
|
|
|
|
return d3.utcHour.every(count);
|
|
|
|
case TimeFormat.DAY:
|
|
|
|
return d3.utcDay.every(count);
|
|
|
|
case TimeFormat.MONTH:
|
|
|
|
return d3.utcMonth.every(count);
|
|
|
|
case TimeFormat.YEAR:
|
|
|
|
return d3.utcYear.every(count);
|
|
|
|
default:
|
|
|
|
return d3.utcMinute.every(count);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
get serieTimestampRange(): number | undefined {
|
|
|
|
if(!this.series.isSeriesAvailable) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
const startTimestamp = first(this.series.visibleSeries[0].datapoints)[0];
|
|
|
|
const endTimestamp = last(this.series.visibleSeries[0].datapoints)[0];
|
|
|
|
return (endTimestamp - startTimestamp) / 1000;
|
|
|
|
}
|
|
|
|
|
|
|
|
getAxisTicksFormatter(axisOptions: AxisOption): (d: any, i: number) => any {
|
|
|
|
// TODO: ticksCount === 0 -> suspicious option
|
|
|
|
if(axisOptions.ticksCount === 0) {
|
|
|
|
return (d) => '';
|
|
|
|
}
|
|
|
|
switch(axisOptions.format) {
|
|
|
|
case AxisFormat.TIME:
|
|
|
|
// TODO: customize time format?
|
|
|
|
return d3.timeFormat('%m/%d %H:%M');
|
|
|
|
case AxisFormat.NUMERIC:
|
|
|
|
return (d) => d;
|
|
|
|
case AxisFormat.STRING:
|
|
|
|
// TODO: add string/symbol format
|
|
|
|
throw new Error(`Not supported AxisFormat type ${axisOptions.format} yet`);
|
|
|
|
case AxisFormat.CUSTOM:
|
|
|
|
if(axisOptions.valueFormatter === undefined) {
|
|
|
|
console.warn(`Value formatter for axis is not defined. Path options.axis.{?}.valueFormatter`);
|
|
|
|
return (d) => d;
|
|
|
|
}
|
|
|
|
return (d, i) => {
|
|
|
|
if(axisOptions.colorFormatter !== undefined) {
|
|
|
|
this.yAxisTicksColors.push(axisOptions.colorFormatter(d, i))
|
|
|
|
}
|
|
|
|
return axisOptions.valueFormatter(d, i)
|
|
|
|
};
|
|
|
|
default:
|
|
|
|
throw new Error(`Unknown time format for axis: ${axisOptions.format}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
get timeInterval(): number {
|
|
|
|
if(this.series.isSeriesAvailable && this.series.visibleSeries[0].datapoints.length > 1) {
|
|
|
|
const interval = this.series.visibleSeries[0].datapoints[1][0] - this.series.visibleSeries[0].datapoints[0][0];
|
|
|
|
return interval;
|
|
|
|
}
|
|
|
|
if(this.options.allOptions.timeInterval.count !== undefined) {
|
|
|
|
//TODO: timeFormat to timestamp
|
|
|
|
return this.options.allOptions.timeInterval.count * MILISECONDS_IN_MINUTE;
|
|
|
|
}
|
|
|
|
return MILISECONDS_IN_MINUTE;
|
|
|
|
}
|
|
|
|
|
|
|
|
get width(): number {
|
|
|
|
return this.d3Node.node().clientWidth - this.margin.left - this.margin.right;
|
|
|
|
}
|
|
|
|
|
|
|
|
get height(): number {
|
|
|
|
return this.d3Node.node().clientHeight - this.margin.top - this.margin.bottom;
|
|
|
|
}
|
|
|
|
|
|
|
|
get legendRowPositionY(): number {
|
|
|
|
return this.height + this.margin.bottom - 5;
|
|
|
|
}
|
|
|
|
|
|
|
|
get margin(): Margin {
|
|
|
|
return this.options.margin;
|
|
|
|
}
|
|
|
|
|
|
|
|
protected clearState(): void {
|
|
|
|
this.state.clearState();
|
|
|
|
}
|
|
|
|
|
|
|
|
protected get rectClipId(): string {
|
|
|
|
if(this._clipPathUID.length === 0) {
|
|
|
|
this._clipPathUID = uid();
|
|
|
|
}
|
|
|
|
return this._clipPathUID;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export {
|
|
|
|
ChartwerkPod, VueChartwerkPodMixin,
|
|
|
|
PodState, CoreSeries, CoreOptions, CORE_SERIE_DEFAULTS, CORE_DEFAULT_OPTIONS,
|
|
|
|
Margin, Serie, Options, TimeFormat, BrushOrientation, PanOrientation,
|
|
|
|
AxesOptions, AxisOption,
|
|
|
|
AxisFormat, yAxisOrientation, CrosshairOrientation, ScrollPanOrientation, ScrollPanDirection, KeyEvent,
|
|
|
|
palette
|
|
|
|
};
|