Skip to content

Commit

Permalink
Display the tooltip for xy
Browse files Browse the repository at this point in the history
The tooltip offered by ChartJS can be extensively configured, but it
still fails in some edge cases when used in the XY chart.

For instance, it is drawn in the canvas that holds the chart, so it
is cut off when the mouse approaches its borders. Also, when selecting
too many processes, the performance of the app degrades to the point of
making the page unusable.

This commit implements a custom tooltip that does not have the problem
of being cut off at the borders, and performs better under heavy loads.

Main characteristics of the tooltip:

- It summarizes all processes that have value equal to zero in a single line;
- It allows the user to move the mouse into it, to scroll and to select text;
- It is aware of the proximity to the borders of the screen, optimizing its
position to avoid clipping.

Signed-off-by: Ibrahim Fradj <ibrahim.fradj@ericsson.com>
Signed-off-by: Rodrigo Pinto <rodrigo.pinto@calian.ca>
  • Loading branch information
IbrahimFradj authored and Rodrigoplp-work committed Nov 26, 2021
1 parent 78678f9 commit 23310ee
Show file tree
Hide file tree
Showing 6 changed files with 239 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { TimeRange } from 'traceviewer-base/lib/utils/time-range';
import { OutputComponentStyle } from './utils/output-component-style';
import { OutputStyleModel } from 'tsp-typescript-client/lib/models/styles';
import { TooltipComponent } from './tooltip-component';
import { TooltipXYComponent } from './tooltip-xy-component';

export interface AbstractOutputProps {
tspClient: TspClient;
tooltipComponent: TooltipComponent | null;
tooltipXYComponent: TooltipXYComponent | null;
traceId: string;
range: TimeRange;
nbEvents: number;
Expand Down
130 changes: 130 additions & 0 deletions packages/react-components/src/components/tooltip-xy-component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import * as React from 'react';

type MaybePromise<T> = T | Promise<T>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface TooltipXYComponentState<T = any> {
tooltipData?: TooltipData,
onDisplay: boolean,
inTooltip: boolean
}

interface TooltipData {
title?: string,
dataPoints?: [];
top?: number,
bottom?: number,
right?: number,
left?: number,
opacity?: number,
transition?: string,
zeros?: number
}

export class TooltipXYComponent extends React.Component<unknown, TooltipXYComponentState> {
private divRef: React.RefObject<HTMLInputElement>;
private readonly horizontalSpace: number = 35; // space to add between mouse cursor and tooltip

timerId?: NodeJS.Timeout;

constructor(props: unknown) {
super(props);
this.state = {
tooltipData: undefined,
onDisplay: false,
inTooltip: false,
};
this.divRef = React.createRef();
}

render(): React.ReactNode {
const leftPos = this.state.tooltipData?.left ? this.state.tooltipData.left + this.horizontalSpace : undefined;
const rightPos = this.state.tooltipData?.right ? this.state.tooltipData.right + this.horizontalSpace : undefined;
let zeros = 0;
let allZeros = false;
if (this.state.tooltipData?.zeros && this.state.tooltipData.zeros > 0) {
zeros = this.state.tooltipData?.zeros;
}
if (this.state.tooltipData?.dataPoints?.length === 0) {
allZeros = true;
}

return (
<div
ref={this.divRef}
onMouseEnter={() => {
this.setState({ inTooltip: true });
if (this.timerId) {
clearTimeout(this.timerId);
this.timerId = undefined;
}
}}
onMouseLeave={() => {
this.setState({ tooltipData: { opacity: 0, transition: '0s' }, inTooltip: false });
}}
style={{
padding: 10,
position: 'absolute',
textAlign: 'left',
maxHeight: '250px',
maxWidth: '400px',
whiteSpace: 'nowrap',
overflow: 'auto',
color: 'white',
border: '1px solid transparent',
backgroundColor: 'rgba(51, 122, 183, 0.9)',
borderRadius: 3,
top: this.state.tooltipData?.top,
bottom: this.state.tooltipData?.bottom,
right: rightPos,
left: leftPos,
opacity: this.state.tooltipData?.opacity ? this.state.tooltipData.opacity : 0,
transition: this.state.tooltipData?.transition ? 'opacity ' + this.state.tooltipData.transition : 'opacity 0.3s',
zIndex: 999
}}
>
<p style={{margin: '0 0 5px 0'}}>{this.state.tooltipData?.title}</p>
<ul style={{padding: '0'}}>
{this.state.tooltipData?.dataPoints?.map((point: { color: string, background: string, label: string, value: string }, i: number) =>
<li key={i} style={{listStyle: 'none', display: 'flex', marginBottom: 5}}>
<div style={{
height: '10px',
width: '10px',
margin: 'auto 0',
border: 'solid thin',
borderColor: point.color,
backgroundColor: point.background
}}
></div>
<span style={{marginLeft: '5px'}}>{point.label} {point.value}</span>
</li>
)}
</ul>
{allZeros ?
<p style={{marginBottom: 0}}>All values: 0</p>
:
zeros > 0 &&
<p style={{marginBottom: 0}}>{this.state.tooltipData?.zeros} other{zeros > 1 ? 's' : ''}: 0</p>
}
</div>
);
}

setElement<TooltipData>(tooltipData: TooltipData): void {
if (this.timerId) {
clearTimeout(this.timerId);
}
this.timerId = setTimeout(() => {
this.setState({ tooltipData, onDisplay: true });
}, 500);

if (this.state.onDisplay) {
this.setState({ onDisplay: false });
setTimeout(() => {
if (!this.state.inTooltip) {
this.setState({tooltipData: { opacity: 0, transition: '0s' }, inTooltip: false });
}
}, 500);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import * as Messages from 'traceviewer-base/lib/message-manager';
import { signalManager, Signals } from 'traceviewer-base/lib/signals/signal-manager';
import ReactTooltip from 'react-tooltip';
import { TooltipComponent } from './tooltip-component';
import { TooltipXYComponent } from './tooltip-xy-component';
import { BIMath } from 'timeline-chart/lib/bigint-utils';

const ResponsiveGridLayout = WidthProvider(Responsive);
Expand Down Expand Up @@ -62,11 +63,13 @@ export class TraceContextComponent extends React.Component<TraceContextProps, Tr
private readonly DEFAULT_CHART_WIDTH: number = Math.floor(this.DEFAULT_COMPONENT_WIDTH * this.COMPONENT_WIDTH_PROPORTION);
private readonly DEFAULT_COMPONENT_HEIGHT: number = 10;
private readonly DEFAULT_COMPONENT_ROWHEIGHT: number = 20;
private readonly DEFAULT_COMPONENT_LEFT: number = 0;
private readonly SCROLLBAR_PADDING: number = 12;
private readonly Y_AXIS_WIDTH: number = 40;

private unitController: TimeGraphUnitController;
private tooltipComponent: React.RefObject<TooltipComponent>;
private tooltipXYComponent: React.RefObject<TooltipXYComponent>;
private traceContextContainer: React.RefObject<HTMLDivElement>;

protected widgetResizeHandlers: (() => void)[] = [];
Expand Down Expand Up @@ -105,6 +108,7 @@ export class TraceContextComponent extends React.Component<TraceContextProps, Tr
width: this.DEFAULT_COMPONENT_WIDTH, // 1245,
chartWidth: this.DEFAULT_CHART_WIDTH,
yAxisWidth: this.Y_AXIS_WIDTH,
componentLeft: this.DEFAULT_COMPONENT_LEFT,
height: this.DEFAULT_COMPONENT_HEIGHT,
rowHeight: this.DEFAULT_COMPONENT_ROWHEIGHT,
naviBackgroundColor: this.props.backgroundTheme === 'light' ? 0xf4f7fb : 0x3f3f3f,
Expand All @@ -129,6 +133,7 @@ export class TraceContextComponent extends React.Component<TraceContextProps, Tr
this.unitController.onSelectionRangeChange(range => { this.handleTimeSelectionChange(range); });
this.unitController.onViewRangeChanged(viewRangeParam => { this.handleViewRangeChange(viewRangeParam); });
this.tooltipComponent = React.createRef();
this.tooltipXYComponent = React.createRef();
this.traceContextContainer = React.createRef();
this.onResize = this.onResize.bind(this);
this.props.addResizeHandler(this.onResize);
Expand Down Expand Up @@ -252,7 +257,8 @@ export class TraceContextComponent extends React.Component<TraceContextProps, Tr

private onResize() {
const newWidth = this.traceContextContainer.current ? this.traceContextContainer.current.clientWidth - this.SCROLLBAR_PADDING : this.DEFAULT_COMPONENT_WIDTH;
this.setState(prevState => ({ style: { ...prevState.style, width: newWidth, chartWidth: this.getChartWidth(newWidth) } }));
const bounds = this.traceContextContainer.current ? this.traceContextContainer.current.getBoundingClientRect() : { left: this.DEFAULT_COMPONENT_LEFT };
this.setState(prevState => ({ style: { ...prevState.style, width: newWidth, chartWidth: this.getChartWidth(newWidth), componentLeft: bounds.left } }));
this.widgetResizeHandlers.forEach(h => h());
}

Expand Down Expand Up @@ -286,6 +292,7 @@ export class TraceContextComponent extends React.Component<TraceContextProps, Tr
onKeyDown={event => this.onKeyDown(event)}
ref={this.traceContextContainer}>
<TooltipComponent ref={this.tooltipComponent} />
<TooltipXYComponent ref={this.tooltipXYComponent} />
{this.props.outputs.length ? this.renderOutputs() : this.renderPlaceHolder()}
</div>;
}
Expand Down Expand Up @@ -327,6 +334,7 @@ export class TraceContextComponent extends React.Component<TraceContextProps, Tr
const outputProps: AbstractOutputProps = {
tspClient: this.props.tspClient,
tooltipComponent: this.tooltipComponent.current,
tooltipXYComponent: this.tooltipXYComponent.current,
traceId: this.state.experiment.UUID,
outputDescriptor: output,
markerCategories: this.props.markerCategoriesMap.get(output.id),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe('Time axis component', () => {
width: 50,
chartWidth: 60,
yAxisWidth: 30,
componentLeft: 0,
height: 100,
rowHeight: 100,
naviBackgroundColor: 0xf4f7fb,
Expand All @@ -32,6 +33,7 @@ describe('Time axis component', () => {
width: 50,
chartWidth: 60,
yAxisWidth: 30,
componentLeft: 0,
height: 100,
rowHeight: 100,
naviBackgroundColor: 0xf4f7fb,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface OutputComponentStyle {
width: number;
chartWidth: number;
yAxisWidth: number;
componentLeft: number,
// react-grid-layout - The library used for resizing components
// inserts new React components during compilation, and the dimensions
// it returns are strings (pixels).
Expand Down
98 changes: 95 additions & 3 deletions packages/react-components/src/components/xy-output-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export class XYOutputComponent extends AbstractTreeOutputComponent<AbstractOutpu
private mouseIsDown = false;
private positionXMove = 0;
private isRightClick = false;
private isMouseMove = false;
private posPixelSelect = 0;
private isMouseLeave = false;
private startPositionMouseRightClick = BigInt(0);
Expand Down Expand Up @@ -201,6 +202,11 @@ export class XYOutputComponent extends AbstractTreeOutputComponent<AbstractOutpu
},
maintainAspectRatio: false,
legend: { display: false },
tooltips: {
intersect: false,
mode: 'index',
enabled: false,
},
layout: {
padding: {
left: 0,
Expand All @@ -214,7 +220,7 @@ export class XYOutputComponent extends AbstractTreeOutputComponent<AbstractOutpu
yAxes: [{ display: false }]
},
animation: { duration: 0 },
events: [ 'mousedown' ],
events: [ 'mouseout' ],
};
// width={this.props.style.chartWidth}
if (this.state.outputStatus === ResponseStatus.COMPLETED && this.state.xyTree.length === 0 ) {
Expand All @@ -237,9 +243,9 @@ export class XYOutputComponent extends AbstractTreeOutputComponent<AbstractOutpu
onContextMenu={event => event.preventDefault()}
onMouseLeave={event => this.onMouseLeave(event)}
onMouseDown={event => this.onMouseDown(event)}
style={{ height: this.props.style.height }}
style={{ height: this.props.style.height, position: 'relative' }}
ref={this.chartRef}
>
>
<Line
data={this.state.xyData}
height={parseInt(this.props.style.height.toString())}
Expand Down Expand Up @@ -467,24 +473,108 @@ export class XYOutputComponent extends AbstractTreeOutputComponent<AbstractOutpu
}
}

private tooltip(x: number, y: number): void {
const xPos = this.positionXMove;
const timeForX = this.getTimeForX(xPos);
let timeLabel: string | undefined = timeForX.toString();
if (this.props.unitController.numberTranslator) {
timeLabel = this.props.unitController.numberTranslator(timeForX);
}
const chartWidth = this.lineChartRef.current.chartInstance.width;
const arraySize = this.state.xyData.labels.length;
const index = Math.max(Math.round((xPos / chartWidth) * (arraySize - 1)), 0);
const points: any = [];
let zeros = 0;

this.state.xyData.datasets.forEach((d: any) => {
// In case there are less data points than pixels in the chart,
// calculate nearest value.
const yValue = d.data[index];
const rounded = isNaN(yValue) ? 0 : (Math.round(Number(yValue) * 100) / 100);
// If there are less than 10 lines in the chart, show all values, even if they are equal to 0.
// If there are more than 10 lines in the chart, summarise the ones that are equal to 0.
if (this.state.xyData.datasets.length <= 10 || rounded > 0) {
const point: any = {
label: d.label,
color: d.borderColor,
background: d.backgroundColor,
value: rounded.toString(),
};
points.push(point);
}
else {
zeros += 1;
}
});
// Sort in decreasing order
points.sort((a: any, b: any) => Number(b.value) - Number(a.value));

// Adjust tooltip position if mouse is too close to the bottom of the window
let topPos = undefined;
let bottomPos = undefined;
if (y > window.innerHeight - 350) {
bottomPos = window.innerHeight - y;
}
else {
topPos = window.pageYOffset + y - 40;
}

// Adjust tooltip position if mouse is too close to the left edge of the chart
let leftPos = undefined;
let rightPos = undefined;
const xLocation = chartWidth - xPos;
if (xLocation > chartWidth * 0.8) {
leftPos = x - this.props.style.componentLeft;
}
else {
rightPos = xLocation;
}

const tooltipData = {
title: timeLabel,
dataPoints: points,
top: topPos,
bottom: bottomPos,
right: rightPos,
left: leftPos,
opacity: 1,
zeros
};

this.props.tooltipXYComponent?.setElement(tooltipData);
}

private hideTooltip() {
this.props.tooltipXYComponent?.setElement({
opacity: 0
});
}

private onMouseMove(event: React.MouseEvent) {
this.isMouseMove = true;
this.positionXMove = event.nativeEvent.offsetX;
this.isMouseLeave = false;

if (this.mouseIsDown && !this.isRightClick) {
this.updateSelection();
}
if (this.mouseIsDown && this.isRightClick) {
this.forceUpdate();
}
if (this.state.xyData.labels.length > 0) {
this.tooltip(event.nativeEvent.x, event.nativeEvent.y);
}
}

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

private applySelectionZoom() {
Expand All @@ -495,6 +585,7 @@ export class XYOutputComponent extends AbstractTreeOutputComponent<AbstractOutpu
}

private onKeyDown(key: React.KeyboardEvent) {
this.hideTooltip();
if (!this.isMouseLeave) {
switch (key.key) {
case 'W':
Expand Down Expand Up @@ -560,6 +651,7 @@ export class XYOutputComponent extends AbstractTreeOutputComponent<AbstractOutpu
label: series.seriesName,
fill: false,
borderColor: color,
backgroundColor: color,
borderWidth: 2,
data: series.yValues
});
Expand Down

0 comments on commit 23310ee

Please sign in to comment.