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.
1062 lines
34 KiB
1062 lines
34 KiB
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, |
|
}, |
|
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<T extends TimeSerie, O extends Options> { |
|
protected d3Node?: d3.Selection<HTMLElement, unknown, null, undefined>; |
|
protected chartContainer?: d3.Selection<SVGGElement, unknown, null, undefined>; |
|
protected customOverlay?: 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 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 series: T[]; |
|
protected options: O; |
|
protected readonly d3: typeof d3; |
|
protected deltaYTransform = 0; |
|
protected debouncedRender = debounce(this.render.bind(this), 100); |
|
|
|
// 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.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.render(); |
|
} |
|
} |
|
|
|
protected updateOptions(newOptions: O): void { |
|
if(newOptions === undefined) { |
|
return; |
|
} |
|
let options = cloneDeep(newOptions); |
|
defaultsDeep(options, DEFAULT_OPTIONS); |
|
this.options = options; |
|
|
|
} |
|
|
|
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 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.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<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.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(); |
|
} |
|
const pan = this.d3.zoom() |
|
.on('zoom', this.onPanning.bind(this)) |
|
.on('end', this.onPanningEnd.bind(this)); |
|
|
|
this.chartContainer.call(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(`<form><input type=checkbox ${isChecked? 'checked' : ''} /></form>`) |
|
.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<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 |
|
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<any, any>): 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)) { |
|
this.state.transform = { k: event.transform.k }; |
|
} |
|
} 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.state.transform = { x: event.transform.x }; |
|
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)]; |
|
} |
|
if(panOrientation === PanOrientation.VERTICAL || panOrientation === PanOrientation.BOTH) { |
|
this.state.transform = { y: event.transform.y }; |
|
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<any, any>): 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<number, number> { |
|
const domain = [0, Math.abs(this.state.maxValueX - this.state.minValueX)]; |
|
return this.d3.scaleLinear() |
|
.domain(domain) |
|
.range([0, this.width]); |
|
} |
|
|
|
get absYScale(): d3.ScaleLinear<number, number> { |
|
const domain = [0, Math.abs(this.state.maxValueY - this.state.minValueY)]; |
|
return this.d3.scaleLinear() |
|
.domain(domain) |
|
.range([0, this.height]); |
|
} |
|
|
|
// TODO: these getters can be removed, but it will break all Pods. |
|
get xScale(): d3.ScaleLinear<number, number> { |
|
return this.state.xScale; |
|
} |
|
|
|
get yScale(): d3.ScaleLinear<number, number> { |
|
return this.state.yScale; |
|
} |
|
|
|
protected get y1Scale(): d3.ScaleLinear<number, number> { |
|
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); |
|
} |
|
|
|
formatedBound(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.formatedBound(this.options.bounds.upper, serie.target)); |
|
series.push(this.formatedBound(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 |
|
};
|
|
|