diff --git a/.gitignore b/.gitignore index 5cd6bd8..1fa1112 100755 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ node_modules dist # yarn -.yarn/* +.yarn !.yarn/patches !.yarn/plugins !.yarn/releases diff --git a/examples/area.html b/examples/area.html new file mode 100644 index 0000000..290b64a --- /dev/null +++ b/examples/area.html @@ -0,0 +1,42 @@ + + + + + + + + + +
+ + + + diff --git a/examples/markers.html b/examples/markers.html index 5f8d5f9..a08430d 100644 --- a/examples/markers.html +++ b/examples/markers.html @@ -13,9 +13,18 @@ const startTime = 1701790172908; const timeSerieData = [5, 6, 3, 7, 5, 6, 8, 4, 5, 6, 4, 3, 5, 7, 8] .map((el, idx) => [startTime + idx * 1000, el]); - // TODO: make this one-dimensinal data when implemented - const markersData1 = [3, 6, 9].map(el => [startTime + el * 1000]); - const markersData2 = [4, 11].map(el => [startTime + el * 1000]); + const markersData1 = [3, 6, 9].map(el => ({ + x: startTime + el * 1000, + color: 'red', + alwaysDisplay: false, + html: new Date(startTime).toISOString(), + })); + const markersData2 = [4, 11].map(el => ({ + x: startTime + el * 1000, + color: 'blue', + alwaysDisplay: true, + html: new Date(startTime).toISOString(), + })); let options = { renderLegend: false, axis: { @@ -31,8 +40,8 @@ options, { series: [ - { data: markersData1, color: 'red' }, - { data: markersData2, color: 'blue' }, + { data: markersData1 }, + { data: markersData2 }, ] } ); diff --git a/examples/markers_select.html b/examples/markers_select.html index 48a1623..f801e3b 100644 --- a/examples/markers_select.html +++ b/examples/markers_select.html @@ -13,11 +13,11 @@ const startTime = 1701790172908; const timeSerieData = [5, 6, 3, 7, 5, 6, 8, 4, 5, 6, 4, 3, 5, 7, 8] .map((el, idx) => [startTime + idx * 1000, el]); - // TODO: make this one-dimensinal data when implemented - const markersData = [3, 6, 9].map(el => [ - startTime + el * 1000, - { el } - ]); + const markersData = [3, 6, 9].map(el => ({ + x: startTime + el * 1000, + payload: el, + color: 'red', + })); let options = { renderLegend: false, axis: { @@ -33,7 +33,7 @@ options, { series: [ - { data: markersData, color: 'red' }, + { data: markersData }, ], events: { onMouseMove: (el) => { console.log(el); }, diff --git a/examples/right_click.html b/examples/right_click.html new file mode 100644 index 0000000..692d69a --- /dev/null +++ b/examples/right_click.html @@ -0,0 +1,33 @@ + + + + + + + + +
+ + + diff --git a/package.json b/package.json index 26b0e52..babb7ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chartwerk/line-pod", - "version": "0.6.19", + "version": "0.7.8", "description": "Chartwerk line chart", "main": "dist/index.js", "files": [ @@ -19,7 +19,7 @@ "author": "CorpGlory", "license": "ISC", "dependencies": { - "@chartwerk/core": "^0.6.23" + "@chartwerk/core": "^0.6.26" }, "devDependencies": { "copy-webpack-plugin": "^11.0.0", diff --git a/src/components/markers.ts b/src/components/markers.ts index a481059..2dfa2d1 100644 --- a/src/components/markers.ts +++ b/src/components/markers.ts @@ -1,59 +1,139 @@ -import { MarkersConf, MarkerSerie } from "../models/marker"; -import { PodState } from "@chartwerk/core"; -import { LineTimeSerie, LineOptions } from "../types"; +import { MarkerElem, MarkersConf, MarkerSerie } from '../models/marker'; +import { LineTimeSerie, LineOptions } from '../types'; -import d3 from "d3"; +import { Margin, PodState } from '@chartwerk/core'; + +import d3 from 'd3'; export class Markers { - // TODO: more semantic name - private _d3Holder = null; + private _layerContainer = null; + private _chartHeight = 0; - constructor(private _markerConf: MarkersConf, private _state: PodState) { + constructor( + private _chartContainer: d3.Selection, + private _markerConf: MarkersConf, + private _state: PodState, + private _margin: Margin, + ) { } + clear() { + if(this._layerContainer !== null) { + this._layerContainer.remove(); + } + this._chartContainer.selectAll('.marker-content').remove(); } - render(metricContainer: d3.Selection) { - if(this._d3Holder !== null) { - this._d3Holder.remove(); + render(metricContainer: d3.Selection, chartHeight: number) { + this._chartHeight = chartHeight; + this._layerContainer = metricContainer + .append('g') + .attr('class', 'markers-layer'); + for(const serie of this._markerConf.series) { + this.renderSerie(serie); } - this._d3Holder = metricContainer.append('g').attr('class', 'markers-layer'); - for (const ms of this._markerConf.series) { - this.renderSerie(ms); + } + + private _getLinePosition(marker: MarkerElem): number { + return this._state.xScale(marker.x); + } + + private _renderCircle(marker: MarkerElem) { + const linePosition = this._getLinePosition(marker); + + let circle = this._layerContainer.append('circle') + .attr('class', 'gap-circle') + .attr('stroke', marker.color) + .attr('stroke-width', '2px') + .attr('r', 4) + .attr('cx', linePosition) + .attr('cy', 5) + + circle + .attr('pointer-events', 'all') + .style('cursor', 'pointer') + .on('mousemove', () => { + const onMouseMove = this._markerConf.events?.onMouseMove; + if(onMouseMove) { + onMouseMove(marker); + return + } + if(marker.alwaysDisplay) { + return; + } + this._chartContainer + .selectAll(`.marker-content-${marker.x}`) + .style('visibility', 'visible') + .style('z-index', 9999); + }) + .on('mouseout', () => { + const onMouseOut = this._markerConf.events?.onMouseOut; + if(onMouseOut) { + onMouseOut() + return + } + if(marker.alwaysDisplay) { + return; + } + this._chartContainer + .selectAll(`.marker-content-${marker.x}`) + .style('visibility', 'hidden') + .style('z-index', 1); + }); + } + + private _renderLine(marker: MarkerElem) { + const linePosition = this._getLinePosition(marker); + + this._layerContainer.append('line') + .attr('class', 'gap-line') + .attr('stroke', marker.color) + .attr('stroke-width', '1px') + .attr('stroke-opacity', '0.3') + .attr('stroke-dasharray', '4') + .attr('x1', linePosition) + .attr('x2', linePosition) + .attr('y1', 0) + // @ts-ignore // TODO: remove ignore but boxParams are protected + .attr('y2', this._state.boxParams.height) + .attr('pointer-events', 'none'); + } + + private _renderTooltip(marker: MarkerElem) { + if(marker.html === undefined) { + return; } + + const linePosition = this._getLinePosition(marker); + + const div = this._chartContainer + .append('div') + .attr('class', `marker-content marker-content-${marker.x}`) + // @ts-ignore // TODO: remove ignore but boxParams are protected + .style('top', `${this._state.boxParams.height - this._chartHeight}px`) + .style('visibility', marker.alwaysDisplay ? 'visible' : 'hidden') + .style('position', 'absolute') + .style('border', '1px solid black') + .style('background-color', 'rgb(33, 37, 41)') + .style('color', 'rgb(255, 255, 255)') + .style('line-height', '1.55') + .style('font-size', '0.875rem') + .style('border-radius', '0.5rem') + .style('padding', 'calc(0.3125rem) 0.625rem') + .style('position', 'absolute') + .style('white-space', 'nowrap') + .style('pointer-events', 'none') + .style('z-index', 1) + .html(marker.html); + + // align tooltip: center (we need it to be rendered first) + div.style('left', `${linePosition + this._margin.left - div.node().getBoundingClientRect().width / 2}px`) } protected renderSerie(serie: MarkerSerie) { - serie.data.forEach((d) => { - let linePosition = this._state.xScale(d[0]) as number; - this._d3Holder.append('line') - .attr('class', 'gap-line') - .attr('stroke', serie.color) - .attr('stroke-width', '1px') - .attr('stroke-opacity', '0.3') - .attr('stroke-dasharray', '4') - .attr('x1', linePosition) - .attr('x2', linePosition) - .attr('y1', 0) - // @ts-ignore // TODO: remove ignore but boxParams are protected - .attr('y2', this._state.boxParams.height) - .attr('pointer-events', 'none'); - let circle = this._d3Holder.append('circle') - .attr('class', 'gap-circle') - .attr('stroke', serie.color) - .attr('stroke-width', '2px') - .attr('r', 4) - .attr('cx', linePosition) - .attr('cy', 5) - - if(this._markerConf !== undefined) { - circle - .attr('pointer-events', 'all') - .style('cursor', 'pointer') - .on('mousemove', () => this._markerConf.events.onMouseMove(d)) - .on('mouseout', () => this._markerConf.events.onMouseOut()) - } - + serie.data.forEach((marker: MarkerElem) => { + this._renderLine(marker); + this._renderCircle(marker); + this._renderTooltip(marker); }); - } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 4a2f291..d66ff42 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { ChartwerkPod, VueChartwerkPodMixin, TimeFormat, CrosshairOrientation, BrushOrientation, yAxisOrientation } from '@chartwerk/core'; -import { LineTimeSerie, LineOptions, MouseObj } from './types'; +import { LineTimeSerie, LineOptions, MouseObj, AreaType } from './types'; import { Markers } from './components/markers'; import { Segments } from './components/segments'; @@ -14,14 +14,9 @@ const METRIC_CIRCLE_RADIUS = 1.5; const CROSSHAIR_CIRCLE_RADIUS = 3; const CROSSHAIR_BACKGROUND_RAIDUS = 9; const CROSSHAIR_BACKGROUND_OPACITY = 0.3; - +type Generator = d3.Line<[number, number]> | d3.Area<[number, number]>; class LinePod extends ChartwerkPod { - lineGenerator = null; - areaGenerator = null; - lineGeneratorY1 = null; - areaGeneratorY1 = null; - private _markersLayer: Markers = null; private _segmentsLayer: Segments = null; @@ -40,55 +35,70 @@ class LinePod extends ChartwerkPod { this.clearAllMetrics(); this.updateCrosshair(); - this.initLineGenerator(); - this.initAreaGenerator(); + this.updateEvents(); if(!this.series.isSeriesAvailable) { this.renderNoDataPointsMessage(); return; } for(const serie of this.series.visibleSeries) { - this._renderMetric(serie); + const generator = this.getRenderGenerator(serie.renderArea, serie.yOrientation); + this._renderMetric(serie, generator); } - if(this._markersConf !== undefined) { - this._markersLayer = new Markers(this._markersConf, this.state); - this._markersLayer.render(this.metricContainer); + if(!_.isEmpty(this._markersConf)) { + this._markersLayer = new Markers(this.d3Node, this._markersConf, this.state, this.margin); + this._markersLayer.render(this.metricContainer, this.height); } - this._segmentsLayer = new Segments(this._segmentSeries, this.state); - this._segmentsLayer.render(this.metricContainer, this.chartContainer); + if(!_.isEmpty(this._segmentSeries)) { + this._segmentsLayer = new Segments(this._segmentSeries, this.state); + this._segmentsLayer.render(this.metricContainer, this.chartContainer); + } } clearAllMetrics(): void { // TODO: temporary hack before it will be implemented in core. this.chartContainer.selectAll('.metric-el').remove(); + this._markersLayer?.clear(); } - initLineGenerator(): void { - this.lineGenerator = d3.line() - .x(d => this.state.xScale(d[0])) - .y(d => this.state.yScale(d[1])); - this.lineGeneratorY1 = d3.line() - .x(d => this.state.xScale(d[0])) - .y(d => this.state.y1Scale(d[1])); - } - - initAreaGenerator(): void { - this.areaGenerator = d3.area() - .x(d => this.state.xScale(d[0])) - .y1(d => this.state.yScale(d[1])) - .y0(d => this.height); - this.areaGeneratorY1 = d3.area() - .x(d => this.state.xScale(d[0])) - .y1(d => this.state.y1Scale(d[1])) - .y0(d => this.height); - } - - getRenderGenerator(renderArea: boolean, yOrientation: yAxisOrientation): any { - if(renderArea) { - return yOrientation === yAxisOrientation.LEFT ? this.areaGenerator : this.areaGeneratorY1; + protected updateEvents(): void { + // overlay - core component that is used to handle mouse events + if(!this.overlay) { + return; + } + if(this.options._options.events?.contextMenu === undefined) { + return; + } + this.overlay.on('contextmenu', this.onContextMenu.bind(this)); + } + + getRenderGenerator(renderArea: AreaType, yOrientation: yAxisOrientation): Generator { + const yScale = yOrientation === yAxisOrientation.LEFT ? this.state.yScale : this.state.y1Scale; + const yValueRange = yOrientation === yAxisOrientation.LEFT ? this.state.yValueRange : this.state.y1ValueRange; + const yAxisOptions = yOrientation === yAxisOrientation.LEFT ? this.options.axis.y : this.options.axis.y1; + + const topChartBorder = !yAxisOptions.invert ? yScale(yValueRange[1]) : yScale(yValueRange[0]); + const bottomChartBorder = !yAxisOptions.invert ? yScale(yValueRange[0]) : yScale(yValueRange[1]); + switch(renderArea) { + case AreaType.NONE: + // return line generator + return d3.line() + .x(d => this.state.xScale(d[0])) + .y(d => yScale(d[1])); + case AreaType.ABOVE: + return d3.area() + .x(d => this.state.xScale(d[0])) + .y0(topChartBorder) + .y1(d => yScale(d[1])); + case AreaType.BELOW: + return d3.area() + .x(d => this.state.xScale(d[0])) + .y0(d => yScale(d[1])) + .y1(bottomChartBorder); + default: + throw new Error(`Unknown type of renderArea: ${renderArea}`); } - return yOrientation === yAxisOrientation.LEFT ? this.lineGenerator : this.areaGeneratorY1; } _renderDots(serie: LineTimeSerie): void { @@ -101,12 +111,12 @@ class LinePod extends ChartwerkPod { .attr('r', METRIC_CIRCLE_RADIUS) .style('pointer-events', 'none') .attr('cx', d => this.state.xScale(d[0])) - .attr('cy', d => this.state.yScale(d[1])); + .attr('cy', d => this.state.getYScaleByOrientation(serie.yOrientation)(d[1])); } - _renderLines(serie: LineTimeSerie): void { - const fillColor = serie.renderArea ? serie.color : 'none'; - const fillOpacity = serie.renderArea ? 0.5 : 'none'; + _renderLines(serie: LineTimeSerie, generator: Generator): void { + const fillColor = serie.renderArea !== AreaType.NONE ? serie.color : 'none'; + const fillOpacity = serie.renderArea !== AreaType.NONE ? 0.5 : 'none'; this.metricContainer .append('path') @@ -119,12 +129,12 @@ class LinePod extends ChartwerkPod { .attr('stroke-opacity', 0.7) .attr('pointer-events', 'none') .style('stroke-dasharray', serie.dashArray) - .attr('d', this.getRenderGenerator(serie.renderArea, serie.yOrientation)); + .attr('d', generator); } - _renderMetric(serie: LineTimeSerie): void { + _renderMetric(serie: LineTimeSerie, generator: Generator): void { if(serie.renderLines === true) { - this._renderLines(serie); + this._renderLines(serie, generator); } if(serie.renderDots === true) { @@ -446,7 +456,7 @@ class LinePod extends ChartwerkPod { // TODO: refactor core not to take _options explicitly if( - this.options._options.events !== undefined && + this.options._options.events !== undefined && this.options._options.events.zoomOut !== undefined ) { this.options._options.events.zoomOut( @@ -455,12 +465,56 @@ class LinePod extends ChartwerkPod { ); } } + + protected onContextMenu(): void { + d3.event.preventDefault(); // do not open browser's context menu. + const eventX = d3.mouse(this.chartContainer.node())[0]; + const eventY = d3.mouse(this.chartContainer.node())[1]; + + this.options._options.events.contextMenu({ + x: this.state.xScale.invert(eventX), + y: this.state.yScale.invert(eventY), + }); + } + + // override parent updateData method to provide markers and segments + protected updateLineData( + series?: LineTimeSerie[], + options?: LineOptions, + markersConf?: MarkersConf, + segments?: SegmentSerie[], + shouldRerender = true + ): void { + this.updateMarkers(markersConf); + this.updateSegments(segments); + this.updateData(series, options, shouldRerender); + } } // TODO: it should be moved to VUE folder // it is used with Vue.component, e.g.: Vue.component('chartwerk-line-pod', VueChartwerkLinePod) export const VueChartwerkLinePod = { // alternative to `template: '
'` + props: { + markersConf: { + type: Object, + required: false, + default: function() { return {}; } + }, + segments: { + type: Array, + required: false, + default: function() { return []; } + }, + }, + watch: { + markersConf() { + this.renderChart(); + }, + segments() { + this.renderChart(); + }, + }, render(createElement) { return createElement( 'div', @@ -474,10 +528,10 @@ export const VueChartwerkLinePod = { methods: { render() { if(this.pod === undefined) { - this.pod = new LinePod(document.getElementById(this.id), this.series, this.options); + this.pod = new LinePod(document.getElementById(this.id), this.series, this.options, this.markersConf, this.segments); this.pod.render(); } else { - this.pod.updateData(this.series, this.options); + this.pod.updateLineData(this.series, this.options, this.markersConf, this.segments); } }, renderSharedCrosshair(values) { @@ -489,4 +543,4 @@ export const VueChartwerkLinePod = { } }; -export { LineTimeSerie, LineOptions, TimeFormat, LinePod }; +export { LineTimeSerie, LineOptions, TimeFormat, LinePod, AreaType, MarkersConf, SegmentSerie }; diff --git a/src/models/line_series.ts b/src/models/line_series.ts index d75cf07..55737ef 100644 --- a/src/models/line_series.ts +++ b/src/models/line_series.ts @@ -1,5 +1,5 @@ import { CoreSeries, yAxisOrientation } from '@chartwerk/core'; -import { LineTimeSerie } from '../types'; +import { LineTimeSerie, AreaType } from '../types'; import * as _ from 'lodash'; @@ -10,7 +10,7 @@ const LINE_SERIE_DEFAULTS = { renderLines: true, dashArray: '0', class: '', - renderArea: false, + renderArea: AreaType.NONE, yOrientation: yAxisOrientation.LEFT, }; diff --git a/src/models/marker.ts b/src/models/marker.ts index 7b75deb..f0ae211 100644 --- a/src/models/marker.ts +++ b/src/models/marker.ts @@ -1,9 +1,13 @@ -export type MarkerElem = [number, any?]; +export type MarkerElem = { + x: number; + color: string; + html?: string; + alwaysDisplay?: boolean; + payload?: any; +} export type MarkerSerie = { - color: string; - // TODO: make one-dimensional array with only x - data: MarkerElem[] // [x, payload] payload is any data for tooltip + data: MarkerElem[]; } export type MarkersConf = { diff --git a/src/types.ts b/src/types.ts index 3a61c33..53a71b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,12 +2,18 @@ import { Serie, Options } from '@chartwerk/core'; import { AxisRange } from '@chartwerk/core/dist/types'; type LineTimeSerieParams = { - maxLength: number, - renderDots: boolean, - renderLines: boolean, // TODO: refactor same as scatter-pod + maxLength: number; + renderDots: boolean; + renderLines: boolean; // TODO: refactor same as scatter-pod dashArray: string; // dasharray attr, only for lines class: string; // option to add custom class to each serie element - renderArea: boolean; // TODO: move to render type + renderArea: AreaType; // default is none +} + +export enum AreaType { + NONE = 'None', + ABOVE = 'Above', + BELOW = 'Below', } export type LineTimeSerie = Serie & Partial; @@ -16,8 +22,12 @@ export type LineOptions = Options & { zoomOut?: (centers: { x: number; y: number; - }, range: AxisRange[]) => void, - mouseMove?: (evt: MouseObj) => void + }, range: AxisRange[]) => void; + mouseMove?: (evt: MouseObj) => void; + contextMenu?: (position: { + x: number; + y: number; + }) => void; } } diff --git a/yarn.lock b/yarn.lock index fd4c3b3..284f259 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,13 +5,13 @@ __metadata: version: 6 cacheKey: 8 -"@chartwerk/core@npm:^0.6.23": - version: 0.6.23 - resolution: "@chartwerk/core@npm:0.6.23" +"@chartwerk/core@npm:^0.6.26": + version: 0.6.26 + resolution: "@chartwerk/core@npm:0.6.26" dependencies: d3: ^5.16.0 lodash: ^4.17.21 - checksum: 629b0438e8cea02914e12956069d318caa98e6b3e2dd2514aab267474fa87e0aa92c190c4ca0fe95ca8091f83be1e1897801f5632c3f11d9cb3be39fa89cca84 + checksum: d77ef83701dc13cf2b7fb36dc96448060b6301928bcc0730a7150930f83c51f295e176bcda4e1b8cb8f56d15fef5696edfe6f4e1033adbb5ef5d3487a02c3390 languageName: node linkType: hard @@ -19,7 +19,7 @@ __metadata: version: 0.0.0-use.local resolution: "@chartwerk/line-pod@workspace:." dependencies: - "@chartwerk/core": ^0.6.23 + "@chartwerk/core": ^0.6.26 copy-webpack-plugin: ^11.0.0 css-loader: ^6.8.1 style-loader: ^3.3.3