Skip to content

Commit 1e339d5

Browse files
Add D3.js time axis to generic-xy-view with sampling data
- Add bottom time axis using D3.js scales and axes - Support timestamp, range, and category sampling from tsp-typescript-client - Display duration labels (s/ms/μs/ns) for range sampling - Include gridlines extending through chart area - Add CSS styling with VSCode theme integration - Increase bottom margin to accommodate axis labels Signed-off-by: Matthew Khouzam <matthew.khouzam@ericsson.com>
1 parent 07efb5f commit 1e339d5

3 files changed

Lines changed: 125 additions & 4 deletions

File tree

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

Lines changed: 107 additions & 4 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,6 +69,7 @@ interface GenericXYState extends AbstractTreeOutputState {
6869
allMax: number;
6970
allMin: number;
7071
cursor: string;
72+
currentSampling?: Sampling;
7173
}
7274

7375
interface GenericXYProps extends AbstractOutputProps {
@@ -91,8 +93,9 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
9193
private readonly chartRef = React.createRef<any>();
9294
private readonly yAxisRef: any;
9395
private readonly divRef = React.createRef<HTMLDivElement>();
96+
private readonly xAxisRef = React.createRef<SVGGElement>();
9497

95-
private readonly margin = { top: 15, right: 0, bottom: 6, left: this.getYAxisWidth() };
98+
private readonly margin = { top: 15, right: 0, bottom: 30, left: this.getYAxisWidth() };
9699

97100
private mouseIsDown = false;
98101
private isPanning = false;
@@ -134,7 +137,8 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
134137
allMax: 0,
135138
allMin: 0,
136139
cursor: 'default',
137-
showTree: true
140+
showTree: true,
141+
currentSampling: undefined
138142
};
139143

140144
this.addPinViewOptions(() => ({
@@ -311,7 +315,8 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
311315
flushSync(() =>
312316
this.setState({
313317
xyData: { labels: [], datasets: [], labelIndices: [] },
314-
outputStatus: model.status ?? ResponseStatus.COMPLETED
318+
outputStatus: model.status ?? ResponseStatus.COMPLETED,
319+
currentSampling: undefined
315320
})
316321
);
317322
this.calculateYRange();
@@ -323,9 +328,15 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
323328
this.isTimeAxis = !!series[0].xValues;
324329
this.mode = st === 'scatter' ? ChartMode.SCATTER : st === 'line' ? ChartMode.LINE : ChartMode.BAR;
325330

331+
const xValues = series[0].xValues ?? series[0].xRanges ?? series[0].xCategories;
326332
const xy = this.buildXYData(series, this.mode);
327-
flushSync(() => this.setState({ xyData: xy, outputStatus: model.status ?? ResponseStatus.COMPLETED }));
333+
flushSync(() => this.setState({
334+
xyData: xy,
335+
outputStatus: model.status ?? ResponseStatus.COMPLETED,
336+
currentSampling: xValues as Sampling
337+
}));
328338
this.calculateYRange();
339+
this.updateXAxis();
329340
}
330341

331342
private buildXYData(seriesObj: XYSeries[], mode: ChartMode): GenericXYData {
@@ -408,6 +419,68 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
408419
signalManager().emit('SAVE_AS_CSV', this.props.traceId, csv);
409420
}
410421

422+
private updateXAxis(): void {
423+
if (!this.xAxisRef.current || !this.state.currentSampling) {
424+
return;
425+
}
426+
427+
const chartWidth = this.getChartWidth();
428+
if (chartWidth <= 0) return;
429+
430+
const sampling = this.state.currentSampling;
431+
let scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number> | d3.ScaleBand<string>;
432+
let axis: d3.Axis<any>;
433+
434+
if (isTimestampSampling(sampling)) {
435+
const domain = d3.extent(sampling.map(ts => new Date(Number(ts) / 1000000))) as [Date, Date];
436+
scale = d3.scaleTime().domain(domain).range([0, chartWidth]);
437+
axis = d3.axisBottom(scale).ticks(5).tickSizeInner(-parseInt(String(this.props.style.height)) + this.margin.top + this.margin.bottom);
438+
} else if (isRangeSampling(sampling)) {
439+
const timestamps = sampling.map(range => Number(range.start) / 1000000);
440+
console.log('Raw timestamps (ms):', timestamps.slice(0, 3));
441+
const dates = timestamps.map(ts => new Date(ts));
442+
console.log('Converted dates:', dates.slice(0, 3));
443+
const domain = d3.extent(dates) as [Date, Date];
444+
console.log('Domain:', domain);
445+
scale = d3.scaleTime().domain(domain).range([0, chartWidth]);
446+
axis = d3.axisBottom(scale).ticks(5).tickSizeInner(-parseInt(String(this.props.style.height)) + this.margin.top + this.margin.bottom)
447+
.tickFormat((d) => {
448+
const date = d instanceof Date ? d : new Date(d as number);
449+
const rangeIndex = Math.floor((date.getTime() - domain[0].getTime()) / (domain[1].getTime() - domain[0].getTime()) * (sampling.length - 1));
450+
const range = sampling[Math.min(rangeIndex, sampling.length - 1)];
451+
const endNs = Number(range.end);
452+
453+
const formatDuration = (ns: number): string => {
454+
if (ns >= 1e9) return `${(ns / 1e9).toFixed(2)}s`;
455+
if (ns >= 1e6) return `${(ns / 1e6).toFixed(2)}ms`;
456+
if (ns >= 1e3) return `${(ns / 1e3).toFixed(2)}μs`;
457+
return `${ns}ns`;
458+
};
459+
460+
return formatDuration(endNs);
461+
});
462+
} else if (isCategorySampling(sampling)) {
463+
scale = d3.scaleBand().domain(sampling).range([0, chartWidth]).padding(0.1);
464+
axis = d3.axisBottom(scale).tickSizeInner(-parseInt(String(this.props.style.height)) + this.margin.top + this.margin.bottom);
465+
} else {
466+
return;
467+
}
468+
469+
d3.select(this.xAxisRef.current).call(axis);
470+
// Ensure ticks and text are visible
471+
d3.select(this.xAxisRef.current).selectAll('text')
472+
.style('fill', 'var(--vscode-foreground)')
473+
.style('font-size', '12px')
474+
.attr('y', 9)
475+
.attr('dy', '0.71em');
476+
d3.select(this.xAxisRef.current).selectAll('.tick line')
477+
.style('stroke', 'var(--vscode-foreground)')
478+
.attr('y2', 6);
479+
d3.select(this.xAxisRef.current).select('.domain')
480+
.style('stroke', 'var(--vscode-foreground)')
481+
.style('stroke-width', '1px');
482+
}
483+
411484
private calculateYRange() {
412485
const ds = this.state.xyData?.datasets ?? [];
413486
if (!ds.length) {
@@ -420,6 +493,30 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
420493
this.setState({ allMax: max, allMin: min });
421494
}
422495

496+
renderXAxis(): React.ReactNode {
497+
const chartHeight = parseInt(String(this.props.style.height), 10);
498+
const xTransform = `translate(${this.margin.left}, 5)`;
499+
const chartWidth = this.getChartWidth();
500+
return (
501+
<svg
502+
height={this.margin.bottom}
503+
width={chartWidth + this.margin.left}
504+
style={{ position: 'absolute', bottom: 0 }}
505+
>
506+
<line
507+
x1={this.margin.left}
508+
y1={0}
509+
x2={chartWidth + this.margin.left}
510+
y2={0}
511+
stroke="var(--vscode-foreground)"
512+
strokeWidth={1}
513+
opacity={0.3}
514+
/>
515+
<g className="x-axis" ref={this.xAxisRef} transform={xTransform} />
516+
</svg>
517+
);
518+
}
519+
423520
componentDidMount(): void {
424521
super.componentDidMount?.();
425522
this.waitAnalysisCompletion();
@@ -437,10 +534,15 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
437534
prevProps.viewRange.getEnd() !== this.props.viewRange.getEnd();
438535

439536
const checksChanged = prevState.checkedSeries !== this.state.checkedSeries;
537+
const samplingChanged = prevState.currentSampling !== this.state.currentSampling;
440538

441539
if (sizeChanged || viewChanged || checksChanged) {
442540
if (this.getChartWidth() > 0) this._debouncedUpdateXY();
443541
}
542+
543+
if (sizeChanged || samplingChanged) {
544+
this.updateXAxis();
545+
}
444546
}
445547

446548
componentWillUnmount(): void {
@@ -734,6 +836,7 @@ export class GenericXYOutputComponent extends AbstractTreeOutputComponent<Generi
734836
ref={this.divRef}
735837
>
736838
{this.chooseReactChart()}
839+
{this.renderXAxis()}
737840
{this.state.outputStatus === ResponseStatus.RUNNING && (
738841
<div className="analysis-running-overflow" style={{ width: this.getChartWidth() }}>
739842
<div>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
.x-axis text {
2+
font-size: 12px;
3+
fill: var(--vscode-foreground) !important;
4+
}
5+
6+
.x-axis path,
7+
.x-axis line {
8+
stroke: var(--vscode-foreground);
9+
stroke-width: 1px;
10+
}
11+
12+
.x-axis .tick line {
13+
stroke: var(--vscode-foreground);
14+
stroke-width: 1px;
15+
opacity: 0.3;
16+
}

local-libs/traceviewer-libs/react-components/style/output-components-style.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
/* Output component styling */
2+
@import './generic-xy-output-component.css';
3+
24
/* Main container*/
35
.output-container {
46
display: flex;

0 commit comments

Comments
 (0)