diff --git a/src/index.ts b/src/index.ts index ac9ec2e..256cc25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,13 +2,15 @@ import VueChartwerkPodMixin from './VueChartwerkPodMixin'; import { PodState } from './state'; import { Grid } from './components/grid'; +import { CoreSeries } from './models/series'; +import { CoreOptions } from './models/options'; + import styles from './css/style.css'; import { Margin, - TimeSerie, + CoreSerie, Options, - TickOrientation, TimeFormat, BrushOrientation, AxisFormat, @@ -20,96 +22,27 @@ import { ScrollPanOrientation, ScrollPanDirection, AxisOption, + AxesOptions, } from './types'; import { uid } from './utils'; import { palette } from './colors'; 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_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 - }, - doubleClick: { - isActive: true, - keyEvent: KeyEvent.MAIN, - }, - }, - scroll: { - zoom: { - isActive: true, - keyEvent: KeyEvent.MAIN, - orientation: PanOrientation.BOTH, - }, - pan: { - isActive: false, - keyEvent: KeyEvent.SHIFT, - panStep: DEFAULT_SCROLL_PAN_STEP, - orientation: ScrollPanOrientation.HORIZONTAL, - direction: ScrollPanDirection.BOTH, - }, - }, - }, - 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 { +abstract class ChartwerkPod { + + protected coreSeries: CoreSeries; + protected coreOptions: CoreOptions; + protected d3Node?: d3.Selection; protected customOverlay?: d3.Selection; protected crosshair?: d3.Selection; @@ -130,8 +63,7 @@ abstract class ChartwerkPod { protected y1AxisElement?: d3.Selection; protected yAxisTicksColors?: string[] = []; private _clipPathUID = ''; - protected series: T[]; - protected options: O; + 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 @@ -151,10 +83,8 @@ abstract class ChartwerkPod { // TODO: test if it's necessary styles.use(); - let options = cloneDeep(_options); - defaultsDeep(options, DEFAULT_OPTIONS); - this.options = options; - this.series = cloneDeep(_series); + this.coreOptions = new CoreOptions(_options); + this.coreSeries = new CoreSeries(_series); this.d3Node = d3.select(this.el); this.addEventListeners(); @@ -173,9 +103,7 @@ abstract class ChartwerkPod { } public render(): void { - if(has(this.options.eventsCallbacks, 'renderStart')) { - this.options.eventsCallbacks.renderStart(); - } + this.coreOptions.callbackRenderStart(); this.renderClipPath(); this.addEvents(); @@ -191,9 +119,7 @@ abstract class ChartwerkPod { this.renderYLabel(); this.renderXLabel(); - if(has(this.options.eventsCallbacks, 'renderEnd')) { - this.options.eventsCallbacks.renderEnd(); - } + this.coreOptions.callbackRenderEnd(); } public updateData(series?: T[], options?: O, shouldRerender = true): void { @@ -217,18 +143,14 @@ abstract class ChartwerkPod { if(newOptions === undefined) { return; } - let options = cloneDeep(newOptions); - defaultsDeep(options, DEFAULT_OPTIONS); - this.options = options; - // TODO: update state if axis ranges were changed + this.coreOptions.updateOptions(newOptions); } protected updateSeries(newSeries: T[]): void { if(newSeries === undefined) { return; } - let series = cloneDeep(newSeries); - this.series = series; + this.coreSeries.updateSeries(newSeries); } protected abstract renderMetrics(): void; @@ -243,7 +165,8 @@ abstract class ChartwerkPod { height: this.height, width: this.width, } - this.state = new PodState(boxPararms, this.series, this.options); + // TODO: use instanses instead of oblects + this.state = new PodState(boxPararms, this.coreSeries.visibleSeries, this.coreOptions.allOptions); } protected initComponents(): void { @@ -256,7 +179,7 @@ abstract class ChartwerkPod { yScale: this.state.yScale, } - this.grid = new Grid(this.chartContainer, svgElParams, this.options.grid); + this.grid = new Grid(this.chartContainer, svgElParams, this.coreOptions.grid); } protected renderMetricsContainer(): void { @@ -299,7 +222,7 @@ abstract class ChartwerkPod { } protected renderXAxis(): void { - if(this.options.axis.x.isActive === false) { + if(this.coreOptions.axis.x.isActive === false) { return; } this.chartContainer.select('#x-axis-container').remove(); @@ -310,16 +233,14 @@ abstract class ChartwerkPod { .style('pointer-events', 'none') .call( d3.axisBottom(this.xScale) - .ticks(this.options.axis.x.ticksCount) + .ticks(this.coreOptions.axis.x.ticksCount) .tickSize(DEFAULT_TICK_SIZE) - .tickFormat(this.getAxisTicksFormatter(this.options.axis.x)) + .tickFormat(this.getAxisTicksFormatter(this.coreOptions.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) { + if(this.coreOptions.axis.y.isActive === false) { return; } this.chartContainer.select('#y-axis-container').remove(); @@ -331,9 +252,9 @@ abstract class ChartwerkPod { // TODO: number of ticks shouldn't be hardcoded .call( d3.axisLeft(this.yScale) - .ticks(this.options.axis.y.ticksCount) + .ticks(this.coreOptions.axis.y.ticksCount) .tickSize(DEFAULT_TICK_SIZE) - .tickFormat(this.getAxisTicksFormatter(this.options.axis.y)) + .tickFormat(this.getAxisTicksFormatter(this.coreOptions.axis.y)) ); const ticks = this.yAxisElement.selectAll(`.tick`).select('text').nodes(); this.yAxisTicksColors.map((color, index) => { @@ -345,7 +266,7 @@ abstract class ChartwerkPod { } protected renderY1Axis(): void { - if(this.options.axis.y1.isActive === false) { + if(this.coreOptions.axis.y1.isActive === false) { return; } this.chartContainer.select('#y1-axis-container').remove(); @@ -359,7 +280,7 @@ abstract class ChartwerkPod { d3.axisRight(this.y1Scale) .ticks(DEFAULT_TICK_COUNT) .tickSize(DEFAULT_TICK_SIZE) - .tickFormat(this.getAxisTicksFormatter(this.options.axis.y1)) + .tickFormat(this.getAxisTicksFormatter(this.coreOptions.axis.y1)) ); } @@ -371,28 +292,28 @@ abstract class ChartwerkPod { .style('display', 'none'); if( - this.options.crosshair.orientation === CrosshairOrientation.VERTICAL || - this.options.crosshair.orientation === CrosshairOrientation.BOTH + this.coreOptions.crosshair.orientation === CrosshairOrientation.VERTICAL || + this.coreOptions.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('fill', this.coreOptions.crosshair.color) + .attr('stroke', this.coreOptions.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.coreOptions.crosshair.orientation === CrosshairOrientation.HORIZONTAL || + this.coreOptions.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('fill', this.coreOptions.crosshair.color) + .attr('stroke', this.coreOptions.crosshair.color) .attr('stroke-width', '1px') .attr('x1', 0) .attr('x2', this.width) @@ -402,8 +323,8 @@ abstract class ChartwerkPod { 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; + const panKeyEvent = this.coreOptions.mousePanEvent.keyEvent; + const isPanActive = this.coreOptions.mousePanEvent.isActive; if(isPanActive === true && panKeyEvent === KeyEvent.MAIN) { this.initPan(); this.initBrush(); @@ -420,11 +341,11 @@ abstract class ChartwerkPod { } protected initBrush(): void { - const isBrushActive = this.options.zoomEvents.mouse.zoom.isActive; + const isBrushActive = this.coreOptions.mouseZoomEvent.isActive; if(isBrushActive === false) { return; } - switch(this.options.zoomEvents.mouse.zoom.orientation) { + switch(this.coreOptions.mouseZoomEvent.orientation) { case BrushOrientation.VERTICAL: this.brush = d3.brushY(); break; @@ -438,7 +359,7 @@ abstract class ChartwerkPod { default: this.brush = d3.brushX(); } - const keyEvent = this.options.zoomEvents.mouse.zoom.keyEvent; + const keyEvent = this.coreOptions.mouseZoomEvent.keyEvent; this.brush.extent([ [0, 0], [this.width, this.height] @@ -476,13 +397,13 @@ abstract class ChartwerkPod { 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 + this.coreOptions.mousePanEvent.isActive === false && + this.coreOptions.scrollPanEvent.isActive === false && + this.coreOptions.scrollZoomEvent.isActive === false ) { return; } - if(this.options.zoomEvents.mouse.zoom.isActive === false) { + if(this.coreOptions.mouseZoomEvent.isActive === false) { // init cumstom overlay to handle all events this.customOverlay = this.chartContainer.append('rect') .attr('class', 'custom-overlay') @@ -497,7 +418,7 @@ abstract class ChartwerkPod { this.initScaleX = this.xScale.copy(); this.initScaleY = this.yScale.copy(); - if(this.options.axis.y1.isActive === true) { + if(this.coreOptions.axis.y1.isActive) { this.initScaleY1 = this.y1Scale.copy(); } this.pan = d3.zoom() @@ -518,54 +439,45 @@ abstract class ChartwerkPod { } protected renderLegend(): void { - if(this.options.renderLegend === false) { + if(this.coreOptions.allOptions.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); - } - }); + if(!this.coreSeries.isSeriesAvailable) { + return; + } + let legendRow = this.chartContainer + .append('g') + .attr('class', 'legend-row'); + const series = this.coreSeries.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(`
`) + .on('click', () => this.coreOptions.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.coreOptions.callbackLegendLabelClick(idx)); } } protected renderYLabel(): void { - if(this.options.labelFormat === undefined || this.options.labelFormat.yAxis === undefined) { + if(this.coreOptions.axis.y.label === undefined) { return; } this.chartContainer.append('text') @@ -577,15 +489,15 @@ abstract class ChartwerkPod { .style('text-anchor', 'middle') .style('font-size', '14px') .style('fill', 'currentColor') - .text(this.options.labelFormat.yAxis); + .text(this.coreOptions.axis.y.label); } protected renderXLabel(): void { - if(this.options.labelFormat === undefined || this.options.labelFormat.xAxis === undefined) { + if(this.coreOptions.axis.x.label === undefined) { return; } let yPosition = this.height + this.margin.top + this.margin.bottom - 35; - if(this.series.length === 0) { + if(this.coreSeries.isSeriesAvailable) { yPosition += 20; } this.chartContainer.append('text') @@ -595,7 +507,7 @@ abstract class ChartwerkPod { .style('text-anchor', 'middle') .style('font-size', '14px') .style('fill', 'currentColor') - .text(this.options.labelFormat.xAxis); + .text(this.coreOptions.axis.x.label); } protected renderNoDataPointsMessage(): void { @@ -611,12 +523,12 @@ abstract class ChartwerkPod { private disableScrollForward(event: any): boolean { return event.sourceEvent.wheelDelta > 0 - && this.options.zoomEvents.scroll.pan.direction === ScrollPanDirection.FORWARD; + && this.coreOptions.scrollPanEvent.direction === ScrollPanDirection.FORWARD; } private disableScrollBackward(event: any): boolean { return event.sourceEvent.wheelDelta < 0 - && this.options.zoomEvents.scroll.pan.direction === ScrollPanDirection.BACKWARD; + && this.coreOptions.scrollPanEvent.direction === ScrollPanDirection.BACKWARD; } protected onPanning(): void { @@ -630,14 +542,10 @@ abstract class ChartwerkPod { } 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'); - } + this.coreOptions.callbackPanning({ + ranges: [this.state.xValueRange, this.state.yValueRange, this.state.y1ValueRange], + d3Event: event + }); } public rescaleMetricAndAxis(event: d3.D3ZoomEvent): void { @@ -656,8 +564,8 @@ abstract class ChartwerkPod { 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; + const scrollPanOptions = this.coreOptions.scrollPanEvent; + const scrollZoomOptions = this.coreOptions.scrollZoomEvent; // TODO: maybe use switch and move it to onPanning if(eventType === 'wheel') { if(scrollPanOptions.isActive === true && this.isD3EventKeyEqualOption(event, scrollPanOptions.keyEvent)) { @@ -690,7 +598,7 @@ abstract class ChartwerkPod { return; } - const panOrientation = this.options.zoomEvents.mouse.pan.orientation; + const panOrientation = this.coreOptions.mousePanEvent.orientation; if(panOrientation === PanOrientation.HORIZONTAL || panOrientation === PanOrientation.BOTH) { this.rescaleAxisX(event.transform.x); } @@ -723,7 +631,7 @@ abstract class ChartwerkPod { } protected onScrollPanningRescale(event: d3.D3ZoomEvent): void { - const scrollPanOptions = this.options.zoomEvents.scroll.pan; + const scrollPanOptions = this.coreOptions.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; @@ -740,7 +648,7 @@ abstract class ChartwerkPod { case ScrollPanOrientation.VERTICAL: // @ts-ignore let signY = Math.sign(event.transform.y); - if(this.options.axis.y.invert === true) { + if(this.coreOptions.axis.y.invert === true) { signY = -signY; } let rangeY = this.state.yValueRange; @@ -764,16 +672,12 @@ abstract class ChartwerkPod { 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'); - } + this.coreOptions.callbackPanningEnd([this.state.xValueRange, this.state.yValueRange, this.state.y1ValueRange]); } protected onBrush(): void { const selection = d3.event.selection; - if(this.options.zoomEvents.mouse.zoom.orientation !== BrushOrientation.SQUARE || selection === null) { + if(this.coreOptions.mouseZoomEvent.orientation !== BrushOrientation.SQUARE || selection === null) { return; } const selectionAtts = this.getSelectionAttrs(selection); @@ -829,7 +733,7 @@ abstract class ChartwerkPod { let xRange: [number, number]; let yRange: [number, number]; - switch(this.options.zoomEvents.mouse.zoom.orientation) { + switch(this.coreOptions.mouseZoomEvent.orientation) { case BrushOrientation.HORIZONTAL: const startTimestamp = this.xScale.invert(extent[0]); const endTimestamp = this.xScale.invert(extent[1]); @@ -869,28 +773,17 @@ abstract class ChartwerkPod { 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'); - } + this.coreOptions.callbackZoomIn([xRange, yRange]); } 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'); - } + this.coreOptions.callbackZoomOut(centers); } // TODO: move to State @@ -921,10 +814,10 @@ abstract class ChartwerkPod { } getd3TimeRangeEvery(count: number): d3.TimeInterval { - if(this.options.timeInterval === undefined || this.options.timeInterval.timeFormat === undefined) { + if(this.coreOptions.allOptions.timeInterval.timeFormat === undefined) { return d3.timeMinute.every(count); } - switch(this.options.timeInterval.timeFormat) { + switch(this.coreOptions.allOptions.timeInterval.timeFormat) { case TimeFormat.SECOND: return d3.utcSecond.every(count); case TimeFormat.MINUTE: @@ -943,11 +836,11 @@ abstract class ChartwerkPod { } get serieTimestampRange(): number | undefined { - if(this.series.length === 0) { + if(!this.coreSeries.isSeriesAvailable) { return undefined; } - const startTimestamp = first(this.series[0].datapoints)[0]; - const endTimestamp = last(this.series[0].datapoints)[0]; + const startTimestamp = first(this.coreSeries.visibleSeries[0].datapoints)[0]; + const endTimestamp = last(this.coreSeries.visibleSeries[0].datapoints)[0]; return (endTimestamp - startTimestamp) / 1000; } @@ -982,63 +875,17 @@ abstract class ChartwerkPod { } 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]; + if(this.coreSeries.isSeriesAvailable && this.coreSeries.visibleSeries[0].datapoints.length > 1) { + const interval = this.coreSeries.visibleSeries[0].datapoints[1][0] - this.coreSeries.visibleSeries[0].datapoints[0][0]; return interval; } - if(this.options.timeInterval !== undefined && this.options.timeInterval.count !== undefined) { + if(this.coreOptions.allOptions.timeInterval.count !== undefined) { //TODO: timeFormat to timestamp - return this.options.timeInterval.count * MILISECONDS_IN_MINUTE; + return this.coreOptions.allOptions.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; } @@ -1052,78 +899,25 @@ abstract class ChartwerkPod { } 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; + return this.coreOptions.margin; } 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 = 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, + Margin, CoreSerie, Options, TimeFormat, BrushOrientation, PanOrientation, + AxesOptions, AxisOption, AxisFormat, yAxisOrientation, CrosshairOrientation, ScrollPanOrientation, ScrollPanDirection, KeyEvent, palette }; diff --git a/src/models/options.ts b/src/models/options.ts new file mode 100644 index 0000000..6627586 --- /dev/null +++ b/src/models/options.ts @@ -0,0 +1,210 @@ +import { + Options, + GridOptions, AxesOptions, AxisFormat, + CrosshairOptions, CrosshairOrientation, + ZoomEvents, MouseZoomEvent, MousePanEvent, DoubleClickEvent, ScrollZoomEvent, ScrollPanEvent, + ScrollPanOrientation, ScrollPanDirection, PanOrientation, KeyEvent, BrushOrientation, + Margin, TimeFormat, AxisRange, +} from '../types'; + +import lodashDefaultsDeep from 'lodash/defaultsDeep'; +import lodashMap from 'lodash/map'; +import lodashCloneDeep from 'lodash/cloneDeep'; +import has from 'lodash/has'; + + +const DEFAULT_TICK_COUNT = 4; +const DEFAULT_SCROLL_PAN_STEP = 50; +const DEFAULT_GRID_TICK_COUNT = 5; + +const DEFAULT_GRID_OPTIONS: GridOptions = { + x: { + enabled: true, + ticksCount: DEFAULT_GRID_TICK_COUNT, + }, + y: { + enabled: true, + ticksCount: DEFAULT_GRID_TICK_COUNT, + }, +} + +const DEFAULT_AXES_OPTIONS: AxesOptions = { + 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 + } +} + +const DEFAULT_CROSSHAIR_OPTIONS = { + orientation: CrosshairOrientation.VERTICAL, + color: 'gray', +} + +const DEFAULT_MARGIN: Margin = { top: 30, right: 20, bottom: 20, left: 30 }; + +const DEFAULT_OPTIONS: Options = { + zoomEvents: { + mouse: { + zoom: { + isActive: true, + keyEvent: KeyEvent.MAIN, + orientation: BrushOrientation.HORIZONTAL + }, + pan: { + isActive: true, + keyEvent: KeyEvent.SHIFT, + orientation: PanOrientation.HORIZONTAL + }, + doubleClick: { + isActive: true, + keyEvent: KeyEvent.MAIN, + }, + }, + scroll: { + zoom: { + isActive: true, + keyEvent: KeyEvent.MAIN, + orientation: PanOrientation.BOTH, + }, + pan: { + isActive: false, + keyEvent: KeyEvent.SHIFT, + panStep: DEFAULT_SCROLL_PAN_STEP, + orientation: ScrollPanOrientation.HORIZONTAL, + direction: ScrollPanDirection.BOTH, + }, + }, + }, + axis: DEFAULT_AXES_OPTIONS, + grid: DEFAULT_GRID_OPTIONS, + crosshair: DEFAULT_CROSSHAIR_OPTIONS, + renderLegend: true, + margin: DEFAULT_MARGIN, + // remove options below + renderTicksfromTimestamps: false, + timeInterval: { + timeFormat: TimeFormat.MINUTE + }, +} + +export class CoreOptions { + _options: O; + _defaults: Options = DEFAULT_OPTIONS; + + constructor(options: O) { + this.setOptions(options); + } + + public updateOptions(options: O): void { + this.setOptions(options); + } + + protected setOptions(options: O): void { + this._options = lodashDefaultsDeep(lodashCloneDeep(options), this._defaults); + } + + get allOptions(): O { + return this._options; + } + + get grid(): GridOptions { + return this._options.grid; + } + + get axis(): AxesOptions { + return this._options.axis; + } + + get crosshair(): CrosshairOptions { + return this._options.crosshair; + } + + get margin(): Margin { + return this._options.margin; + } + + // events + get allEvents(): ZoomEvents { + return this._options.zoomEvents; + } + + get mouseZoomEvent(): MouseZoomEvent { + return this._options.zoomEvents.mouse.zoom; + } + + get mousePanEvent(): MousePanEvent { + return this._options.zoomEvents.mouse.pan; + } + + get doubleClickEvent(): DoubleClickEvent { + return this._options.zoomEvents.mouse.doubleClick; + } + + get scrollZoomEvent(): ScrollZoomEvent { + return this._options.zoomEvents.scroll.zoom; + } + + get scrollPanEvent(): ScrollPanEvent { + return this._options.zoomEvents.scroll.pan; + } + + // event callbacks + callbackRenderStart(): void { + if(has(this._options.eventsCallbacks, 'renderStart')) { + this._options.eventsCallbacks.renderStart(); + } + } + + callbackRenderEnd(): void { + if(has(this._options.eventsCallbacks, 'renderEnd')) { + this._options.eventsCallbacks.renderStart(); + } + } + + callbackLegendClick(idx: number): void { + if(has(this._options.eventsCallbacks, 'onLegendClick')) { + this._options.eventsCallbacks.onLegendClick(idx); + } + } + + callbackLegendLabelClick(idx: number): void { + if(has(this._options.eventsCallbacks, 'onLegendLabelClick')) { + this._options.eventsCallbacks.onLegendLabelClick(idx); + } + } + + callbackPanning(event: { ranges: AxisRange[], d3Event: any }): void { + if(has(this._options.eventsCallbacks, 'panning')) { + this._options.eventsCallbacks.panning(event); + } + } + + callbackPanningEnd(ranges: AxisRange[]): void { + if(has(this._options.eventsCallbacks, 'panningEnd')) { + this._options.eventsCallbacks.panningEnd(ranges); + } + } + + callbackZoomIn(ranges: AxisRange[]): void { + if(has(this._options.eventsCallbacks, 'zoomIn')) { + this._options.eventsCallbacks.zoomIn(ranges); + } + } + + callbackZoomOut(centers: { x: number, y: number }): void { + if(has(this._options.eventsCallbacks, 'zoomOut')) { + this._options.eventsCallbacks.zoomOut(centers); + } + } +} diff --git a/src/models/series.ts b/src/models/series.ts new file mode 100644 index 0000000..86dde81 --- /dev/null +++ b/src/models/series.ts @@ -0,0 +1,73 @@ +import { CoreSerie, yAxisOrientation } from '../types'; +import { palette } from '../colors'; + +import lodashDefaultsDeep from 'lodash/defaultsDeep'; +import lodashMap from 'lodash/map'; +import lodashCloneDeep from 'lodash/cloneDeep'; +import lodashUniq from 'lodash/uniq'; + + +const SERIE_DEFAULTS = { + alias: '', + target: '', + visible: true, + yOrientation: yAxisOrientation.LEFT, + datapoints: [], + // fields below will be set in "fillDefaults" method + idx: undefined, + color: undefined, +}; + +export class CoreSeries { + _series: Array = []; + _defaults: CoreSerie = SERIE_DEFAULTS; + + constructor(series: T[]) { + this.setSeries(series); + } + + public updateSeries(series: T[]): void { + this.setSeries(series); + } + + protected setSeries(series: T[]): void { + this._series = lodashMap(series, (serie, serieIdx) => this.fillDefaults(lodashCloneDeep(serie), serieIdx)); + this.ensureSeriesValid(this._series); + } + + ensureSeriesValid(series: T[]): void { + const targets = lodashMap(series, serie => serie.target); + const uniqTargets = lodashUniq(targets); + if(uniqTargets.length !== series.length) { + throw new Error(`All serie.target should be uniq`); + } + } + + protected fillDefaults(serie: T, idx: number): T { + let defaults = lodashCloneDeep(this._defaults); + defaults.color = palette[idx % palette.length]; + defaults.idx = idx; + lodashDefaultsDeep(serie, defaults); + return serie; + } + + get isSeriesAvailable(): boolean { + return this.visibleSeries.length > 0; + } + + get visibleSeries(): Array { + return this._series.filter(serie => serie.visible); + } + + get allSeries(): Array { + return this._series; + } + + get leftYRelatedSeries(): Array { + return this.visibleSeries.filter(serie => serie.yOrientation = yAxisOrientation.LEFT); + } + + get rightYRelatedSeries(): Array { + return this.visibleSeries.filter(serie => serie.yOrientation = yAxisOrientation.RIGHT); + } +} diff --git a/src/state.ts b/src/state.ts index aec86b8..75ee733 100755 --- a/src/state.ts +++ b/src/state.ts @@ -1,4 +1,4 @@ -import { TimeSerie, Options, yAxisOrientation } from './types'; +import { CoreSerie, Options, yAxisOrientation } from './types'; import * as d3 from 'd3'; @@ -22,7 +22,7 @@ const DEFAULT_TRANSFORM = { // TODO: remove duplicates in max/min values. // TODO: PodState can be divided in two classes, but it is hard now. // TODO: PodState.transform has conflicts with d3.zoom.event.transform. It should be synchronized. -export class PodState { +export class PodState { private _xValueRange: [number, number]; private _yValueRange: [number, number]; private _y1ValueRange: [number, number]; @@ -245,7 +245,7 @@ export class PodState { } protected filterSerieByYAxisOrientation(serie: T, orientation: yAxisOrientation): boolean { - if(serie.yOrientation === undefined || serie.yOrientation === yAxisOrientation.BOTH) { + if(serie.yOrientation === undefined) { return true; } return serie.yOrientation === orientation; diff --git a/src/types.ts b/src/types.ts index 6e55391..e156fb8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,9 +4,10 @@ export type Margin = { top: number, right: number, bottom: number, left: number export type Timestamp = number; // TODO: Pods can render not only "time" series -export type TimeSerie = { +export type CoreSerie = { target: string, datapoints: [Timestamp, number][], + idx?: number, alias?: string, visible?: boolean, color?: string, @@ -15,7 +16,6 @@ export type TimeSerie = { // TODO: move some options to line-chart export type Options = { margin?: Margin; - confidence?: number; eventsCallbacks?: { zoomIn?: (range: AxisRange[]) => void, panning?: (event: { ranges: AxisRange[], d3Event: any }) => void, @@ -30,71 +30,18 @@ export type Options = { renderStart?: () => void, renderEnd?: () => void, }; - axis?: { - x?: AxisOption, - y?: AxisOption, - y1?: AxisOption - }; + axis?: AxesOptions; grid?: GridOptions; - crosshair?: { - orientation?: CrosshairOrientation; - color?: string; - } + crosshair?: CrosshairOptions; timeInterval?: { timeFormat?: TimeFormat; count?: number; }; - tickFormat?: { - xAxis?: string; - xTickOrientation?: TickOrientation; - }; - labelFormat?: { - xAxis?: string; - yAxis?: string; - }; - bounds?: { - upper: string; - lower: string; - }; - timeRange?: { - from: number, - to: number - }; - zoomEvents?: { - mouse?: { - zoom?: { // same as brush - isActive: boolean; - keyEvent?: KeyEvent; // main(or base, or smth) / shift / alt / etc - orientation?: BrushOrientation; // to BrushOrientation: vertical, horizaontal, square, rectange - }, - pan?: { - isActive: boolean; - keyEvent?: KeyEvent; // main(or base, or smth) / shift / alt / etc - orientation?: PanOrientation; - }, - doubleClick?: { - isActive: boolean; - keyEvent?: KeyEvent; - }, - }, - scroll?: { - zoom?: { - isActive: boolean; - keyEvent?: KeyEvent; - orientation?: PanOrientation; // TODO: rename - }, - pan?: { - isActive: boolean; - keyEvent?: KeyEvent; - panStep?: number; - orientation?: ScrollPanOrientation; - direction?: ScrollPanDirection; - }, - }, - } + zoomEvents?: ZoomEvents; renderTicksfromTimestamps?: boolean; renderLegend?: boolean; }; + export type GridOptions = { x?: { enabled?: boolean; @@ -105,22 +52,32 @@ export type GridOptions = { ticksCount?: number; }, } + +export type AxesOptions = { + x?: AxisOption, + y?: AxisOption, + y1?: AxisOption +} + export type AxisOption = { isActive?: boolean; ticksCount?: number; format?: AxisFormat; range?: [number, number]; invert?: boolean; + label?: string; valueFormatter?: (value: number, i: number) => string; colorFormatter?: (value: number, i: number) => string; } + +export type CrosshairOptions = { + orientation?: CrosshairOrientation; + color?: string; +} + export type AxisRange = [number, number] | undefined; export type VueOptions = Omit; -export enum TickOrientation { - VERTICAL = 'vertical', - HORIZONTAL = 'horizontal', - DIAGONAL = 'diagonal' -} + export enum TimeFormat { SECOND = 'second', MINUTE = 'minute', @@ -129,47 +86,56 @@ export enum TimeFormat { MONTH = 'month', YEAR = 'year' } + export enum BrushOrientation { VERTICAL = 'vertical', HORIZONTAL = 'horizontal', RECTANGLE = 'rectangle', SQUARE = 'square' } + export enum PanOrientation { VERTICAL = 'vertical', HORIZONTAL = 'horizontal', BOTH = 'both', } + export enum ScrollPanOrientation { VERTICAL = 'vertical', HORIZONTAL = 'horizontal', } + export enum ScrollPanDirection { FORWARD = 'forward', BACKWARD = 'backward', BOTH = 'both', } + export enum AxisFormat { TIME = 'time', NUMERIC = 'numeric', STRING = 'string', CUSTOM = 'custom' } + export enum CrosshairOrientation { VERTICAL = 'vertical', HORIZONTAL = 'horizontal', BOTH = 'both' } + export type SvgElementAttributes = { x: number, y: number, width: number, height: number } + export enum KeyEvent { MAIN = 'main', SHIFT = 'shift' } + // allow series values to affect a specific axis export enum xAxisOrientation { TOP = 'top', @@ -179,7 +145,6 @@ export enum xAxisOrientation { export enum yAxisOrientation { LEFT = 'left', RIGHT = 'right', - BOTH = 'both' } export type SvgElParams = { height: number, @@ -187,3 +152,46 @@ export type SvgElParams = { xScale: d3.ScaleLinear, yScale: d3.ScaleLinear, } + +export type ZoomEvents = { + mouse?: { + zoom?: MouseZoomEvent; + pan?: MousePanEvent; + doubleClick?: DoubleClickEvent; + }, + scroll?: { + zoom?: ScrollZoomEvent; + pan?: ScrollPanEvent; + } +} + +export type MouseZoomEvent = { // same as brush + isActive?: boolean; + keyEvent?: KeyEvent; // main(or base, or smth) / shift / alt / etc + orientation?: BrushOrientation; // to BrushOrientation: vertical, horizaontal, square, rectange +} + +export type MousePanEvent = { // same as brush + isActive?: boolean; + keyEvent?: KeyEvent; // main(or base, or smth) / shift / alt / etc + orientation?: PanOrientation; +} + +export type DoubleClickEvent = { + isActive: boolean; + keyEvent?: KeyEvent; +} + +export type ScrollZoomEvent = { + isActive?: boolean; + keyEvent?: KeyEvent; + orientation?: PanOrientation; // TODO: rename +} + +export type ScrollPanEvent = { + isActive?: boolean; + keyEvent?: KeyEvent; + panStep?: number; + orientation?: ScrollPanOrientation; + direction?: ScrollPanDirection; +}