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