Skip to content

Commit 1c5f7f9

Browse files
Add timeline axis with multi-unit support to XY charts
- Add configurable timeline bar below XY charts with D3.js - Support multiple unit types: time, cycles, bytes, calls, data rates - Include automatic unit formatting (bytes/KB/MB/GB, rates) - Add timeline props: timelineUnit and timelineUnitType - Update timeline on chart resize and data changes Signed-off-by: Matthew Khouzam <matthew.khouzam@ericsson.com>
1 parent 07efb5f commit 1c5f7f9

File tree

1 file changed

+145
-23
lines changed

1 file changed

+145
-23
lines changed

local-libs/traceviewer-libs/react-components/src/components/generic-xy-output-component.tsx

Lines changed: 145 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { EntryTree } from './utils/filter-tree/entry-tree';
1919
import { TimeRange } from 'traceviewer-base/src/utils/time-range';
2020
import { BIMath } from 'timeline-chart/lib/bigint-utils';
2121
import { debounce } from 'lodash';
22+
import * as d3 from 'd3';
2223

2324
import {
2425
applyYAxis,
@@ -68,12 +69,18 @@ interface GenericXYState extends AbstractTreeOutputState {
6869
allMax: number;
6970
allMin: number;
7071
cursor: string;
72+
timelineUnit: string;
73+
timelineUnitType: TimelineUnitType;
7174
}
7275

76+
type TimelineUnitType = 'time' | 'cycles' | 'bytes' | 'calls' | 'bytes/sec' | 'iterations/sec';
77+
7378
interface GenericXYProps extends AbstractOutputProps {
7479
formatX?: (x: number | bigint | string) => string;
7580
formatY?: (y: number) => string;
7681
stacked?: boolean;
82+
timelineUnit?: string;
83+
timelineUnitType?: TimelineUnitType;
7784
}
7885

7986
enum ChartMode {
@@ -91,8 +98,10 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
9198
private readonly chartRef = React.createRef<any>();
9299
private readonly yAxisRef: any;
93100
private readonly divRef = React.createRef<HTMLDivElement>();
101+
private readonly timelineRef = React.createRef<SVGSVGElement>();
94102

95103
private readonly margin = { top: 15, right: 0, bottom: 6, left: this.getYAxisWidth() };
104+
private readonly timelineHeight = 30;
96105

97106
private mouseIsDown = false;
98107
private isPanning = false;
@@ -134,7 +143,9 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
134143
allMax: 0,
135144
allMin: 0,
136145
cursor: 'default',
137-
showTree: true
146+
showTree: true,
147+
timelineUnit: this.props.timelineUnit || 'ms',
148+
timelineUnitType: this.props.timelineUnitType || 'time'
138149
};
139150

140151
this.addPinViewOptions(() => ({
@@ -315,6 +326,7 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
315326
})
316327
);
317328
this.calculateYRange();
329+
this.updateTimeline();
318330
return;
319331
}
320332

@@ -326,6 +338,7 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
326338
const xy = this.buildXYData(series, this.mode);
327339
flushSync(() => this.setState({ xyData: xy, outputStatus: model.status ?? ResponseStatus.COMPLETED }));
328340
this.calculateYRange();
341+
this.updateTimeline();
329342
}
330343

331344
private buildXYData(seriesObj: XYSeries[], mode: ChartMode): GenericXYData {
@@ -439,7 +452,10 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
439452
const checksChanged = prevState.checkedSeries !== this.state.checkedSeries;
440453

441454
if (sizeChanged || viewChanged || checksChanged) {
442-
if (this.getChartWidth() > 0) this._debouncedUpdateXY();
455+
if (this.getChartWidth() > 0) {
456+
this._debouncedUpdateXY();
457+
this.updateTimeline();
458+
}
443459
}
444460
}
445461

@@ -710,6 +726,109 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
710726
}
711727
}
712728

729+
private renderTimeline(): React.ReactNode {
730+
if (this.state.timelineUnitType === 'time' && !this.isTimeAxis) return <svg/>;
731+
732+
const chartWidth = this.getChartWidth();
733+
if (chartWidth <= 0) return <svg/>;
734+
735+
return (
736+
<svg
737+
ref={this.timelineRef}
738+
width={chartWidth}
739+
height={this.timelineHeight}
740+
style={{ marginLeft: this.margin.left }}
741+
/>
742+
);
743+
}
744+
745+
private updateTimeline(): void {
746+
if (!this.timelineRef.current) return;
747+
if (this.state.timelineUnitType === 'time' && !this.isTimeAxis) return;
748+
749+
const svg = d3.select(this.timelineRef.current);
750+
svg.selectAll('*').remove();
751+
752+
const chartWidth = this.getChartWidth();
753+
const { start, end } = this.getTimelineRange();
754+
755+
const scale = d3.scaleLinear()
756+
.domain([start, end])
757+
.range([0, chartWidth]);
758+
759+
const axis = d3.axisBottom(scale)
760+
.tickFormat(d => this.formatTimelineValue(Number(d)));
761+
762+
svg.append('g')
763+
.attr('transform', `translate(0, 0)`)
764+
.call(axis);
765+
}
766+
767+
private getTimelineRange(): { start: number; end: number } {
768+
switch (this.state.timelineUnitType) {
769+
case 'time':
770+
return {
771+
start: Number(this.props.viewRange.getStart()),
772+
end: Number(this.props.viewRange.getEnd())
773+
};
774+
case 'cycles':
775+
case 'bytes':
776+
case 'calls':
777+
case 'bytes/sec':
778+
case 'iterations/sec':
779+
// For non-time units, use the data range
780+
const labels = this.state.xyData.labels;
781+
if (labels.length === 0) return { start: 0, end: 1 };
782+
return { start: 0, end: labels.length - 1 };
783+
default:
784+
return { start: 0, end: 1 };
785+
}
786+
}
787+
788+
private formatTimelineValue(value: number): string {
789+
switch (this.state.timelineUnitType) {
790+
case 'time':
791+
return `${d3.format('.2f')(value)} ${this.state.timelineUnit}`;
792+
case 'cycles':
793+
case 'calls':
794+
return `${d3.format('.0f')(value)} ${this.state.timelineUnit}`;
795+
case 'bytes':
796+
return this.formatBytes(value);
797+
case 'bytes/sec':
798+
return this.formatDataRate(value);
799+
case 'iterations/sec':
800+
return `${d3.format('.1f')(value)} iter/s`;
801+
default:
802+
return `${value} ${this.state.timelineUnit}`;
803+
}
804+
}
805+
806+
private formatDataRate(rate: number): string {
807+
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
808+
let value = rate;
809+
let unitIndex = 0;
810+
811+
while (value >= 1024 && unitIndex < units.length - 1) {
812+
value /= 1024;
813+
unitIndex++;
814+
}
815+
816+
return `${d3.format('.1f')(value)} ${units[unitIndex]}`;
817+
}
818+
819+
private formatBytes(bytes: number): string {
820+
const units = ['B', 'KB', 'MB', 'GB'];
821+
let value = bytes;
822+
let unitIndex = 0;
823+
824+
while (value >= 1024 && unitIndex < units.length - 1) {
825+
value /= 1024;
826+
unitIndex++;
827+
}
828+
829+
return `${d3.format('.1f')(value)} ${units[unitIndex]}`;
830+
}
831+
713832
renderChart(): React.ReactNode {
714833
const isEmpty =
715834
this.state.outputStatus === ResponseStatus.COMPLETED && (this.state.xyData?.datasets?.length ?? 0) === 0;
@@ -719,28 +838,31 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
719838
}
720839

721840
return (
722-
<div
723-
id={this.getOutputComponentDomId() + 'focusContainer'}
724-
className="xy-main"
725-
tabIndex={0}
726-
onKeyDown={e => this.onKeyDown(e)}
727-
onKeyUp={e => this.onKeyUp(e)}
728-
onWheel={e => this.onWheel(e)}
729-
onMouseMove={e => this.onMouseMove(e)}
730-
onContextMenu={e => e.preventDefault()}
731-
onMouseLeave={e => this.onMouseLeave(e)}
732-
onMouseDown={e => this.onMouseDown(e)}
733-
style={{ height: this.props.style.height, position: 'relative', cursor: this.state.cursor }}
734-
ref={this.divRef}
735-
>
736-
{this.chooseReactChart()}
737-
{this.state.outputStatus === ResponseStatus.RUNNING && (
738-
<div className="analysis-running-overflow" style={{ width: this.getChartWidth() }}>
739-
<div>
740-
<span>Analysis running</span>
841+
<div>
842+
<div
843+
id={this.getOutputComponentDomId() + 'focusContainer'}
844+
className="xy-main"
845+
tabIndex={0}
846+
onKeyDown={e => this.onKeyDown(e)}
847+
onKeyUp={e => this.onKeyUp(e)}
848+
onWheel={e => this.onWheel(e)}
849+
onMouseMove={e => this.onMouseMove(e)}
850+
onContextMenu={e => e.preventDefault()}
851+
onMouseLeave={e => this.onMouseLeave(e)}
852+
onMouseDown={e => this.onMouseDown(e)}
853+
style={{ height: this.props.style.height, position: 'relative', cursor: this.state.cursor }}
854+
ref={this.divRef}
855+
>
856+
{this.chooseReactChart()}
857+
{this.state.outputStatus === ResponseStatus.RUNNING && (
858+
<div className="analysis-running-overflow" style={{ width: this.getChartWidth() }}>
859+
<div>
860+
<span>Analysis running</span>
861+
</div>
741862
</div>
742-
</div>
743-
)}
863+
)}
864+
</div>
865+
{this.renderTimeline()}
744866
</div>
745867
);
746868
}

0 commit comments

Comments
 (0)