Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement navigation for XY charts #419

Merged
merged 3 commits into from Sep 16, 2021
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
261 changes: 229 additions & 32 deletions packages/react-components/src/components/xy-output-component.tsx
Expand Up @@ -23,21 +23,30 @@ type XYOuputState = AbstractOutputState & {
xyData: any;
columns: ColumnHeader[];
};
const RIGHT_CLICK_NUMBER = 2;
const ZOOM_IN = true;
const ZOOM_OUT = false;
const PAN_LEFT = true;
const PAN_RIGHT = false;

export class XYOutputComponent extends AbstractTreeOutputComponent<AbstractOutputProps, XYOuputState> {
private currentColorIndex = 0;
private colorMap: Map<string, number> = new Map();

private lineChartRef: any;
private chartRef: any;
private mouseIsDown = false;
private positionXMove = 0;
private isRightClick = false;
private posPixelSelect = 0;
private isMouseLeave = false;
private startPositionMouseRightClick = 0;
private plugin = {
afterDraw: (chartInstance: Chart, _easing: Chart.Easing, _options?: any) => { this.afterChartDraw(chartInstance); }
};
private updateSelection = (event: MouseEvent) => {
if (this.mouseIsDown && this.props.unitController.selectionRange) {
const scale = this.props.viewRange.getEnd() - this.props.viewRange.getstart();
if (this.mouseIsDown && this.props.unitController.selectionRange && !this.isRightClick) {
const xStartPos = this.props.unitController.selectionRange.start;
const scale = this.props.viewRange.getEnd() - this.props.viewRange.getstart();
let end = xStartPos + ((event.screenX - this.posPixelSelect) / this.lineChartRef.current.chartInstance.width) * scale;
end = Math.min(Math.max(end, 0), this.props.unitController.absoluteRange);
this.props.unitController.selectionRange = {
Expand All @@ -48,11 +57,17 @@ export class XYOutputComponent extends AbstractTreeOutputComponent<AbstractOutpu
};

private endSelection = () => {
if (this.isRightClick) {
this.applySelectionZoom();
} else {
document.removeEventListener('mousemove', this.updateSelection);
}
this.mouseIsDown = false;
document.removeEventListener('mousemove', this.updateSelection);
document.removeEventListener('mouseup', this.endSelection);
};

private preventDefaultHandler: ((event: WheelEvent) => void) | undefined;

constructor(props: AbstractOutputProps) {
super(props);
this.state = {
Expand All @@ -63,11 +78,12 @@ export class XYOutputComponent extends AbstractTreeOutputComponent<AbstractOutpu
collapsedNodes: [],
orderedNodes: [],
xyData: {},
columns: [{title: 'Name', sortable: true}]
columns: [{title: 'Name', sortable: true}],
};

this.afterChartDraw = this.afterChartDraw.bind(this);
this.lineChartRef = React.createRef();
this.chartRef = React.createRef();
}

componentDidMount(): void {
Expand Down Expand Up @@ -111,6 +127,14 @@ export class XYOutputComponent extends AbstractTreeOutputComponent<AbstractOutpu
this.updateXY();
}
if (this.lineChartRef.current) {
if (this.preventDefaultHandler === undefined) {
this.preventDefaultHandler = (event: WheelEvent) => {
if (event.ctrlKey) {
event.preventDefault();
}
};
this.chartRef.current.addEventListener('wheel', this.preventDefaultHandler);
}
IbrahimFradj marked this conversation as resolved.
Show resolved Hide resolved
this.lineChartRef.current.chartInstance.render();
}
}
Expand Down Expand Up @@ -163,7 +187,16 @@ export class XYOutputComponent extends AbstractTreeOutputComponent<AbstractOutpu
// width={this.props.style.chartWidth}
return <React.Fragment>
{this.state.outputStatus === ResponseStatus.COMPLETED ?
<div id='xy-main' onMouseDown={event => this.beginSelection(event)} style={{ height: this.props.style.height }} >
<div id='xy-main' tabIndex={0}
onKeyDown={event => this.onKeyDown(event)}
onWheel={event => this.onWheel(event)}
onMouseMove={event => this.onMouseMove(event)}
onContextMenu={event => event.preventDefault()}
onMouseLeave={event => this.onMouseLeave(event)}
onMouseDown={event => this.onMouseDown(event)}
style={{ height: this.props.style.height }}
ref={this.chartRef}
>
<Line
data={this.state.xyData}
height={parseInt(this.props.style.height.toString())}
Expand All @@ -190,43 +223,66 @@ export class XYOutputComponent extends AbstractTreeOutputComponent<AbstractOutpu
const ctx = chart.ctx;
const xScale = (chart as any).scales['time-axis'];
const ticks: number[] = xScale.ticks;
if (ctx && this.props.selectionRange) {
const min = Math.min(this.props.selectionRange.getstart(), this.props.selectionRange.getEnd());
const max = Math.max(this.props.selectionRange.getstart(), this.props.selectionRange.getEnd());
// If the selection is out of range
if (min > this.props.viewRange.getEnd() || max < this.props.viewRange.getstart()) {
return;
if (ctx) {
let start = 0;
if (this.props.selectionRange) {
const min = Math.min(this.props.selectionRange.getstart(), this.props.selectionRange.getEnd());
const max = Math.max(this.props.selectionRange.getstart(), this.props.selectionRange.getEnd());
const minValue = this.findNearestValue(min, ticks);
let minPixel = xScale.getPixelForValue(min, minValue);
const maxValue = this.findNearestValue(max, ticks);
let maxPixel = xScale.getPixelForValue(max, maxValue);
// In the case the selection is going out of bounds, the pixelValue needs to be in the displayed range.
if (maxPixel === 0) {
maxPixel = chart.chartArea.right;
}
if (minPixel === 0) {
minPixel = chart.chartArea.right;
}
ctx.strokeStyle = '#259fd8';
ctx.fillStyle = '#259fd8';
this.drawSelection(chart, minPixel, maxPixel);
}
const minValue = this.findNearestValue(min, ticks);
const minPixel = xScale.getPixelForValue(min, minValue);
const maxValue = this.findNearestValue(max, ticks);
let maxPixel = xScale.getPixelForValue(max, maxValue);
// In the case the selection is going out of bounds, the pixelValue needs to be in the displayed range.
if (maxPixel === 0) {
maxPixel = chart.chartArea.right;
if (this.isRightClick) {
const offset = this.props.viewRange.getOffset() ?? 0;
start = this.startPositionMouseRightClick + offset;
const startValue = this.findNearestValue(start, ticks);
let startPixel = xScale.getPixelForValue(start, startValue);
const endPixel = this.positionXMove;
if (startPixel === 0) {
startPixel = chart.chartArea.right;
}
ctx.strokeStyle = '#9f9f9f';
ctx.fillStyle = '#9f9f9f';
this.drawSelection(chart, startPixel, endPixel);
}
}
}

private drawSelection(chart: Chart, startPixel: number, endPixel: number) {
const ctx = chart.ctx;
const minPixel = Math.min(startPixel, endPixel);
const maxPixel = Math.max(startPixel, endPixel);
if (ctx) {
ctx.save();

ctx.lineWidth = 1;
ctx.strokeStyle = '#259fd8';
// Selection borders
if (min > this.props.viewRange.getstart()) {
if (startPixel > chart.chartArea.left) {
ctx.beginPath();
ctx.moveTo(minPixel, 0);
ctx.lineTo(minPixel, chart.chartArea.bottom);
ctx.stroke();
}
if (max < this.props.viewRange.getEnd()) {
if (endPixel < this.props.viewRange.getEnd()) {
ctx.beginPath();
ctx.moveTo(maxPixel, 0);
ctx.lineTo(maxPixel, chart.chartArea.bottom);
ctx.stroke();
}
// Selection fill
ctx.globalAlpha = 0.2;
ctx.fillStyle = '#259fd8';
ctx.fillRect(minPixel, 0, maxPixel - minPixel, chart.chartArea.bottom);

ctx.restore();
}
}
Expand Down Expand Up @@ -275,19 +331,160 @@ export class XYOutputComponent extends AbstractTreeOutputComponent<AbstractOutpu
this.setState({orderedNodes: ids});
}

private beginSelection(event: React.MouseEvent<HTMLDivElement, MouseEvent>) {
private onMouseDown(event: React.MouseEvent<HTMLDivElement, MouseEvent>) {
this.isMouseLeave = false;
this.mouseIsDown = true;
this.posPixelSelect = event.nativeEvent.screenX;
const startTime = this.getTimeX(event.nativeEvent.offsetX);
if (event.button === RIGHT_CLICK_NUMBER) {
this.isRightClick = true;
this.startPositionMouseRightClick = startTime;
} else {
this.isRightClick = false;
this.props.unitController.selectionRange = {
start: startTime,
end: startTime
};
document.addEventListener('mousemove', this.updateSelection);
}
document.addEventListener('mouseup', this.endSelection);
}

private updateRange(rangeStart: number, rangeEnd: number) {
if (rangeEnd < rangeStart) {
const temp = rangeStart;
rangeStart = rangeEnd;
rangeEnd = temp;
}
this.props.unitController.viewRange = {
start: rangeStart,
end: rangeEnd
};
}

private zoom(isZoomIn: boolean) {
if (this.props.unitController.viewRangeLength >= 1) {
let newStartRange = 0;
let newEndRange = 0;
const percentZoom = 0.9;
const position = this.getTimeX(this.positionXMove);
const startDistance = position - this.props.unitController.viewRange.start;
const endDistance = this.props.unitController.viewRange.end - position;
const zoomFactor = isZoomIn ? percentZoom : 1 / percentZoom;
newStartRange = position - startDistance * zoomFactor;
newEndRange = position + endDistance * zoomFactor;
if (newStartRange < 0) {
IbrahimFradj marked this conversation as resolved.
Show resolved Hide resolved
newEndRange = newEndRange - newStartRange;
} else if (newEndRange > this.props.unitController.absoluteRange) {
const delta = newEndRange - this.props.unitController.absoluteRange;
newStartRange = newStartRange - delta;
}
newStartRange = Math.max(newStartRange, 0);
newEndRange = Math.min(newEndRange, this.props.unitController.absoluteRange);
this.updateRange(newStartRange, newEndRange);
IbrahimFradj marked this conversation as resolved.
Show resolved Hide resolved
}
}

private pan(panLeft: boolean) {
IbrahimFradj marked this conversation as resolved.
Show resolved Hide resolved
const panFactor = 0.1;
const percentRange = this.props.unitController.viewRangeLength * panFactor;
const panNumber = panLeft ? -1 : 1;
const startRange = this.props.unitController.viewRange.start + (panNumber * percentRange);
const endRange = this.props.unitController.viewRange.end + (panNumber * percentRange);
if (startRange < 0) {
this.props.unitController.viewRange = {
start: 0,
end: this.props.unitController.viewRangeLength
};
} else if (endRange > this.props.unitController.absoluteRange) {
this.props.unitController.viewRange = {
start: this.props.unitController.absoluteRange - this.props.unitController.viewRangeLength,
end: this.props.unitController.absoluteRange
};
} else {
this.props.unitController.viewRange = {
start: startRange,
end: endRange
};
}
}

private onWheel(wheel: React.WheelEvent) {
this.isMouseLeave = false;
if (wheel.shiftKey) {
IbrahimFradj marked this conversation as resolved.
Show resolved Hide resolved
if (wheel.deltaY < 0) {
this.pan(PAN_LEFT);
}
else if (wheel.deltaY > 0) {
this.pan(PAN_RIGHT);
}
} else if (wheel.ctrlKey) {
if (wheel.deltaY < 0) {
this.zoom(ZOOM_IN);
} else if (wheel.deltaY > 0) {
this.zoom(ZOOM_OUT);
}
}
}

private getTimeX(event: number): number {
const offset = this.props.viewRange.getOffset() ?? 0;
const scale = this.props.viewRange.getEnd() - this.props.viewRange.getstart();
const xPos = this.props.viewRange.getstart() - offset +
(event.nativeEvent.offsetX / this.lineChartRef.current.chartInstance.width) * scale;
this.props.unitController.selectionRange = {
start: xPos,
end: xPos
};
document.addEventListener('mousemove', this.updateSelection);
document.addEventListener('mouseup', this.endSelection);
(event / this.lineChartRef.current.chartInstance.width) * scale;
return xPos;
}

private onMouseMove(event: React.MouseEvent) {
this.positionXMove = event.nativeEvent.offsetX;
this.isMouseLeave = false;
if (this.mouseIsDown && this.isRightClick) {
this.forceUpdate();
}
}

private onMouseLeave(event: React.MouseEvent) {
this.isMouseLeave = true;
if (this.isRightClick) {
this.positionXMove = Math.max(0, Math.min(event.nativeEvent.offsetX, this.lineChartRef.current.chartInstance.width));
this.forceUpdate();
}
}

private applySelectionZoom() {
const newStartRange = this.startPositionMouseRightClick;
const newEndRange = this.getTimeX(this.positionXMove);
this.updateRange(newStartRange, newEndRange);
this.isRightClick = false;
}

private onKeyDown(key: React.KeyboardEvent) {
if (!this.isMouseLeave) {
switch (key.key) {
case 'W':
case 'w': {
this.zoom(ZOOM_IN);
break;
}
case 'S':
case 's': {
this.zoom(ZOOM_OUT);
break;
}
case 'A':
case 'a':
case 'ArrowLeft': {
this.pan(PAN_LEFT);
break;
}
case 'D':
case 'd':
case 'ArrowRight': {
this.pan(PAN_RIGHT);
break;
}
}
}
}

private async updateXY() {
Expand Down