diff --git a/src/index.ts b/src/index.ts index 97917cf..6f2729b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,520 +9,76 @@ const CROSSHAIR_CIRCLE_RADIUS = 3; const CROSSHAIR_BACKGROUND_RAIDUS = 9; const CROSSHAIR_BACKGROUND_OPACITY = 0.3; -export class LinePod extends ChartwerkPod { - lineGenerator = null; - areaGenerator = null; +export class LinePod { + protected d3Node?: d3.Selection; + protected svg?: d3.Selection; + protected chartContainer: d3.Selection; + protected customOverlay?: d3.Selection; - constructor(_el: HTMLElement, _series: LineTimeSerie[] = [], _options: LineOptions = {}) { - super(_el, _series, _options); + constructor( + protected readonly el: HTMLElement, + protected series: any[] = [], + protected options: any + ){ + this.d3Node = d3.select(this.el); + this.createSvg(); + this.renderCustomOverlay(); + this.bindEvents(); } - renderMetrics(): void { - this.clearAllMetrics(); - - this.updateCrosshair(); - this.initLineGenerator(); - this.initAreaGenerator(); - - // TODO: seems that renderMetrics is not correct name - if(this.series.length === 0) { - this.renderNoDataPointsMessage(); - return; - } - - for(let idx = 0; idx < this.series.length; ++idx) { - if(this.series[idx].visible === false) { - continue; - } - // TODO: use _.defaults same as in core - const confidence = this.series[idx].confidence || 0; - const mode = this.series[idx].mode || Mode.STANDARD; - const target = this.series[idx].target; - const renderDots = this.series[idx].renderDots !== undefined ? this.series[idx].renderDots : false; - const renderLines = this.series[idx].renderLines !== undefined ? this.series[idx].renderLines : true; - - this._renderMetric( - this.series[idx].datapoints, - { - color: this.getSerieColor(idx), - confidence, - target, - mode, - serieIdx: idx, - renderDots, - renderLines, - } - ); - } + 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(${0},${0})`); } - clearAllMetrics(): void { - // TODO: temporary hack before it will be implemented in core. - this.chartContainer.selectAll('.metric-el').remove(); + renderCustomOverlay(): void { + 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('draggable', 'true') + .attr('fill', 'lightgray'); } - initLineGenerator(): void { - this.lineGenerator = d3.line() - .x(d => this.xScale(d[0])) - .y(d => this.yScale(d[1])); + bindEvents(): void { + this.customOverlay.on('mousedown', this.mouseDown); + this.customOverlay.on('mousemove', this.mouseMove); + this.customOverlay.on('mouseup', this.mouseUp); } - initAreaGenerator(): void { - this.areaGenerator = d3.area() - .x(d => this.xScale(d[0])) - .y1(d => this.yScale(d[1])) - .y0(d => this.height); + mouseMove(event, i, j) { + console.log('move', d3.event); } - getRenderGenerator(serieIdx: number): any { - const renderArea = this.series[serieIdx].renderArea; - if(renderArea) { - return this.areaGenerator; - } - return this.lineGenerator; + mouseDown(event, i, j) { + console.log('mouseDown', d3.event); } - public appendData(data: [number, number][], shouldRerender = true): void { - for(let idx = 0; idx < this.series.length; ++idx) { - if(this.series[idx].visible === false) { - continue; - } - this.series[idx].datapoints.push(data[idx]); - const maxLength = this.series[idx].maxLength; - if(maxLength !== undefined && this.series[idx].datapoints.length > maxLength) { - this.series[idx].datapoints.shift(); - } - } - - for(let idx = 0; idx < this.series.length; ++idx) { - this.metricContainer.select(`.metric-path-${idx}`) - .datum(this.series[idx].datapoints) - .attr('d', this.getRenderGenerator(idx)); - - if(this.series[idx].renderDots === true) { - this.metricContainer.selectAll(`.metric-circle-${idx}`) - .data(this.series[idx].datapoints) - .attr('cx', d => this.xScale(d[0])) - .attr('cy', d => this.yScale(d[1])); - - this._renderDots([data[idx]], idx); - } - } - if(shouldRerender) { - const rightBorder = _.last(data)[0]; - this.state.xValueRange = [this.state.getMinValueX(), rightBorder]; - this.renderXAxis(); - this.renderYAxis(); - this.renderGrid(); - } + mouseUp(event, i, j) { + console.log('mouseUp', d3.event); } - _renderDots(datapoints: number[][], serieIdx: number): void { - const customClass = this.series[serieIdx].class || ''; - - this.metricContainer.selectAll(null) - .data(datapoints) - .enter() - .append('circle') - .attr('class', `metric-circle-${serieIdx} metric-el ${customClass}`) - .attr('fill', this.getSerieColor(serieIdx)) - .attr('r', METRIC_CIRCLE_RADIUS) - .style('pointer-events', 'none') - .attr('cx', d => this.xScale(d[0])) - .attr('cy', d => this.yScale(d[1])); + wheel() { + console.log('wheel', d3.event); } - _renderLines(datapoints: number[][], serieIdx: number): void { - const dashArray = this.series[serieIdx].dashArray !== undefined ? this.series[serieIdx].dashArray : '0'; - const customClass = this.series[serieIdx].class || ''; - const fillColor = this.series[serieIdx].renderArea ? this.getSerieColor(serieIdx) : 'none'; - const fillOpacity = this.series[serieIdx].renderArea ? 0.5 : 'none'; - - this.metricContainer - .append('path') - .datum(datapoints) - .attr('class', `metric-path-${serieIdx} metric-el ${customClass}`) - .attr('fill', fillColor) - .attr('fill-opacity', fillOpacity) - .attr('stroke', this.getSerieColor(serieIdx)) - .attr('stroke-width', 1) - .attr('stroke-opacity', 0.7) - .attr('pointer-events', 'none') - .style('stroke-dasharray', dashArray) - .attr('d', this.getRenderGenerator(serieIdx)); + get width(): number { + return this.d3Node.node().clientWidth; } - _renderMetric( - datapoints: number[][], - metricOptions: { - color: string, - confidence: number, - target: string, - mode: Mode, - serieIdx: number, - renderDots: boolean, - renderLines: boolean, - } - ): void { - if(_.includes(this.seriesTargetsWithBounds, metricOptions.target)) { - return; - } - - if(metricOptions.mode === Mode.CHARGE) { - const dataPairs = d3.pairs(datapoints); - this.metricContainer.selectAll(null) - .data(dataPairs) - .enter() - .append('line') - .attr('x1', d => this.xScale(d[0][0])) - .attr('x2', d => this.xScale(d[1][0])) - .attr('y1', d => this.yScale(d[0][1])) - .attr('y2', d => this.yScale(d[1][1])) - .attr('stroke-opacity', 0.7) - .style('stroke-width', 1) - .style('stroke', d => { - if(d[1][0] > d[0][0]) { - return 'green'; - } else if (d[1][0] < d[0][0]) { - return 'red'; - } else { - return 'gray'; - } - }); - return; - } - - if(metricOptions.renderLines === true) { - this._renderLines(datapoints, metricOptions.serieIdx); - } - - if(metricOptions.renderDots === true) { - this._renderDots(datapoints, metricOptions.serieIdx); - } - } - - updateCrosshair(): void { - this.crosshair.selectAll('circle').remove(); - // Core doesn't know anything about crosshair circles, It is only for line pod - this.appendCrosshairCircles(); - } - - appendCrosshairCircles(): void { - // circle for each serie - this.series.forEach((serie: LineTimeSerie, serieIdx: number) => { - this.appendCrosshairCircle(serieIdx); - }); - } - - appendCrosshairCircle(serieIdx: number): void { - // TODO: cx, cy - hacks to hide circles after zoom out(can be replaced with display: none) - this.crosshair.append('circle') - .attr('class', `crosshair-circle-${serieIdx} crosshair-background`) - .attr('r', CROSSHAIR_BACKGROUND_RAIDUS) - .attr('cx', -CROSSHAIR_BACKGROUND_RAIDUS) - .attr('cy', -CROSSHAIR_BACKGROUND_RAIDUS) - .attr('clip-path', `url(#${this.rectClipId})`) - .attr('fill', this.getSerieColor(serieIdx)) - .style('opacity', CROSSHAIR_BACKGROUND_OPACITY) - .style('pointer-events', 'none'); - - this.crosshair - .append('circle') - .attr('cx', -CROSSHAIR_CIRCLE_RADIUS) - .attr('cy', -CROSSHAIR_CIRCLE_RADIUS) - .attr('class', `crosshair-circle-${serieIdx}`) - .attr('clip-path', `url(#${this.rectClipId})`) - .attr('fill', this.getSerieColor(serieIdx)) - .attr('r', CROSSHAIR_CIRCLE_RADIUS) - .style('pointer-events', 'none'); - } - - public renderSharedCrosshair(values: { x?: number, y?: number }): void { - this.onMouseOver(); // TODO: refactor to use it once - const eventX = this.xScale(values.x); - const eventY = this.yScale(values.y); - this.moveCrosshairLine(eventX, eventY); - const datapoints = this.findAndHighlightDatapoints(values.x, values.y); - - if(this.options.eventsCallbacks === undefined || this.options.eventsCallbacks.sharedCrosshairMove === undefined) { - return; - } - - this.options.eventsCallbacks.sharedCrosshairMove({ - datapoints: datapoints, - eventX, eventY - }); - } - - public hideSharedCrosshair(): void { - this.crosshair.style('display', 'none'); - } - - moveCrosshairLine(xPosition: number, yPosition: number): void { - switch(this.options.crosshair.orientation) { - case CrosshairOrientation.VERTICAL: - this.crosshair.select('#crosshair-line-x') - .attr('x1', xPosition) - .attr('x2', xPosition); - return; - case CrosshairOrientation.HORIZONTAL: - this.crosshair.select('#crosshair-line-y') - .attr('y1', yPosition) - .attr('y2', yPosition); - return; - case CrosshairOrientation.BOTH: - this.crosshair.select('#crosshair-line-x') - .attr('x1', xPosition) - .attr('x2', xPosition); - this.crosshair.select('#crosshair-line-y') - .attr('y1', yPosition) - .attr('y2', yPosition); - return; - default: - throw new Error(`Unknown type of crosshair orientaion: ${this.options.crosshair.orientation}`); - } - } - - moveCrosshairCircle(xPosition: number, yPosition: number, serieIdx: number): void { - this.crosshair.selectAll(`.crosshair-circle-${serieIdx}`) - .attr('cx', xPosition) - .attr('cy', yPosition) - .style('display', null); - } - - hideCrosshairCircle(serieIdx: number): void { - // hide circle for singe serie - this.crosshair.selectAll(`.crosshair-circle-${serieIdx}`) - .style('display', 'none'); - } - - getClosestDatapoint(serie: LineTimeSerie, xValue: number, yValue: number): [number, number] { - // get closest datapoint to the "xValue"/"yValue" in the "serie" - const datapoints = serie.datapoints; - const closestIdx = this.getClosestIndex(datapoints, xValue, yValue); - const datapoint = serie.datapoints[closestIdx]; - return datapoint; - } - - getClosestIndex(datapoints: [number, number][], xValue: number, yValue: number): number { - let columnIdx; // 0 for x value, 1 for y value, - let value; // xValue to y Value - switch(this.options.crosshair.orientation) { - case CrosshairOrientation.VERTICAL: - columnIdx = 0; - value = xValue; - break; - case CrosshairOrientation.HORIZONTAL: - columnIdx = 1; - value = yValue; - break; - case CrosshairOrientation.BOTH: - // TODO: maybe use voronoi - columnIdx = 1; - value = yValue; - default: - throw new Error(`Unknown type of crosshair orientaion: ${this.options.crosshair.orientation}`); - } - - // TODO: d3.bisect is not the best way. Use binary search - const bisectIndex = d3.bisector((d: [number, number]) => d[columnIdx]).left; - let closestIdx = bisectIndex(datapoints, value); - - // TODO: refactor corner cases - if(closestIdx < 0) { - return 0; - } - if(closestIdx >= datapoints.length) { - return datapoints.length - 1; - } - // TODO: do we realy need it? Binary search should fix it - if( - closestIdx > 0 && - Math.abs(value - datapoints[closestIdx - 1][columnIdx]) < - Math.abs(value - datapoints[closestIdx][columnIdx]) - ) { - closestIdx -= 1; - } - return closestIdx; - } - - getValueInterval(columnIdx: number): number | undefined { - // columnIdx: 1 for y, 0 for x - // inverval: x/y value interval between data points - // TODO: move it to base/state instead of timeInterval - const intervals = _.map(this.series, serie => { - if(serie.datapoints.length < 2) { - return undefined; - } - const start = _.head(serie.datapoints)[columnIdx]; - const end = _.last(serie.datapoints)[columnIdx]; - const range = Math.abs(end - start); - const interval = range / (serie.datapoints.length - 1); - return interval; - }); - return _.max(intervals); - } - - onMouseMove(): void { - const eventX = d3.mouse(this.chartContainer.node())[0]; - const eventY = d3.mouse(this.chartContainer.node())[1]; - const xValue = this.xScale.invert(eventX); // mouse x position in xScale - const yValue = this.yScale.invert(eventY); - // TODO: isOutOfChart is a hack, use clip path correctly - if(this.isOutOfChart() === true) { - this.crosshair.style('display', 'none'); - return; - } - this.moveCrosshairLine(eventX, eventY); - - const datapoints = this.findAndHighlightDatapoints(xValue, yValue); - - if(this.options.eventsCallbacks === undefined || this.options.eventsCallbacks.mouseMove === undefined) { - return; - } - // TDOO: is shift key pressed - // TODO: need to refactor this object - this.options.eventsCallbacks.mouseMove({ - x: d3.event.pageX, - y: d3.event.pageY, - xVal: xValue, - yVal: yValue, - series: datapoints, - chartX: eventX, - chartWidth: this.width - }); - } - - findAndHighlightDatapoints(xValue: number, yValue: number): { value: [number, number], color: string, label: string }[] { - if(this.series === undefined || this.series.length === 0) { - return []; - } - let points = []; // datapoints in each metric that is closest to xValue/yValue position - this.series.forEach((serie: LineTimeSerie, serieIdx: number) => { - if( - serie.visible === false || - _.includes(this.seriesTargetsWithBounds, serie.target) - ) { - this.hideCrosshairCircle(serieIdx); - return; - } - const closestDatapoint = this.getClosestDatapoint(serie, xValue, yValue); - if(closestDatapoint === undefined || this.isOutOfRange(closestDatapoint, xValue, yValue, serie.useOutOfRange)) { - this.hideCrosshairCircle(serieIdx); - return; - } - - const xPosition = this.xScale(closestDatapoint[0]); - const yPosition = this.yScale(closestDatapoint[1]); - this.moveCrosshairCircle(xPosition, yPosition, serieIdx); - - points.push({ - value: closestDatapoint, - color: this.getSerieColor(serieIdx), - label: serie.alias || serie.target - }); - }); - return points; - } - - isOutOfRange(closestDatapoint: [number, number], xValue: number, yValue: number, useOutOfRange = true): boolean { - // find is mouse position more than xRange/yRange from closest point - // TODO: refactor getValueInterval to remove this! - if(useOutOfRange === false) { - return false; - } - let columnIdx; // 1 for y value, 0 for x value - let value; // xValue ot y Value - switch(this.options.crosshair.orientation) { - case CrosshairOrientation.VERTICAL: - columnIdx = 0; - value = xValue; - break; - case CrosshairOrientation.HORIZONTAL: - columnIdx = 1; - value = yValue; - break; - case CrosshairOrientation.BOTH: - // TODO: maybe use voronoi - columnIdx = 1; - value = yValue; - default: - throw new Error(`Unknown type of crosshair orientaion: ${this.options.crosshair.orientation}`); - } - const range = Math.abs(closestDatapoint[columnIdx] - value); - const interval = this.getValueInterval(columnIdx); // interval between points - // do not move crosshair circles, it mouse to far from closest point - return interval === undefined || range > interval / 2; - } - - onMouseOver(): void { - this.crosshair.style('display', null); - this.crosshair.selectAll('.crosshair-circle') - .style('display', null); - } - - onMouseOut(): void { - if(this.options.eventsCallbacks !== undefined && this.options.eventsCallbacks.mouseOut !== undefined) { - this.options.eventsCallbacks.mouseOut(); - } - this.crosshair.style('display', 'none'); - } - - isDoubleClickActive(): boolean { - if(this.options.zoomEvents.mouse.doubleClick === undefined) { - return false; - } - return this.options.zoomEvents.mouse.doubleClick.isActive; - } - - // methods below rewrite cores, (move more methods here) - protected zoomOut(): void { - // TODO: test to remove, seems its depricated - if(this.isOutOfChart() === true) { - return; - } - if(d3.event.type === 'dblclick' && !this.isDoubleClickActive()) { - return; - } - // TODO: its not clear, why we use this orientation here. Mb its better to use separate option - const orientation: BrushOrientation = this.options.zoomEvents.mouse.zoom.orientation; - const xInterval = this.state.xValueRange[1] - this.state.xValueRange[0]; - const yInterval = this.state.yValueRange[1] - this.state.yValueRange[0]; - switch(orientation) { - case BrushOrientation.HORIZONTAL: - this.state.xValueRange = [this.state.xValueRange[0] - xInterval / 2, this.state.xValueRange[1] + xInterval / 2]; - break; - case BrushOrientation.VERTICAL: - this.state.yValueRange = [this.state.yValueRange[0] - yInterval / 2, this.state.yValueRange[1] + yInterval / 2]; - break; - case BrushOrientation.RECTANGLE: - this.state.xValueRange = [this.state.xValueRange[0] - xInterval / 2, this.state.xValueRange[1] + xInterval / 2]; - this.state.yValueRange = [this.state.yValueRange[0] - yInterval / 2, this.state.yValueRange[1] + yInterval / 2]; - break; - case BrushOrientation.SQUARE: - this.state.xValueRange = [this.state.xValueRange[0] - xInterval / 2, this.state.xValueRange[1] + xInterval / 2]; - this.state.yValueRange = [this.state.yValueRange[0] - yInterval / 2, this.state.yValueRange[1] + yInterval / 2]; - break; - default: - throw new Error(`Unknown type of orientation: ${orientation}, path: options.zoomEvents.mouse.zoom.orientation`);; - } - - // TODO: it shouldn't be here. Add smth like vue watchers in core components. Optimize! - this.renderMetrics(); - this.renderXAxis(); - this.renderYAxis(); - this.renderGrid(); - this.onMouseOver(); - - if(this.options.eventsCallbacks !== undefined && this.options.eventsCallbacks.zoomOut !== undefined) { - let xAxisMiddleValue: number = this.xScale.invert(this.width / 2); - let yAxisMiddleValue: number = this.yScale.invert(this.height / 2); - const centers = { - x: xAxisMiddleValue, - y: yAxisMiddleValue - } - this.options.eventsCallbacks.zoomOut(centers); - } + get height(): number { + return this.d3Node.node().clientHeight; } }