@dxos/react-ui-trace 0.11.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/lib/index.mjs +1722 -561
- package/dist/lib/index.mjs.map +1 -1
- package/dist/lib/testing.mjs +9 -8
- package/dist/lib/testing.mjs.map +1 -1
- package/dist/lib/translations.mjs +5 -0
- package/dist/lib/translations.mjs.map +1 -1
- package/dist/types/src/components/Gantt/Gantt.d.ts +109 -24
- package/dist/types/src/components/Gantt/Gantt.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/Gantt.stories.d.ts +73 -3
- package/dist/types/src/components/Gantt/Gantt.stories.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/gantt-rows.d.ts +26 -0
- package/dist/types/src/components/Gantt/gantt-rows.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-rows.test.d.ts +2 -0
- package/dist/types/src/components/Gantt/gantt-rows.test.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-scale.d.ts +57 -0
- package/dist/types/src/components/Gantt/gantt-scale.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-scale.test.d.ts +2 -0
- package/dist/types/src/components/Gantt/gantt-scale.test.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/index.d.ts +2 -0
- package/dist/types/src/components/Gantt/index.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/useEnter.d.ts +13 -0
- package/dist/types/src/components/Gantt/useEnter.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/useRangeTween.d.ts +12 -0
- package/dist/types/src/components/Gantt/useRangeTween.d.ts.map +1 -0
- package/dist/types/src/components/ProcessTree/ProcessTree.d.ts +3 -3
- package/dist/types/src/components/ProcessTree/ProcessTree.d.ts.map +1 -1
- package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/Timeline.d.ts +10 -2
- package/dist/types/src/components/Timeline/Timeline.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/Timeline.stories.d.ts +8 -4
- package/dist/types/src/components/Timeline/Timeline.stories.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/timeline-layout.d.ts +35 -0
- package/dist/types/src/components/Timeline/timeline-layout.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/timeline-layout.test.d.ts +2 -0
- package/dist/types/src/components/Timeline/timeline-layout.test.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/SpanTreeView.d.ts +15 -0
- package/dist/types/src/components/TracePanel/SpanTreeView.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/TracePanel.d.ts +3 -3
- package/dist/types/src/components/TracePanel/TracePanel.d.ts.map +1 -1
- package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts +46 -0
- package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/trace-filter.d.ts +7 -2
- package/dist/types/src/components/TracePanel/trace-filter.d.ts.map +1 -1
- package/dist/types/src/components/TracePanel/useTraceMenu.d.ts.map +1 -1
- package/dist/types/src/execution-graph/execution-graph.d.ts +1 -1
- package/dist/types/src/execution-graph/execution-graph.d.ts.map +1 -1
- package/dist/types/src/hooks/useExecutionGraph.d.ts +2 -2
- package/dist/types/src/hooks/useExecutionGraph.d.ts.map +1 -1
- package/dist/types/src/hooks/useSessionTimeline.d.ts +1 -1
- package/dist/types/src/hooks/useSessionTimeline.d.ts.map +1 -1
- package/dist/types/src/hooks/useTraceMessages.d.ts +1 -1
- package/dist/types/src/hooks/useTraceMessages.d.ts.map +1 -1
- package/dist/types/src/session-timeline/TaskHistory.stories.d.ts +12 -0
- package/dist/types/src/session-timeline/TaskHistory.stories.d.ts.map +1 -0
- package/dist/types/src/session-timeline/gantt-mapping.d.ts +14 -0
- package/dist/types/src/session-timeline/gantt-mapping.d.ts.map +1 -0
- package/dist/types/src/session-timeline/gantt-mapping.test.d.ts +2 -0
- package/dist/types/src/session-timeline/gantt-mapping.test.d.ts.map +1 -0
- package/dist/types/src/session-timeline/index.d.ts +1 -0
- package/dist/types/src/session-timeline/index.d.ts.map +1 -1
- package/dist/types/src/session-timeline/session-timeline.d.ts +17 -2
- package/dist/types/src/session-timeline/session-timeline.d.ts.map +1 -1
- package/dist/types/src/session-timeline/types.d.ts +32 -0
- package/dist/types/src/session-timeline/types.d.ts.map +1 -1
- package/dist/types/src/testing/simulated-agent.d.ts +5 -4
- package/dist/types/src/testing/simulated-agent.d.ts.map +1 -1
- package/dist/types/src/testing/test-trace.d.ts +2 -2
- package/dist/types/src/testing/test-trace.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +5 -0
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +30 -26
- package/src/components/Gantt/Gantt.stories.tsx +914 -96
- package/src/components/Gantt/Gantt.tsx +964 -261
- package/src/components/Gantt/gantt-rows.test.ts +123 -0
- package/src/components/Gantt/gantt-rows.ts +71 -0
- package/src/components/Gantt/gantt-scale.test.ts +105 -0
- package/src/components/Gantt/gantt-scale.ts +143 -0
- package/src/components/Gantt/index.ts +2 -0
- package/src/components/Gantt/useEnter.ts +33 -0
- package/src/components/Gantt/useRangeTween.ts +61 -0
- package/src/components/ProcessTree/ProcessTree.stories.tsx +2 -3
- package/src/components/ProcessTree/ProcessTree.tsx +99 -90
- package/src/components/Timeline/Timeline.stories.tsx +110 -22
- package/src/components/Timeline/Timeline.tsx +319 -271
- package/src/components/Timeline/timeline-layout.test.ts +177 -0
- package/src/components/Timeline/timeline-layout.ts +153 -0
- package/src/components/TracePanel/SpanTreeView.tsx +39 -0
- package/src/components/TracePanel/TracePanel.stories.tsx +106 -0
- package/src/components/TracePanel/TracePanel.tsx +113 -106
- package/src/components/TracePanel/trace-filter.test.ts +31 -2
- package/src/components/TracePanel/trace-filter.ts +28 -3
- package/src/components/TracePanel/useTraceMenu.ts +0 -1
- package/src/execution-graph/SPEC.md +10 -10
- package/src/execution-graph/execution-graph.test.ts +16 -15
- package/src/execution-graph/execution-graph.ts +108 -60
- package/src/execution-graph/pending-block-status.ts +1 -1
- package/src/execution-graph/sub-agent-delegation.test.ts +1 -1
- package/src/hooks/useExecutionGraph.ts +14 -11
- package/src/hooks/useSessionTimeline.ts +35 -9
- package/src/hooks/useTraceMessages.ts +1 -1
- package/src/session-timeline/TaskHistory.stories.tsx +142 -0
- package/src/session-timeline/gantt-mapping.test.ts +118 -0
- package/src/session-timeline/gantt-mapping.ts +158 -0
- package/src/session-timeline/index.ts +1 -0
- package/src/session-timeline/session-timeline.test.ts +585 -35
- package/src/session-timeline/session-timeline.ts +433 -62
- package/src/session-timeline/types.ts +14 -0
- package/src/testing/simulated-agent.ts +17 -16
- package/src/testing/test-trace.ts +14 -12
- package/src/translations.ts +8 -2
- package/dist/types/src/execution-graph/remote-snapshot.test.d.ts +0 -2
- package/dist/types/src/execution-graph/remote-snapshot.test.d.ts.map +0 -1
- package/src/execution-graph/remote-snapshot.test.ts +0 -116
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { orderRows } from './gantt-rows.ts';
|
|
8
|
+
import { type GanttGroup, type GanttLane } from './Gantt.tsx';
|
|
9
|
+
|
|
10
|
+
const lane = (id: string, rest: Partial<GanttLane> = {}): GanttLane => ({
|
|
11
|
+
id,
|
|
12
|
+
label: id,
|
|
13
|
+
status: 'done',
|
|
14
|
+
...rest,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** `<row index>: <indent><lane id> [band]`, which is the shape a reader checks against the drawing. */
|
|
18
|
+
const format = ({ rows, bands }: ReturnType<typeof orderRows>): string =>
|
|
19
|
+
[
|
|
20
|
+
...rows.map(({ lane, depth, index }) => `${index}: ${' '.repeat(depth)}${lane.id}`),
|
|
21
|
+
...bands.map(({ group, first, last }) => `band ${group.id}: ${first}..${last}`),
|
|
22
|
+
].join('\n');
|
|
23
|
+
|
|
24
|
+
describe('orderRows', () => {
|
|
25
|
+
test('a band holds its lanes in the order given', ({ expect }) => {
|
|
26
|
+
const groups: GanttGroup[] = [{ id: 'g' }];
|
|
27
|
+
const lanes = [lane('a', { groupId: 'g' }), lane('b', { groupId: 'g' }), lane('c', { groupId: 'g' })];
|
|
28
|
+
expect(`\n${format(orderRows(groups, lanes))}\n`).toMatchInlineSnapshot(`
|
|
29
|
+
"
|
|
30
|
+
0: a
|
|
31
|
+
1: b
|
|
32
|
+
2: c
|
|
33
|
+
band g: 0..2
|
|
34
|
+
"
|
|
35
|
+
`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a lane nests under its parent, which indents it and the lanes under it', ({ expect }) => {
|
|
39
|
+
const groups: GanttGroup[] = [{ id: 'g' }];
|
|
40
|
+
const lanes = [
|
|
41
|
+
lane('root', { groupId: 'g' }),
|
|
42
|
+
lane('child', { groupId: 'g', parentId: 'root' }),
|
|
43
|
+
lane('grandchild', { groupId: 'g', parentId: 'child' }),
|
|
44
|
+
lane('sibling', { groupId: 'g' }),
|
|
45
|
+
];
|
|
46
|
+
expect(`\n${format(orderRows(groups, lanes))}\n`).toMatchInlineSnapshot(`
|
|
47
|
+
"
|
|
48
|
+
0: root
|
|
49
|
+
1: child
|
|
50
|
+
2: grandchild
|
|
51
|
+
3: sibling
|
|
52
|
+
band g: 0..3
|
|
53
|
+
"
|
|
54
|
+
`);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a nested group's band follows its parent's, indented", ({ expect }) => {
|
|
58
|
+
const groups: GanttGroup[] = [{ id: 'one' }, { id: 'one:a', parentId: 'one' }, { id: 'two' }];
|
|
59
|
+
const lanes = [
|
|
60
|
+
lane('p1', { groupId: 'one' }),
|
|
61
|
+
lane('p1:task', { groupId: 'one' }),
|
|
62
|
+
lane('p2', { groupId: 'one:a' }),
|
|
63
|
+
lane('p4', { groupId: 'two' }),
|
|
64
|
+
];
|
|
65
|
+
expect(`\n${format(orderRows(groups, lanes))}\n`).toMatchInlineSnapshot(`
|
|
66
|
+
"
|
|
67
|
+
0: p1
|
|
68
|
+
1: p1:task
|
|
69
|
+
2: p2
|
|
70
|
+
3: p4
|
|
71
|
+
band one: 0..1
|
|
72
|
+
band one:a: 2..2
|
|
73
|
+
band two: 3..3
|
|
74
|
+
"
|
|
75
|
+
`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('every band is a contiguous run of rows that all belong to it', ({ expect }) => {
|
|
79
|
+
// The invariant the rectangles rest on: one rect can only enclose adjacent rows, so a band's
|
|
80
|
+
// range must hold its own lanes and nobody else's — however the caller interleaved the input.
|
|
81
|
+
const groups: GanttGroup[] = [{ id: 'one' }, { id: 'one:a', parentId: 'one' }, { id: 'two' }];
|
|
82
|
+
const lanes = [
|
|
83
|
+
lane('from-two', { groupId: 'two' }),
|
|
84
|
+
lane('from-one', { groupId: 'one' }),
|
|
85
|
+
lane('from-nested', { groupId: 'one:a' }),
|
|
86
|
+
lane('from-one-again', { groupId: 'one' }),
|
|
87
|
+
];
|
|
88
|
+
const { rows, bands } = orderRows(groups, lanes);
|
|
89
|
+
for (const { group, first, last } of bands) {
|
|
90
|
+
expect(rows.slice(first, last + 1).map((row) => row.groupId)).toEqual(
|
|
91
|
+
rows.slice(first, last + 1).map(() => group.id),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
expect(bands.map(({ group, first, last }) => `${group.id}:${first}..${last}`)).toEqual([
|
|
95
|
+
'one:0..1',
|
|
96
|
+
'one:a:2..2',
|
|
97
|
+
'two:3..3',
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('an ungrouped lane comes first and belongs to no band', ({ expect }) => {
|
|
102
|
+
const groups: GanttGroup[] = [{ id: 'g' }];
|
|
103
|
+
const lanes = [lane('grouped', { groupId: 'g' }), lane('loose')];
|
|
104
|
+
const { rows, bands } = orderRows(groups, lanes);
|
|
105
|
+
expect(rows.map((row) => row.lane.id)).toEqual(['loose', 'grouped']);
|
|
106
|
+
expect(bands).toEqual([{ group: { id: 'g' }, first: 1, last: 1 }]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a group with no lanes gets no band, so nothing draws an empty rectangle', ({ expect }) => {
|
|
110
|
+
const { rows, bands } = orderRows([{ id: 'empty' }, { id: 'g' }], [lane('a', { groupId: 'g' })]);
|
|
111
|
+
expect(rows).toHaveLength(1);
|
|
112
|
+
expect(bands.map(({ group }) => group.id)).toEqual(['g']);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a lane whose parent sits in another group is dropped rather than misplaced', ({ expect }) => {
|
|
116
|
+
// Nesting is within a band by definition; drawing such a lane anyway would put it inside a
|
|
117
|
+
// rectangle that does not own it and break the contiguity the bands promise.
|
|
118
|
+
const groups: GanttGroup[] = [{ id: 'one' }, { id: 'two' }];
|
|
119
|
+
const lanes = [lane('a', { groupId: 'one' }), lane('b', { groupId: 'two', parentId: 'a' })];
|
|
120
|
+
const { rows } = orderRows(groups, lanes);
|
|
121
|
+
expect(rows.map((row) => row.lane.id)).toEqual(['a']);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// Kept out of `Gantt.tsx`: react-refresh only fast-refreshes a module whose exports are all
|
|
6
|
+
// components, so a layout function exported beside them forces a full reload on every edit.
|
|
7
|
+
|
|
8
|
+
import { type GanttGroup, type GanttLane } from './Gantt.tsx';
|
|
9
|
+
|
|
10
|
+
export type Row = {
|
|
11
|
+
lane: GanttLane;
|
|
12
|
+
depth: number;
|
|
13
|
+
index: number;
|
|
14
|
+
groupId: string | undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** A group's rows, contiguous — which is what lets one rectangle enclose them. */
|
|
18
|
+
export type Band = {
|
|
19
|
+
group: GanttGroup;
|
|
20
|
+
first: number;
|
|
21
|
+
last: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Rows in reading order: the ungrouped lanes, then each group's band, a nested group's band directly
|
|
26
|
+
* after its parent's. Within a band the lanes come in the order given, each nested under its
|
|
27
|
+
* `parentId`.
|
|
28
|
+
*
|
|
29
|
+
* A band is contiguous by construction, which is the whole reason the order is settled here rather
|
|
30
|
+
* than left to the caller: one rectangle can only enclose a run of adjacent rows.
|
|
31
|
+
*/
|
|
32
|
+
export const orderRows = (
|
|
33
|
+
groups: readonly GanttGroup[],
|
|
34
|
+
lanes: readonly GanttLane[],
|
|
35
|
+
): { rows: Row[]; bands: Band[] } => {
|
|
36
|
+
const rows: Row[] = [];
|
|
37
|
+
const bands: Band[] = [];
|
|
38
|
+
|
|
39
|
+
/** A group's lanes, nested under one another by `parentId`; a parent in another group is ignored. */
|
|
40
|
+
const pushLanes = (members: readonly GanttLane[], parentId: string | undefined, depth: number): void => {
|
|
41
|
+
for (const lane of members.filter((member) => member.parentId === parentId)) {
|
|
42
|
+
rows.push({ lane, depth, index: rows.length, groupId: lane.groupId });
|
|
43
|
+
pushLanes(members, lane.id, depth + 1);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const visitGroup = (group: GanttGroup, depth: number): void => {
|
|
48
|
+
const first = rows.length;
|
|
49
|
+
pushLanes(
|
|
50
|
+
lanes.filter((lane) => lane.groupId === group.id),
|
|
51
|
+
undefined,
|
|
52
|
+
depth,
|
|
53
|
+
);
|
|
54
|
+
if (rows.length > first) {
|
|
55
|
+
bands.push({ group, first, last: rows.length - 1 });
|
|
56
|
+
}
|
|
57
|
+
for (const child of groups.filter((candidate) => candidate.parentId === group.id)) {
|
|
58
|
+
visitGroup(child, depth + 1);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
pushLanes(
|
|
63
|
+
lanes.filter((lane) => lane.groupId === undefined),
|
|
64
|
+
undefined,
|
|
65
|
+
0,
|
|
66
|
+
);
|
|
67
|
+
for (const group of groups.filter((group) => group.parentId === undefined)) {
|
|
68
|
+
visitGroup(group, 0);
|
|
69
|
+
}
|
|
70
|
+
return { rows, bands };
|
|
71
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { timeScale, unitScale } from './gantt-scale.ts';
|
|
8
|
+
|
|
9
|
+
const PAD = 10;
|
|
10
|
+
const STEP = 20;
|
|
11
|
+
|
|
12
|
+
describe('timeScale', () => {
|
|
13
|
+
test('maps the range onto the padded width', ({ expect }) => {
|
|
14
|
+
const scale = timeScale({ range: { start: 100, end: 200 }, width: 220, pad: PAD });
|
|
15
|
+
expect(scale.at(100)).toBe(PAD);
|
|
16
|
+
expect(scale.at(200)).toBe(210);
|
|
17
|
+
expect(scale.at(150)).toBe(110);
|
|
18
|
+
expect(scale.width).toBe(220);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('labels ticks with elapsed time at round intervals', ({ expect }) => {
|
|
22
|
+
const labels = (end: number) =>
|
|
23
|
+
timeScale({ range: { start: 1_000_000, end: 1_000_000 + end }, width: 600, pad: PAD }).ticks.map(
|
|
24
|
+
({ label }) => label,
|
|
25
|
+
);
|
|
26
|
+
expect(labels(40_000)).toEqual(['0s', '5s', '10s', '15s', '20s', '25s', '30s', '35s', '40s']);
|
|
27
|
+
expect(labels(800)).toEqual(['0.0s', '0.1s', '0.2s', '0.3s', '0.4s', '0.5s', '0.6s', '0.7s', '0.8s']);
|
|
28
|
+
expect(labels(150_000)).toEqual(['0:00', '0:30', '1:00', '1:30', '2:00', '2:30']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('an instantaneous range does not divide by zero', ({ expect }) => {
|
|
32
|
+
const scale = timeScale({ range: { start: 100, end: 100 }, width: 220, pad: PAD });
|
|
33
|
+
expect(scale.at(100)).toBe(PAD);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('ticks thin out with the width, never below two', ({ expect }) => {
|
|
37
|
+
const wide = timeScale({ range: { start: 0, end: 1_000 }, width: 800, pad: PAD });
|
|
38
|
+
const narrow = timeScale({ range: { start: 0, end: 1_000 }, width: 120, pad: PAD });
|
|
39
|
+
// Round 0.2s steps across the wide one; the narrow one keeps just its two ends.
|
|
40
|
+
expect(wide.ticks).toHaveLength(6);
|
|
41
|
+
expect(narrow.ticks).toHaveLength(2);
|
|
42
|
+
expect(wide.ticks[0].at).toBe(PAD);
|
|
43
|
+
expect(wide.ticks[5].at).toBe(790);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('unitScale', () => {
|
|
48
|
+
test('one step per event, whatever the gaps between them', ({ expect }) => {
|
|
49
|
+
// The third event is a hundred times further from the second than the second is from the first;
|
|
50
|
+
// on this axis that lull is one step like any other.
|
|
51
|
+
const scale = unitScale({ times: [0, 10, 1_010], step: STEP, pad: PAD });
|
|
52
|
+
expect(scale.at(0)).toBe(PAD);
|
|
53
|
+
expect(scale.at(10)).toBe(30);
|
|
54
|
+
expect(scale.at(1_010)).toBe(50);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('out of order and duplicate instants land on the same units', ({ expect }) => {
|
|
58
|
+
const scale = unitScale({ times: [1_010, 10, 0, 10], step: STEP, pad: PAD });
|
|
59
|
+
expect([scale.at(0), scale.at(10), scale.at(1_010)]).toEqual([PAD, 30, 50]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('an instant between two events sits proportionally between their units', ({ expect }) => {
|
|
63
|
+
const scale = unitScale({ times: [0, 100], step: STEP, pad: PAD });
|
|
64
|
+
expect(scale.at(25)).toBe(15);
|
|
65
|
+
expect(scale.at(50)).toBe(20);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('an instant outside the events clamps to the nearest end', ({ expect }) => {
|
|
69
|
+
const scale = unitScale({ times: [100, 200], step: STEP, pad: PAD });
|
|
70
|
+
expect(scale.at(0)).toBe(PAD);
|
|
71
|
+
expect(scale.at(10_000)).toBe(30);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the width ends at the newest event, with no empty unit after it', ({ expect }) => {
|
|
75
|
+
expect(unitScale({ times: [0, 10, 20], step: STEP, pad: PAD }).width).toBe(2 * PAD + 2 * STEP);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('no events is a drawing with no axis rather than a division by zero', ({ expect }) => {
|
|
79
|
+
const scale = unitScale({ times: [], step: STEP, pad: PAD });
|
|
80
|
+
expect(scale.at(1_234)).toBe(PAD);
|
|
81
|
+
expect(scale.ticks).toEqual([]);
|
|
82
|
+
expect(scale.width).toBe(2 * PAD);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('ticks are event ordinals, strided so their labels cannot collide', ({ expect }) => {
|
|
86
|
+
// A step as wide as a label leaves room for one tick per event.
|
|
87
|
+
expect(unitScale({ times: [0, 1, 2, 3, 4, 5], step: 40, pad: PAD }).ticks).toEqual([
|
|
88
|
+
{ at: PAD, label: '#1' },
|
|
89
|
+
{ at: PAD + 40, label: '#2' },
|
|
90
|
+
{ at: PAD + 80, label: '#3' },
|
|
91
|
+
{ at: PAD + 120, label: '#4' },
|
|
92
|
+
{ at: PAD + 160, label: '#5' },
|
|
93
|
+
{ at: PAD + 200, label: '#6' },
|
|
94
|
+
]);
|
|
95
|
+
// Half that, and only every third event can carry one.
|
|
96
|
+
expect(unitScale({ times: [0, 1, 2, 3, 4, 5], step: STEP, pad: PAD }).ticks).toEqual([
|
|
97
|
+
{ at: PAD, label: '#1' },
|
|
98
|
+
{ at: PAD + 3 * STEP, label: '#4' },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a single event still labels itself', ({ expect }) => {
|
|
103
|
+
expect(unitScale({ times: [7], step: STEP, pad: PAD }).ticks).toEqual([{ at: PAD, label: '#1' }]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the horizontal axis measures.
|
|
7
|
+
* - `time` — real duration, fitted to the width available: an idle gap is as wide as it was long.
|
|
8
|
+
* - `unit` — one fixed step per event: a burst and a lull read alike, and the drawing grows to the
|
|
9
|
+
* right instead of compressing, so an event already placed never moves again. That is what makes a
|
|
10
|
+
* live chart watchable — on a fitted axis every arrival shifts the whole history left.
|
|
11
|
+
*/
|
|
12
|
+
export type GanttAxis = 'time' | 'unit';
|
|
13
|
+
|
|
14
|
+
/** A labelled gridline, positioned in the same pixels as `GanttScale.at`. */
|
|
15
|
+
export type GanttTick = { at: number; label: string };
|
|
16
|
+
|
|
17
|
+
/** The mapping every part of the drawing shares: an instant to a horizontal offset in pixels. */
|
|
18
|
+
export type GanttScale = {
|
|
19
|
+
at: (time: number) => number;
|
|
20
|
+
/** The drawing's width: the time axis takes what it is given, the unit axis states its own. */
|
|
21
|
+
width: number;
|
|
22
|
+
/** The labelled gridlines, as many as fit — the label's own width decides, so the scale owns this. */
|
|
23
|
+
ticks: GanttTick[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Room a tick label needs; elapsed labels are short, so this also caps how many fit. */
|
|
27
|
+
const TIME_LABEL_WIDTH = 56;
|
|
28
|
+
const MAX_TIME_TICKS = 10;
|
|
29
|
+
|
|
30
|
+
/** Round tick intervals in milliseconds, so elapsed labels read `0s, 10s, 20s` rather than `13.4s`. */
|
|
31
|
+
const TICK_STEPS = [
|
|
32
|
+
100, 200, 500, 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000, 600_000, 900_000, 1_800_000,
|
|
33
|
+
3_600_000, 7_200_000, 21_600_000, 43_200_000, 86_400_000,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const pad2 = (value: number): string => String(value).padStart(2, '0');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Elapsed time since the start of the axis. The span picks the form for every tick alike, so a
|
|
40
|
+
* run's labels read as one unit: `0.2s` below a second's step, `45s` below a minute, `1:05` below an
|
|
41
|
+
* hour, `1:02:05` beyond.
|
|
42
|
+
*/
|
|
43
|
+
export const formatElapsed = (elapsed: number, { span, step }: { span: number; step: number }): string => {
|
|
44
|
+
const seconds = Math.round(elapsed / 1_000);
|
|
45
|
+
if (span >= 3_600_000) {
|
|
46
|
+
return `${Math.floor(seconds / 3_600)}:${pad2(Math.floor(seconds / 60) % 60)}:${pad2(seconds % 60)}`;
|
|
47
|
+
}
|
|
48
|
+
if (span >= 60_000) {
|
|
49
|
+
return `${Math.floor(seconds / 60)}:${pad2(seconds % 60)}`;
|
|
50
|
+
}
|
|
51
|
+
if (step < 1_000) {
|
|
52
|
+
return `${(elapsed / 1_000).toFixed(1)}s`;
|
|
53
|
+
}
|
|
54
|
+
return `${seconds}s`;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** An event's ordinal is two or three glyphs, so its labels pack tighter than a clock's. */
|
|
58
|
+
const EVENT_LABEL_WIDTH = 40;
|
|
59
|
+
|
|
60
|
+
export type TimeScaleOptions = {
|
|
61
|
+
range: { start: number; end: number };
|
|
62
|
+
/** The width the range is fitted into. */
|
|
63
|
+
width: number;
|
|
64
|
+
/** Clearance at each end, so a bar's cap and the first tick's label stay inside the drawing. */
|
|
65
|
+
pad: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Duration against width, labelled with the time elapsed since the start of the range. */
|
|
69
|
+
export const timeScale = ({ range, width, pad }: TimeScaleOptions): GanttScale => {
|
|
70
|
+
const span = Math.max(range.end - range.start, 1);
|
|
71
|
+
const inner = Math.max(width - 2 * pad, 1);
|
|
72
|
+
const at = (time: number): number => pad + ((time - range.start) / span) * inner;
|
|
73
|
+
const maxTicks = Math.max(2, Math.min(MAX_TIME_TICKS, Math.floor(inner / TIME_LABEL_WIDTH)));
|
|
74
|
+
const step =
|
|
75
|
+
TICK_STEPS.find((candidate) => span / candidate <= maxTicks - 1) ??
|
|
76
|
+
Math.ceil(span / (maxTicks - 1) / TICK_STEPS[TICK_STEPS.length - 1]) * TICK_STEPS[TICK_STEPS.length - 1];
|
|
77
|
+
const ticks: GanttTick[] = [];
|
|
78
|
+
for (let elapsed = 0; elapsed <= span; elapsed += step) {
|
|
79
|
+
ticks.push({ at: at(range.start + elapsed), label: formatElapsed(elapsed, { span, step }) });
|
|
80
|
+
}
|
|
81
|
+
return { at, width, ticks };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type UnitScaleOptions = {
|
|
85
|
+
/** Every event's instant; order and duplicates do not matter. */
|
|
86
|
+
times: readonly number[];
|
|
87
|
+
/** Pixels per event — the axis's one unit. */
|
|
88
|
+
step: number;
|
|
89
|
+
/** Clearance at each end, which also keeps the newest node off the edge. */
|
|
90
|
+
pad: number;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One step per event, in the order they happened. Ordinals are global rather than per lane: a
|
|
95
|
+
* delegation drops from a parent's node to its child's first node, and only a shared ordering keeps
|
|
96
|
+
* those two at the same offset.
|
|
97
|
+
*/
|
|
98
|
+
export const unitScale = ({ times, step, pad }: UnitScaleOptions): GanttScale => {
|
|
99
|
+
const events = [...new Set(times)].sort((left, right) => left - right);
|
|
100
|
+
const last = Math.max(events.length - 1, 0);
|
|
101
|
+
|
|
102
|
+
/** Index of the last event at or before `time`, or -1 when `time` precedes every event. */
|
|
103
|
+
const floorIndex = (time: number): number => {
|
|
104
|
+
let low = 0;
|
|
105
|
+
let high = events.length - 1;
|
|
106
|
+
let found = -1;
|
|
107
|
+
while (low <= high) {
|
|
108
|
+
const mid = (low + high) >> 1;
|
|
109
|
+
if (events[mid] <= time) {
|
|
110
|
+
found = mid;
|
|
111
|
+
low = mid + 1;
|
|
112
|
+
} else {
|
|
113
|
+
high = mid - 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return found;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// An instant between two events sits proportionally between their units: a lane's start or end is
|
|
120
|
+
// rarely an event of its own, and snapping it to the nearest would pull a bar's edge off its node.
|
|
121
|
+
const unit = (time: number): number => {
|
|
122
|
+
if (events.length === 0) {
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
const index = floorIndex(time);
|
|
126
|
+
if (index < 0) {
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
if (index >= last) {
|
|
130
|
+
return last;
|
|
131
|
+
}
|
|
132
|
+
const from = events[index];
|
|
133
|
+
return index + (time - from) / (events[index + 1] - from);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const stride = Math.max(1, Math.ceil(last / Math.max(Math.floor((last * step) / EVENT_LABEL_WIDTH), 1)));
|
|
137
|
+
const ticks: GanttTick[] = [];
|
|
138
|
+
for (let index = 0; events.length > 0 && index <= last; index += stride) {
|
|
139
|
+
ticks.push({ at: pad + index * step, label: `#${index + 1}` });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { at: (time) => pad + unit(time) * step, width: 2 * pad + last * step, ticks };
|
|
143
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Which of `ids` arrived since the last frame.
|
|
9
|
+
*
|
|
10
|
+
* A caller draws an entering element at the place it came from — the event before it, the node that
|
|
11
|
+
* spawned its lane — and the next frame draws it where it belongs, leaving a CSS transition to carry
|
|
12
|
+
* it the rest of the way. That is all the bookkeeping an enter animation needs: everything else in
|
|
13
|
+
* the drawing animates from its own previous value, which the browser already has.
|
|
14
|
+
*
|
|
15
|
+
* Nothing present on mount ever counts as entering — a chart that animates its whole history open on
|
|
16
|
+
* first paint is noise, since the reader was not there to see it arrive.
|
|
17
|
+
*/
|
|
18
|
+
export const useEnter = (ids: readonly string[]): ((id: string) => boolean) => {
|
|
19
|
+
const [settled, setSettled] = useState<ReadonlySet<string>>(() => new Set(ids));
|
|
20
|
+
const entering = ids.some((id) => !settled.has(id));
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!entering) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// The transition needs one painted frame at the origin to run from. This effect is already past
|
|
27
|
+
// that paint, so the frame after it is where the resting value belongs.
|
|
28
|
+
const frame = requestAnimationFrame(() => setSettled(new Set(ids)));
|
|
29
|
+
return () => cancelAnimationFrame(frame);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return (id) => !settled.has(id);
|
|
33
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useEffect, useRef, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
export type TimeRange = { start: number; end: number };
|
|
8
|
+
|
|
9
|
+
/** How long the axis takes to settle on new bounds. */
|
|
10
|
+
const TWEEN_DURATION = 400;
|
|
11
|
+
|
|
12
|
+
const easeOutCubic = (progress: number): number => 1 - (1 - progress) ** 3;
|
|
13
|
+
|
|
14
|
+
/** Motion is skipped where nothing would see it, or where the reader asked for less of it. */
|
|
15
|
+
const shouldSnap = (): boolean =>
|
|
16
|
+
typeof document === 'undefined' ||
|
|
17
|
+
document.hidden ||
|
|
18
|
+
(typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The range to draw with: `target`, reached over a short ease rather than in one step, so a
|
|
22
|
+
* time axis whose bounds move (a new event, `now` advancing) slides every span, node and tick to
|
|
23
|
+
* its new place together instead of jumping. A new target mid-flight eases on from wherever the
|
|
24
|
+
* drawing is, so a stream of updates reads as one continuous motion.
|
|
25
|
+
*/
|
|
26
|
+
export const useRangeTween = (target: TimeRange, enabled: boolean): TimeRange => {
|
|
27
|
+
const [shown, setShown] = useState(target);
|
|
28
|
+
const shownRef = useRef(target);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const from = shownRef.current;
|
|
32
|
+
const settle = (range: TimeRange) => {
|
|
33
|
+
shownRef.current = range;
|
|
34
|
+
setShown(range);
|
|
35
|
+
};
|
|
36
|
+
if (!enabled || shouldSnap() || (from.start === target.start && from.end === target.end)) {
|
|
37
|
+
settle(target);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let frame: number | undefined;
|
|
42
|
+
const startedAt = performance.now();
|
|
43
|
+
const step = (time: number) => {
|
|
44
|
+
const progress = Math.min((time - startedAt) / TWEEN_DURATION, 1);
|
|
45
|
+
const eased = easeOutCubic(progress);
|
|
46
|
+
settle({
|
|
47
|
+
start: from.start + (target.start - from.start) * eased,
|
|
48
|
+
end: from.end + (target.end - from.end) * eased,
|
|
49
|
+
});
|
|
50
|
+
frame = progress < 1 ? requestAnimationFrame(step) : undefined;
|
|
51
|
+
};
|
|
52
|
+
frame = requestAnimationFrame(step);
|
|
53
|
+
return () => {
|
|
54
|
+
if (frame !== undefined) {
|
|
55
|
+
cancelAnimationFrame(frame);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}, [enabled, target.start, target.end]);
|
|
59
|
+
|
|
60
|
+
return enabled ? shown : target;
|
|
61
|
+
};
|
|
@@ -11,10 +11,9 @@ import { log } from '@dxos/log';
|
|
|
11
11
|
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
12
12
|
|
|
13
13
|
import { makeProcess } from '../../testing/index.ts';
|
|
14
|
-
|
|
15
14
|
import { ProcessTree, type ProcessTreeProps } from './ProcessTree.tsx';
|
|
16
15
|
|
|
17
|
-
const processes: Process.
|
|
16
|
+
const processes: Process.Process[] = [
|
|
18
17
|
makeProcess({
|
|
19
18
|
pid: Process.ID.make('97793611-815e-4a67-bc04-60fa67b2c987'),
|
|
20
19
|
name: 'Trigger watcher',
|
|
@@ -68,7 +67,7 @@ const DefaultStory = (props: ProcessTreeProps) => {
|
|
|
68
67
|
};
|
|
69
68
|
|
|
70
69
|
const meta: Meta<typeof ProcessTree> = {
|
|
71
|
-
title: '
|
|
70
|
+
title: 'ui/react-ui-trace/ProcessTree',
|
|
72
71
|
component: ProcessTree,
|
|
73
72
|
render: DefaultStory,
|
|
74
73
|
decorators: [withTheme(), withLayout({ layout: 'column', classNames: 'w-(--dx-complementary-sidebar-size)' })],
|