import VueChartwerkPodMixin from './VueChartwerkPodMixin'; import { PodState } from './state'; import { Grid } from './components/grid'; import styles from './css/style.css'; import { Margin, TimeSerie, Options, TickOrientation, TimeFormat, BrushOrientation, AxisFormat, CrosshairOrientation, SvgElementAttributes, KeyEvent, PanOrientation, yAxisOrientation, ScrollPanOrientation, AxisOption, SvgElParams, } from './types'; import { uid } from './utils'; import { palette } from './colors'; // we import only d3 types here import * as d3 from 'd3'; import defaultsDeep from 'lodash/defaultsDeep'; import includes from 'lodash/includes'; import first from 'lodash/first'; import last from 'lodash/last'; import mergeWith from 'lodash/mergeWith'; import add from 'lodash/add'; import replace from 'lodash/replace'; import cloneDeep from 'lodash/cloneDeep'; import debounce from 'lodash/debounce'; import has from 'lodash/has'; const DEFAULT_MARGIN: Margin = { top: 30, right: 20, bottom: 20, left: 30 }; const DEFAULT_TICK_COUNT = 4; const DEFAULT_TICK_SIZE = 2; const MILISECONDS_IN_MINUTE = 60 * 1000; const DEFAULT_AXIS_RANGE = [0, 1]; const DEFAULT_SCROLL_PAN_STEP = 50; const DEFAULT_OPTIONS: Options = { confidence: 0, timeInterval: { timeFormat: TimeFormat.MINUTE }, tickFormat: { xAxis: '%H:%M', xTickOrientation: TickOrientation.HORIZONTAL }, zoomEvents: { mouse: { zoom: { isActive: true, keyEvent: KeyEvent.MAIN, orientation: BrushOrientation.HORIZONTAL }, pan: { isActive: true, keyEvent: KeyEvent.SHIFT, orientation: PanOrientation.HORIZONTAL }, }, scroll: { zoom: { isActive: true, keyEvent: KeyEvent.MAIN, orientation: PanOrientation.BOTH, }, pan: { isActive: false, keyEvent: KeyEvent.SHIFT, panStep: DEFAULT_SCROLL_PAN_STEP, orientation: ScrollPanOrientation.HORIZONTAL, }, }, }, axis: { x: { isActive: true, ticksCount: DEFAULT_TICK_COUNT, format: AxisFormat.TIME }, y: { isActive: true, ticksCount: DEFAULT_TICK_COUNT, format: AxisFormat.NUMERIC }, y1: { isActive: false, ticksCount: DEFAULT_TICK_COUNT, format: AxisFormat.NUMERIC } }, crosshair: { orientation: CrosshairOrientation.VERTICAL, color: 'red' }, renderTicksfromTimestamps: false, renderLegend: true, } abstract class ChartwerkPod { protected d3Node?: d3.Selection; protected customOverlay?: d3.Selection; protected crosshair?: d3.Selection; protected brush?: d3.BrushBehavior; protected zoom?: any; protected svg?: d3.Selection; protected state: PodState; protected pan?: d3.ZoomBehavior; protected clipPath?: any; protected isPanning = false; protected isBrushing = false; protected brushStartSelection: [number, number] | null = null; protected initScaleX?: d3.ScaleLinear; protected initScaleY?: d3.ScaleLinear; protected initScaleY1?: d3.ScaleLinear; protected xAxisElement?: d3.Selection; protected yAxisElement?: d3.Selection; protected y1AxisElement?: d3.Selection; protected yAxisTicksColors?: string[] = []; private _clipPathUID = ''; protected series: T[]; protected options: O; protected readonly d3: typeof d3; protected deltaYTransform = 0; protected debouncedRender = debounce(this.render.bind(this), 100); // containers // TODO: add better names protected chartContainer: d3.Selection; protected metricContainer: d3.Selection; // components protected grid: Grid; constructor( // maybe it's not the best idea _d3: typeof d3, protected readonly el: HTMLElement, _series: T[] = [], _options: O ) { // TODO: test if it's necessary styles.use(); let options = cloneDeep(_options); defaultsDeep(options, DEFAULT_OPTIONS); this.options = options; this.series = cloneDeep(_series); this.d3 = _d3; this.d3Node = this.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.renderAxes(); this.renderGrid(); this.renderClipPath(); this.addEvents(); this.renderCrosshair(); this.renderMetricsContainer(); this.renderMetrics(); this.renderLegend(); this.renderYLabel(); this.renderXLabel(); if(has(this.options.eventsCallbacks, 'renderEnd')) { this.options.eventsCallbacks.renderEnd(); } } 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; } let options = cloneDeep(newOptions); defaultsDeep(options, DEFAULT_OPTIONS); this.options = options; // TODO: update state if axis ranges were changed } protected updateSeries(newSeries: T[]): void { if(newSeries === undefined) { return; } let series = cloneDeep(newSeries); this.series = series; } 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(this.d3, 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.d3, 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( this.d3.axisBottom(this.xScale) .ticks(this.options.axis.x.ticksCount) .tickSize(DEFAULT_TICK_SIZE) .tickFormat(this.getAxisTicksFormatter(this.options.axis.x)) ); this.chartContainer.select('#x-axis-container').selectAll('.tick').selectAll('text') .style('transform', this.xTickTransform); } 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( this.d3.axisLeft(this.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; } this.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( this.d3.axisRight(this.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.zoomEvents.mouse.pan.keyEvent; const isPanActive = this.options.zoomEvents.mouse.pan.isActive; if(isPanActive === true && panKeyEvent === KeyEvent.MAIN) { this.initPan(); this.initBrush(); } else { this.initBrush(); this.initPan(); } this.chartContainer .on('mouseover', this.onMouseOver.bind(this)) .on('mouseout', this.onMouseOut.bind(this)) .on('mousemove', this.onMouseMove.bind(this)) .on('dblclick.zoom', this.zoomOut.bind(this)); } protected initBrush(): void { const isBrushActive = this.options.zoomEvents.mouse.zoom.isActive; if(isBrushActive === false) { return; } switch(this.options.zoomEvents.mouse.zoom.orientation) { case BrushOrientation.VERTICAL: this.brush = this.d3.brushY(); break; case BrushOrientation.HORIZONTAL: this.brush = this.d3.brushX(); break; case BrushOrientation.SQUARE: case BrushOrientation.RECTANGLE: this.brush = this.d3.brush(); break; default: this.brush = this.d3.brushX(); } const keyEvent = this.options.zoomEvents.mouse.zoom.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 () => !this.d3.event.shiftKey; case KeyEvent.SHIFT: return () => this.d3.event.shiftKey; default: throw new Error(`Unknown type of KeyEvent: ${key}`); } } protected isD3EventKeyEqualOption(event: d3.D3ZoomEvent, 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.zoomEvents.mouse.pan.isActive === false && this.options.zoomEvents.scroll.pan.isActive === false && this.options.zoomEvents.scroll.zoom.isActive === false ) { return; } if(this.options.zoomEvents.mouse.zoom.isActive === false) { // init cumstom overlay to handle all events this.customOverlay = this.chartContainer.append('rect') .attr('class', 'custom-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.xScale.copy(); this.initScaleY = this.yScale.copy(); if(this.options.axis.y1.isActive === true) { this.initScaleY1 = this.y1Scale.copy(); } this.pan = this.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.renderLegend === false) { return; } if(this.series.length > 0) { let legendRow = this.chartContainer .append('g') .attr('class', 'legend-row'); for(let idx = 0; idx < this.series.length; idx++) { if(includes(this.seriesTargetsWithBounds, this.series[idx].target)) { continue; } let node = legendRow.selectAll('text').node(); let rowWidth = 0; if(node !== null) { rowWidth = legendRow.node().getBBox().width + 25; } const isChecked = this.series[idx].visible !== false; legendRow.append('foreignObject') .attr('x', rowWidth) .attr('y', this.legendRowPositionY - 12) .attr('width', 13) .attr('height', 15) .html(`
`) .on('click', () => { if(this.options.eventsCallbacks !== undefined && this.options.eventsCallbacks.onLegendClick !== undefined) { this.options.eventsCallbacks.onLegendClick(idx); } }); legendRow.append('text') .attr('x', rowWidth + 20) .attr('y', this.legendRowPositionY) .attr('class', `metric-legend-${idx}`) .style('font-size', '12px') .style('fill', this.getSerieColor(idx)) .text(this.series[idx].target) .on('click', () => { if(this.options.eventsCallbacks !== undefined && this.options.eventsCallbacks.onLegendLabelClick !== undefined) { this.options.eventsCallbacks.onLegendLabelClick(idx); } }); } } } protected renderYLabel(): void { if(this.options.labelFormat === undefined || this.options.labelFormat.yAxis === 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.labelFormat.yAxis); } protected renderXLabel(): void { if(this.options.labelFormat === undefined || this.options.labelFormat.xAxis === undefined) { return; } let yPosition = this.height + this.margin.top + this.margin.bottom - 35; if(this.series.length === 0) { 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.labelFormat.xAxis); } 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'); } protected onPanning(): void { const event = this.d3.event; if(event.sourceEvent === null || event.sourceEvent === undefined) { return; } this.rescaleMetricAndAxis(event); if(this.options.eventsCallbacks !== undefined && this.options.eventsCallbacks.panning !== undefined) { this.options.eventsCallbacks.panning({ ranges: [this.state.xValueRange, this.state.yValueRange, this.state.y1ValueRange], d3Event: event }); } else { console.log('on panning, but there is no callback'); } } public rescaleMetricAndAxis(event: d3.D3ZoomEvent): 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 this.chartContainer.select('.metrics-rect') .attr('transform', `translate(${this.state.transform.x},${this.state.transform.y}), scale(${this.state.transform.k})`); // TODO: use one standard for all metrics in Pods this.chartContainer.selectAll('.metric-el') .attr('transform', `translate(${this.state.transform.x},${this.state.transform.y}), scale(${this.state.transform.k})`); } protected onPanningRescale(event: d3.D3ZoomEvent): void { // rescale metrics and axis on mouse and scroll panning const eventType = event.sourceEvent.type; // 'wheel' or 'mousemove' const scrollPanOptions = this.options.zoomEvents.scroll.pan; const scrollZoomOptions = this.options.zoomEvents.scroll.zoom; // 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; } } else { const isPanActive = this.options.zoomEvents.mouse.pan.isActive; if(!isPanActive) { return; } } const panOrientation = this.options.zoomEvents.mouse.pan.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 = this.d3.event.transform.rescaleX(this.initScaleX); this.xAxisElement.call(this.d3.axisBottom(this.xScale).scale(rescaleX)); this.state.xValueRange = [rescaleX.invert(0), rescaleX.invert(this.width)]; } rescaleAxisY(transformY: number): void { this.state.transform = { y: transformY }; const rescaleY = this.d3.event.transform.rescaleY(this.initScaleY); this.yAxisElement.call(this.d3.axisLeft(this.yScale).scale(rescaleY)); this.state.yValueRange = [rescaleY.invert(0), rescaleY.invert(this.height)]; if(this.y1AxisElement) { const rescaleY1 = this.d3.event.transform.rescaleY(this.initScaleY1); this.y1AxisElement.call(this.d3.axisLeft(this.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): void { const scrollPanOptions = this.options.zoomEvents.scroll.pan; // 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.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: const deltaY = Math.min(Math.abs(event.sourceEvent.deltaY), this.height * 0.1); // @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.absYScale.invert(deltaY); this.deltaYTransform = this.deltaYTransform + deltaY; // 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 * deltaY; 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(); if(this.options.eventsCallbacks !== undefined && this.options.eventsCallbacks.panningEnd !== undefined) { this.options.eventsCallbacks.panningEnd([this.state.xValueRange, this.state.yValueRange, this.state.y1ValueRange]); } else { console.log('on panning end, but there is no callback'); } } protected onBrush(): void { const selection = this.d3.event.selection; if(this.options.zoomEvents.mouse.zoom.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 = this.d3.event.selection; if(selection !== null && selection.length > 0) { this.brushStartSelection = this.d3.event.selection[0]; } this.onMouseOut(); } protected onBrushEnd(): void { const extent = this.d3.event.selection; this.isBrushing === false; if(extent === undefined || extent === null || extent.length < 2) { return; } this.chartContainer .call(this.brush.move, null); let xRange: [number, number]; let yRange: [number, number]; switch(this.options.zoomEvents.mouse.zoom.orientation) { case BrushOrientation.HORIZONTAL: const startTimestamp = this.xScale.invert(extent[0]); const endTimestamp = this.xScale.invert(extent[1]); if(Math.abs(endTimestamp - startTimestamp) < this.timeInterval) { return; } xRange = [startTimestamp, endTimestamp]; this.state.xValueRange = xRange; break; case BrushOrientation.VERTICAL: const upperY = this.yScale.invert(extent[0]); const bottomY = this.yScale.invert(extent[1]); // TODO: add min zoom y yRange = [upperY, bottomY]; this.state.yValueRange = yRange; break; case BrushOrientation.RECTANGLE: const bothStartTimestamp = this.xScale.invert(extent[0][0]); const bothEndTimestamp = this.xScale.invert(extent[1][0]); const bothUpperY = this.yScale.invert(extent[0][1]); const bothBottomY = this.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.xScale.invert(selectionAtts.x); const scaledX1 = this.xScale.invert(selectionAtts.x + selectionAtts.width); const scaledY0 = this.yScale.invert(selectionAtts.y); const scaledY1 = this.yScale.invert(selectionAtts.y + selectionAtts.height); xRange = [scaledX0, scaledX1]; yRange = [scaledY0, scaledY1]; this.state.xValueRange = xRange; this.state.yValueRange = yRange; this.brushStartSelection = null; } if(this.options.eventsCallbacks !== undefined && this.options.eventsCallbacks.zoomIn !== undefined) { this.options.eventsCallbacks.zoomIn([xRange, yRange]); } else { console.log('zoom in, but there is no callback'); } } protected zoomOut(): void { if(this.isOutOfChart() === true) { return; } let xAxisMiddleValue: number = this.xScale.invert(this.width / 2); let yAxisMiddleValue: number = this.yScale.invert(this.height / 2); const centers = { x: xAxisMiddleValue, y: yAxisMiddleValue } if(this.options.eventsCallbacks !== undefined && this.options.eventsCallbacks.zoomOut !== undefined) { this.options.eventsCallbacks.zoomOut(centers); } else { console.log('zoom out, but there is no callback'); } } // TODO: move to State get absXScale(): d3.ScaleLinear { const domain = [0, Math.abs(this.state.getMaxValueX() - this.state.getMinValueX())]; return this.d3.scaleLinear() .domain(domain) .range([0, this.width]); } get absYScale(): d3.ScaleLinear { const domain = [0, Math.abs(this.state.getMaxValueY() - this.state.getMinValueY())]; return this.d3.scaleLinear() .domain(domain) .range([0, this.height]); } get xScale(): d3.ScaleLinear { return this.state.xScale; } get yScale(): d3.ScaleLinear { return this.state.yScale; } protected get y1Scale(): d3.ScaleLinear { return this.state.y1Scale; } getd3TimeRangeEvery(count: number): d3.TimeInterval { if(this.options.timeInterval === undefined || this.options.timeInterval.timeFormat === undefined) { return this.d3.timeMinute.every(count); } switch(this.options.timeInterval.timeFormat) { case TimeFormat.SECOND: return this.d3.utcSecond.every(count); case TimeFormat.MINUTE: return this.d3.utcMinute.every(count); case TimeFormat.HOUR: return this.d3.utcHour.every(count); case TimeFormat.DAY: return this.d3.utcDay.every(count); case TimeFormat.MONTH: return this.d3.utcMonth.every(count); case TimeFormat.YEAR: return this.d3.utcYear.every(count); default: return this.d3.utcMinute.every(count); } } get serieTimestampRange(): number | undefined { if(this.series.length === 0) { return undefined; } const startTimestamp = first(this.series[0].datapoints)[0]; const endTimestamp = last(this.series[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 this.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 !== undefined && this.series.length > 0 && this.series[0].datapoints.length > 1) { const interval = this.series[0].datapoints[1][0] - this.series[0].datapoints[0][0]; return interval; } if(this.options.timeInterval !== undefined && this.options.timeInterval.count !== undefined) { //TODO: timeFormat to timestamp return this.options.timeInterval.count * MILISECONDS_IN_MINUTE; } return MILISECONDS_IN_MINUTE; } get xTickTransform(): string { if(this.options.tickFormat === undefined || this.options.tickFormat.xTickOrientation === undefined) { return ''; } switch (this.options.tickFormat.xTickOrientation) { case TickOrientation.VERTICAL: return 'translate(-10px, 50px) rotate(-90deg)'; case TickOrientation.HORIZONTAL: return ''; case TickOrientation.DIAGONAL: return 'translate(-30px, 30px) rotate(-45deg)'; default: return ''; } } get extraMargin(): Margin { let optionalMargin = { top: 0, right: 0, bottom: 0, left: 0 }; if(this.options.tickFormat !== undefined && this.options.tickFormat.xTickOrientation !== undefined) { switch (this.options.tickFormat.xTickOrientation) { case TickOrientation.VERTICAL: optionalMargin.bottom += 80; break; case TickOrientation.HORIZONTAL: break; case TickOrientation.DIAGONAL: optionalMargin.left += 15; optionalMargin.bottom += 50; optionalMargin.right += 10; break; } } if(this.options.labelFormat !== undefined) { if(this.options.labelFormat.xAxis !== undefined && this.options.labelFormat.xAxis.length > 0) { optionalMargin.bottom += 20; } if(this.options.labelFormat.yAxis !== undefined && this.options.labelFormat.yAxis.length > 0) { optionalMargin.left += 20; } } if(this.series.length > 0) { optionalMargin.bottom += 25; } return optionalMargin; } 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 { if(this.options.margin !== undefined) { return this.options.margin; } return mergeWith({}, DEFAULT_MARGIN, this.extraMargin, add); } formattedBound(alias: string, target: string): string { const confidenceMetric = replace(alias, '$__metric_name', target); return confidenceMetric; } protected clearState(): void { this.state.clearState(); } protected getSerieColor(idx: number): string { if(this.series[idx] === undefined) { throw new Error( `Can't get color for unexisting serie: ${idx}, there are only ${this.series.length} series` ); } let serieColor = this.series[idx].color; if(serieColor === undefined) { serieColor = palette[idx % palette.length]; } return serieColor; } protected get seriesTargetsWithBounds(): any[] { if( this.options.bounds === undefined || this.options.bounds.upper === undefined || this.options.bounds.lower === undefined ) { return []; } let series = []; this.series.forEach(serie => { series.push(this.formattedBound(this.options.bounds.upper, serie.target)); series.push(this.formattedBound(this.options.bounds.lower, serie.target)); }); return series; } protected get visibleSeries(): any[] { return this.series.filter(serie => serie.visible !== false); } protected get rectClipId(): string { if(this._clipPathUID.length === 0) { this._clipPathUID = uid(); } return this._clipPathUID; } isOutOfChart(): boolean { const event = this.d3.mouse(this.chartContainer.node()); const eventX = event[0]; const eventY = event[1]; if( eventY > this.height + 1 || eventY < -1 || eventX > this.width || eventX < 0 ) { return true; } return false; } } export { ChartwerkPod, VueChartwerkPodMixin, Margin, TimeSerie, Options, TickOrientation, TimeFormat, BrushOrientation, PanOrientation, AxisFormat, yAxisOrientation, CrosshairOrientation, ScrollPanOrientation, KeyEvent, palette };