Browse Source

my panning

some-tests
vargburz 3 years ago
parent
commit
9aa94a104c
  1. 546
      src/index.ts

546
src/index.ts

@ -9,520 +9,76 @@ const CROSSHAIR_CIRCLE_RADIUS = 3;
const CROSSHAIR_BACKGROUND_RAIDUS = 9; const CROSSHAIR_BACKGROUND_RAIDUS = 9;
const CROSSHAIR_BACKGROUND_OPACITY = 0.3; const CROSSHAIR_BACKGROUND_OPACITY = 0.3;
export class LinePod extends ChartwerkPod<LineTimeSerie, LineOptions> { export class LinePod {
lineGenerator = null; protected d3Node?: d3.Selection<HTMLElement, unknown, null, undefined>;
areaGenerator = null; protected svg?: d3.Selection<SVGElement, unknown, null, undefined>;
protected chartContainer: d3.Selection<SVGGElement, unknown, null, undefined>;
protected customOverlay?: d3.Selection<SVGRectElement, unknown, null, undefined>;
constructor(_el: HTMLElement, _series: LineTimeSerie[] = [], _options: LineOptions = {}) { constructor(
super(_el, _series, _options); protected readonly el: HTMLElement,
protected series: any[] = [],
protected options: any
){
this.d3Node = d3.select(this.el);
this.createSvg();
this.renderCustomOverlay();
this.bindEvents();
} }
renderMetrics(): void { protected createSvg(): void {
this.clearAllMetrics(); this.d3Node.select('svg').remove();
this.svg = this.d3Node
this.updateCrosshair(); .append('svg')
this.initLineGenerator(); .style('width', '100%')
this.initAreaGenerator(); .style('height', '100%')
.style('backface-visibility', 'hidden');
// TODO: seems that renderMetrics is not correct name this.chartContainer = this.svg
if(this.series.length === 0) { .append('g')
this.renderNoDataPointsMessage(); .attr('transform', `translate(${0},${0})`);
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,
}
);
}
}
clearAllMetrics(): void {
// TODO: temporary hack before it will be implemented in core.
this.chartContainer.selectAll('.metric-el').remove();
}
initLineGenerator(): void {
this.lineGenerator = d3.line()
.x(d => this.xScale(d[0]))
.y(d => this.yScale(d[1]));
}
initAreaGenerator(): void {
this.areaGenerator = d3.area()
.x(d => this.xScale(d[0]))
.y1(d => this.yScale(d[1]))
.y0(d => this.height);
}
getRenderGenerator(serieIdx: number): any {
const renderArea = this.series[serieIdx].renderArea;
if(renderArea) {
return this.areaGenerator;
}
return this.lineGenerator;
}
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();
}
}
_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]));
}
_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));
}
_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 { renderCustomOverlay(): void {
// circle for each serie this.customOverlay = this.chartContainer.append('rect')
this.series.forEach((serie: LineTimeSerie, serieIdx: number) => { .attr('class', 'custom-overlay')
this.appendCrosshairCircle(serieIdx); .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');
} }
appendCrosshairCircle(serieIdx: number): void { bindEvents(): void {
// TODO: cx, cy - hacks to hide circles after zoom out(can be replaced with display: none) this.customOverlay.on('mousedown', this.mouseDown);
this.crosshair.append('circle') this.customOverlay.on('mousemove', this.mouseMove);
.attr('class', `crosshair-circle-${serieIdx} crosshair-background`) this.customOverlay.on('mouseup', this.mouseUp);
.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 { mouseMove(event, i, j) {
// columnIdx: 1 for y, 0 for x console.log('move', d3.event);
// 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 { mouseDown(event, i, j) {
const eventX = d3.mouse(this.chartContainer.node())[0]; console.log('mouseDown', d3.event);
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) { mouseUp(event, i, j) {
return; console.log('mouseUp', d3.event);
}
// 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 }[] { wheel() {
if(this.series === undefined || this.series.length === 0) { console.log('wheel', d3.event);
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({ get width(): number {
value: closestDatapoint, return this.d3Node.node().clientWidth;
color: this.getSerieColor(serieIdx),
label: serie.alias || serie.target
});
});
return points;
} }
isOutOfRange(closestDatapoint: [number, number], xValue: number, yValue: number, useOutOfRange = true): boolean { get height(): number {
// find is mouse position more than xRange/yRange from closest point return this.d3Node.node().clientHeight;
// 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);
}
} }
} }

Loading…
Cancel
Save