@dxos/react-ui-trace 0.11.1 → 0.13.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 +3 -3
- 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 +3 -2
- 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 +5 -6
- 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 +567 -19
- package/src/session-timeline/session-timeline.ts +433 -62
- package/src/session-timeline/types.ts +14 -0
- package/src/testing/simulated-agent.ts +3 -2
- package/src/testing/test-trace.ts +2 -2
- 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
|
@@ -3,183 +3,267 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { format } from 'date-fns';
|
|
6
|
-
import React, {
|
|
6
|
+
import React, {
|
|
7
|
+
Fragment,
|
|
8
|
+
type KeyboardEvent,
|
|
9
|
+
type PropsWithChildren,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
forwardRef,
|
|
12
|
+
useCallback,
|
|
13
|
+
useEffect,
|
|
14
|
+
useId,
|
|
15
|
+
useLayoutEffect,
|
|
16
|
+
useMemo,
|
|
17
|
+
useRef,
|
|
18
|
+
useState,
|
|
19
|
+
} from 'react';
|
|
7
20
|
|
|
8
21
|
import { createContext, useComposedRefs } from '@dxos/react-hooks';
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import
|
|
22
|
+
import * as Button from '@dxos/react-ui/Button';
|
|
23
|
+
import * as Hooks from '@dxos/react-ui/Hooks';
|
|
24
|
+
import * as HoverCard from '@dxos/react-ui/HoverCard';
|
|
25
|
+
import * as ScrollArea from '@dxos/react-ui/ScrollArea';
|
|
26
|
+
import * as Util from '@dxos/react-ui/Util';
|
|
27
|
+
import { type Hue, mx } from '@dxos/ui-theme';
|
|
12
28
|
|
|
13
|
-
|
|
29
|
+
import { translationKey } from '../../translations.ts';
|
|
30
|
+
import { type Band, type Row, orderRows } from './gantt-rows.ts';
|
|
31
|
+
import { type GanttAxis, type GanttScale, formatElapsed, timeScale, unitScale } from './gantt-scale.ts';
|
|
32
|
+
import { useEnter } from './useEnter.ts';
|
|
33
|
+
import { useRangeTween } from './useRangeTween.ts';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The chart's vocabulary is groups, lanes, segments and markers — deliberately not the vocabulary of
|
|
37
|
+
* whatever is being charted. A host maps its own nouns onto these (a process onto a group, a task
|
|
38
|
+
* onto a lane, a status change onto a marker) and the chart stays ignorant of what they mean.
|
|
39
|
+
*/
|
|
14
40
|
export type GanttLaneStatus = 'pending' | 'blocked' | 'running' | 'review' | 'done' | 'failed';
|
|
15
|
-
export type GanttMarkerKind = 'request' | 'operation' | 'tool' | 'message' | 'error' | 'delegation' | 'task';
|
|
16
41
|
|
|
42
|
+
/**
|
|
43
|
+
* A band of related lanes, enclosed by one rectangle. Pure structure: the lanes inside carry the
|
|
44
|
+
* labels, so a group needs no name of its own.
|
|
45
|
+
*/
|
|
46
|
+
export type GanttGroup = {
|
|
47
|
+
id: string;
|
|
48
|
+
/** Indents under another group; a nested group's band is drawn after its parent's. */
|
|
49
|
+
parentId?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** One stretch during which a lane was worked. `end` is absent while the stretch is still open. */
|
|
53
|
+
export type GanttSegment = {
|
|
54
|
+
start: number;
|
|
55
|
+
end?: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** A fact the host wants beside a lane: the legend shows it in place of, or over, the lane's title, and never interprets it. */
|
|
59
|
+
export type GanttMeta = {
|
|
60
|
+
label: string;
|
|
61
|
+
title?: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One row. Its three references are deliberately distinct: `groupId` is which band it sits in,
|
|
66
|
+
* `parentId` is what it nests under, and `openedFrom` is what caused it. Collapsing any two of them
|
|
67
|
+
* into one edge is what left the previous model unable to hold a task hierarchy and a process tree
|
|
68
|
+
* at the same time.
|
|
69
|
+
*/
|
|
17
70
|
export type GanttLane = {
|
|
18
71
|
id: string;
|
|
19
|
-
kind: GanttLaneKind;
|
|
20
72
|
label: string;
|
|
21
73
|
status: GanttLaneStatus;
|
|
22
|
-
|
|
23
|
-
|
|
74
|
+
/** The band this lane sits in. */
|
|
75
|
+
groupId?: string;
|
|
76
|
+
/** Indents under another lane of the same group — nesting, nothing more. */
|
|
24
77
|
parentId?: string;
|
|
25
|
-
|
|
78
|
+
/** Every stretch this lane was worked. A lane with none exists but has no extent, so no bar. */
|
|
79
|
+
segments?: readonly GanttSegment[];
|
|
80
|
+
/** The node this lane opened out of, on another lane. */
|
|
81
|
+
openedFrom?: { laneId: string; markerId: string };
|
|
82
|
+
/**
|
|
83
|
+
* The node this lane's result came back into, on another lane — the mirror of `openedFrom`, and a
|
|
84
|
+
* separate fact: a lane can open out of another and end without ever reporting back.
|
|
85
|
+
*/
|
|
86
|
+
closedInto?: { laneId: string; markerId: string };
|
|
87
|
+
/** Colours the legend dot, the bar, its nodes and its thread in place of the status colour. */
|
|
88
|
+
hue?: Hue;
|
|
89
|
+
/** Lanes this one waits on. */
|
|
26
90
|
blockedOn?: readonly string[];
|
|
27
|
-
|
|
28
|
-
tokens?: { input: number; output: number; total: number };
|
|
29
|
-
toolCalls?: number;
|
|
91
|
+
meta?: readonly GanttMeta[];
|
|
30
92
|
};
|
|
31
93
|
|
|
32
94
|
export type GanttMarker = {
|
|
33
95
|
id: string;
|
|
34
96
|
laneId: string;
|
|
35
|
-
|
|
97
|
+
/** The host's own word for what happened; shown in the hover card, never interpreted. */
|
|
98
|
+
kind?: string;
|
|
36
99
|
timestamp: number;
|
|
37
100
|
label: string;
|
|
38
101
|
level?: 'info' | 'warn' | 'error';
|
|
102
|
+
/**
|
|
103
|
+
* A wait begun at this node and since ended — a question put to the reader and answered. Drawn as a
|
|
104
|
+
* dashed line along the lane to the node that ended it; a wait still open draws nothing, so the lane
|
|
105
|
+
* ends at the question until the answer lands.
|
|
106
|
+
*/
|
|
107
|
+
wait?: { until: string };
|
|
108
|
+
/** Awaiting a response — an unanswered question. The node pings until the response arrives. */
|
|
109
|
+
pending?: boolean;
|
|
39
110
|
};
|
|
40
111
|
|
|
41
|
-
const
|
|
112
|
+
const PAD_X = 16;
|
|
113
|
+
const ROW_HEIGHT = 24;
|
|
42
114
|
const HEADER_HEIGHT = 20;
|
|
43
|
-
const PAD_X = 12;
|
|
44
|
-
const NODE_RADIUS = 5;
|
|
45
|
-
/** Upper bound on axis ticks; the count shrinks with the width so `HH:mm:ss` labels never overlap. */
|
|
46
|
-
const MAX_TICKS = 5;
|
|
47
|
-
const TICK_MIN_WIDTH = 72;
|
|
48
115
|
|
|
116
|
+
/** Pixels per event on the `unit` axis — wide enough that two adjacent nodes read as two. */
|
|
117
|
+
const UNIT_STEP = 32;
|
|
118
|
+
/** How long a newly arrived element takes to travel from where it came from to where it belongs. */
|
|
119
|
+
const ENTER_TRANSITION = 'duration-500 ease-out';
|
|
120
|
+
|
|
121
|
+
/** Within this of the live edge the chart keeps following it; past it the reader is reading history. */
|
|
122
|
+
const FOLLOW_SLACK = 4;
|
|
123
|
+
/**
|
|
124
|
+
* How long the chart takes to drift to the live edge. Longer than the enter transition on purpose:
|
|
125
|
+
* the arriving event settles into place first, and the window follows it rather than racing it.
|
|
126
|
+
*/
|
|
127
|
+
const FOLLOW_DURATION = 600;
|
|
128
|
+
|
|
129
|
+
const NODE_RADIUS = 6;
|
|
49
130
|
const BAR_HEIGHT = 15;
|
|
131
|
+
/** The thread through a lane's nodes: a hairline, so the nodes remain what the row reads as. */
|
|
132
|
+
const THREAD_HEIGHT = 1;
|
|
50
133
|
/**
|
|
51
134
|
* A bar reaches half its height past its first and last instant, so a node sitting on either one is
|
|
52
135
|
* as far from the bar's end as it is from its top and bottom.
|
|
53
136
|
*/
|
|
54
137
|
const BAR_OVERHANG = BAR_HEIGHT / 2;
|
|
55
|
-
|
|
138
|
+
|
|
139
|
+
/** Gap between adjacent band rectangles, halved on each. */
|
|
56
140
|
const GROUP_INSET = 2;
|
|
57
141
|
/** The rectangle's clearance around its bars, the same on every side, so its corners stay concentric with theirs. */
|
|
58
142
|
const GROUP_PAD = ROW_HEIGHT / 2 - BAR_HEIGHT / 2 - GROUP_INSET;
|
|
59
143
|
const GROUP_RADIUS = BAR_HEIGHT / 2 + GROUP_PAD;
|
|
60
|
-
|
|
144
|
+
|
|
145
|
+
/** Radius of the connector's bend from the drop into the lane it opens. */
|
|
61
146
|
const BEND_RADIUS = BAR_HEIGHT / 2;
|
|
62
147
|
|
|
63
148
|
/**
|
|
64
149
|
* Bar, node, thread and label colours per status: nodes and the thread through them are the bar's
|
|
65
|
-
* hue in a lighter shade
|
|
150
|
+
* hue in a lighter shade, and `edge` — the live end of a lane still running — is the stronger shade
|
|
151
|
+
* the legend's own status dot uses, so the two read as the same statement about the lane.
|
|
152
|
+
*/
|
|
153
|
+
/**
|
|
154
|
+
* A hued lane's colour: the theme's tag surface token, read as a variable because the hue is data, so
|
|
155
|
+
* the lane matches a `Tag` of the same hue.
|
|
66
156
|
*/
|
|
67
|
-
const
|
|
157
|
+
const hueColor = (hue: Hue): string => `var(--color-${hue}-surface)`;
|
|
158
|
+
|
|
159
|
+
/** The hue's strong shade, for a node that needs attention (a question, an error) within its lane. */
|
|
160
|
+
const hueStrongColor = (hue: Hue): string => `var(--color-${hue}-text)`;
|
|
161
|
+
|
|
162
|
+
const STATUS_COLOR: Record<
|
|
163
|
+
GanttLaneStatus,
|
|
164
|
+
{ fill: string; node: string; edge: string; thread: string; text: string }
|
|
165
|
+
> = {
|
|
68
166
|
pending: {
|
|
69
167
|
fill: 'fill-neutral-500/40',
|
|
70
168
|
node: 'fill-neutral-300',
|
|
169
|
+
edge: 'fill-neutral-400',
|
|
71
170
|
thread: 'stroke-neutral-300',
|
|
72
171
|
text: 'text-neutral-400',
|
|
73
172
|
},
|
|
74
173
|
blocked: {
|
|
75
174
|
fill: 'fill-orange-500/40',
|
|
76
175
|
node: 'fill-orange-300',
|
|
176
|
+
edge: 'fill-orange-500',
|
|
77
177
|
thread: 'stroke-orange-300',
|
|
78
178
|
text: 'text-orange-500',
|
|
79
179
|
},
|
|
80
180
|
running: {
|
|
81
181
|
fill: 'fill-sky-500/40',
|
|
82
182
|
node: 'fill-sky-300',
|
|
183
|
+
edge: 'fill-sky-500',
|
|
83
184
|
thread: 'stroke-sky-300',
|
|
84
185
|
text: 'text-sky-500',
|
|
85
186
|
},
|
|
86
187
|
review: {
|
|
87
188
|
fill: 'fill-cyan-500/40',
|
|
88
189
|
node: 'fill-cyan-300',
|
|
190
|
+
edge: 'fill-cyan-500',
|
|
89
191
|
thread: 'stroke-cyan-300',
|
|
90
192
|
text: 'text-cyan-500',
|
|
91
193
|
},
|
|
92
194
|
done: {
|
|
93
195
|
fill: 'fill-green-500/40',
|
|
94
196
|
node: 'fill-green-300',
|
|
197
|
+
edge: 'fill-green-500',
|
|
95
198
|
thread: 'stroke-green-300',
|
|
96
199
|
text: 'text-green-500',
|
|
97
200
|
},
|
|
98
201
|
failed: {
|
|
99
202
|
fill: 'fill-red-500/40',
|
|
100
203
|
node: 'fill-red-300',
|
|
204
|
+
edge: 'fill-red-500',
|
|
101
205
|
thread: 'stroke-red-300',
|
|
102
206
|
text: 'text-red-500',
|
|
103
207
|
},
|
|
104
208
|
};
|
|
105
209
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
depth: number;
|
|
109
|
-
index: number;
|
|
110
|
-
sessionId: string | undefined;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
/** A session's rows: the session itself followed by every task it works in-session, contiguous. */
|
|
114
|
-
type SessionGroup = {
|
|
115
|
-
session: GanttLane;
|
|
116
|
-
first: number;
|
|
117
|
-
last: number;
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Rows grouped by the session that processes them: a session, then the tasks (and subtasks) it
|
|
122
|
-
* works itself, then — after that block — every session those tasks spawned, recursively. Keeping a
|
|
123
|
-
* session's own rows contiguous is what lets one rectangle enclose them.
|
|
124
|
-
*/
|
|
125
|
-
const orderRows = (lanes: readonly GanttLane[]): { rows: Row[]; groups: SessionGroup[] } => {
|
|
126
|
-
const rows: Row[] = [];
|
|
127
|
-
const groups: SessionGroup[] = [];
|
|
128
|
-
const childrenOf = (parentId: string | undefined): GanttLane[] => lanes.filter((lane) => lane.parentId === parentId);
|
|
129
|
-
|
|
130
|
-
const visitSession = (session: GanttLane, depth: number): void => {
|
|
131
|
-
const first = rows.length;
|
|
132
|
-
rows.push({ lane: session, depth, index: rows.length, sessionId: session.id });
|
|
133
|
-
const spawned: { lane: GanttLane; depth: number }[] = [];
|
|
134
|
-
const visitTasks = (parentId: string, taskDepth: number): void => {
|
|
135
|
-
for (const child of childrenOf(parentId)) {
|
|
136
|
-
if (child.kind === 'task') {
|
|
137
|
-
rows.push({ lane: child, depth: taskDepth, index: rows.length, sessionId: session.id });
|
|
138
|
-
visitTasks(child.id, taskDepth + 1);
|
|
139
|
-
} else {
|
|
140
|
-
spawned.push({ lane: child, depth: taskDepth });
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
};
|
|
144
|
-
visitTasks(session.id, depth + 1);
|
|
145
|
-
groups.push({ session, first, last: rows.length - 1 });
|
|
146
|
-
for (const child of spawned) {
|
|
147
|
-
visitSession(child.lane, child.depth);
|
|
148
|
-
}
|
|
149
|
-
};
|
|
210
|
+
/** A node that asks for attention: a question put to the reader, or something that went wrong. */
|
|
211
|
+
const isAlert = (marker: GanttMarker): boolean => marker.level === 'warn' || marker.level === 'error';
|
|
150
212
|
|
|
151
|
-
|
|
152
|
-
if (lane.kind === 'session') {
|
|
153
|
-
visitSession(lane, 0);
|
|
154
|
-
} else {
|
|
155
|
-
rows.push({ lane, depth: 0, index: rows.length, sessionId: undefined });
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return { rows, groups };
|
|
159
|
-
};
|
|
213
|
+
const CONNECTOR_CLASSNAME = 'stroke-fuchsia-500';
|
|
160
214
|
|
|
161
|
-
|
|
162
|
-
|
|
215
|
+
/** What the legend's column shows per lane: its title, or the facts the host attached to it. */
|
|
216
|
+
export type GanttLegendMode = 'title' | 'stats';
|
|
163
217
|
|
|
164
218
|
export type GanttData = {
|
|
219
|
+
/** The bands. A chart with none draws its lanes plain, with no rectangles. */
|
|
220
|
+
groups?: readonly GanttGroup[];
|
|
165
221
|
lanes: readonly GanttLane[];
|
|
166
222
|
markers?: readonly GanttMarker[];
|
|
167
223
|
range?: { start: number; end: number };
|
|
168
224
|
now?: number;
|
|
169
225
|
showNow?: boolean;
|
|
226
|
+
/**
|
|
227
|
+
* What the horizontal axis measures: `unit` steps once per event and scrolls to follow the newest;
|
|
228
|
+
* `time` fits the whole range to the width and eases to new bounds. `unit` by default.
|
|
229
|
+
*/
|
|
230
|
+
axis?: GanttAxis;
|
|
231
|
+
/** Pixels per event on the `unit` axis. */
|
|
232
|
+
unitStep?: number;
|
|
233
|
+
/**
|
|
234
|
+
* Slide a newly arrived event out of the one before it — out of the node that opened its lane, for
|
|
235
|
+
* a lane's first — and grow its bar to meet it, on the `unit` axis. Only events that arrive while the
|
|
236
|
+
* chart is on screen animate, so a chart that is merely mounted is still.
|
|
237
|
+
*/
|
|
238
|
+
animate?: boolean;
|
|
170
239
|
onLaneSelect?: (lane: GanttLane) => void;
|
|
171
240
|
onMarkerSelect?: (marker: GanttMarker) => void;
|
|
241
|
+
/** Called by `Gantt.AxisToggle`; without it the toggle does not render. */
|
|
242
|
+
onAxisChange?: (axis: GanttAxis) => void;
|
|
243
|
+
/** What the legend's column shows; held by the root when absent, `title` to begin with. */
|
|
244
|
+
legend?: GanttLegendMode;
|
|
245
|
+
/** Called by `Gantt.LegendToggle`. */
|
|
246
|
+
onLegendChange?: (legend: GanttLegendMode) => void;
|
|
172
247
|
};
|
|
173
248
|
|
|
174
|
-
/** What every part reads: the ordered rows and the shared
|
|
249
|
+
/** What every part reads: the ordered rows and the shared axis, resolved once by the root. */
|
|
175
250
|
type GanttContextValue = {
|
|
176
251
|
rows: Row[];
|
|
177
|
-
|
|
252
|
+
bands: Band[];
|
|
178
253
|
rowById: Map<string, Row>;
|
|
179
254
|
markers: readonly GanttMarker[];
|
|
180
255
|
markerById: Map<string, GanttMarker>;
|
|
181
256
|
range: { start: number; end: number };
|
|
182
|
-
|
|
257
|
+
legend: GanttLegendMode;
|
|
258
|
+
setLegend: (legend: GanttLegendMode) => void;
|
|
259
|
+
/** The one scrolling element: lanes scroll vertically under the axis, the drawing horizontally beside the legend. */
|
|
260
|
+
viewport: HTMLDivElement | null;
|
|
261
|
+
/** Reports the sticky legend's width, which the horizontal bar keeps clear of since the legend does not scroll. */
|
|
262
|
+
setLegendWidth: (width: number) => void;
|
|
263
|
+
} & Pick<
|
|
264
|
+
GanttData,
|
|
265
|
+
'onLaneSelect' | 'onMarkerSelect' | 'onAxisChange' | 'now' | 'showNow' | 'axis' | 'unitStep' | 'animate'
|
|
266
|
+
>;
|
|
183
267
|
|
|
184
268
|
const [GanttProvider, useGanttContext] = createContext<GanttContextValue>('Gantt');
|
|
185
269
|
|
|
@@ -187,27 +271,61 @@ const [GanttProvider, useGanttContext] = createContext<GanttContextValue>('Gantt
|
|
|
187
271
|
// Root
|
|
188
272
|
//
|
|
189
273
|
|
|
190
|
-
type GanttRootProps = ThemedClassName<GanttData & { children?: ReactNode }>;
|
|
274
|
+
type GanttRootProps = Util.ThemedClassName<GanttData & { children?: ReactNode }>;
|
|
191
275
|
|
|
192
276
|
/**
|
|
193
|
-
* Gantt view of
|
|
194
|
-
* the parts lay out side by side in the order given, sharing one row grid: `Legend` names the
|
|
195
|
-
*
|
|
196
|
-
*
|
|
277
|
+
* Gantt view of lanes on a shared axis. The root resolves the rows and the axis and fills its host;
|
|
278
|
+
* the parts lay out side by side in the order given, sharing one row grid: `Legend` names the lanes
|
|
279
|
+
* and `Chart` draws them. A host that already lists the lanes renders the chart alone.
|
|
280
|
+
*
|
|
281
|
+
* The root owns the scrolling, on both axes at once: the legend's rows and the chart's move together
|
|
282
|
+
* vertically, the legend stays put while the drawing scrolls horizontally, and the horizontal bar is
|
|
283
|
+
* at the foot of the host rather than under the last lane.
|
|
197
284
|
*/
|
|
198
|
-
const GanttRoot = composable<HTMLDivElement, GanttRootProps>(
|
|
285
|
+
const GanttRoot = Util.composable<HTMLDivElement, GanttRootProps>(
|
|
199
286
|
(
|
|
200
|
-
{
|
|
287
|
+
{
|
|
288
|
+
groups = [],
|
|
289
|
+
lanes,
|
|
290
|
+
markers = [],
|
|
291
|
+
range: rangeProp,
|
|
292
|
+
now,
|
|
293
|
+
showNow,
|
|
294
|
+
axis = 'unit',
|
|
295
|
+
unitStep,
|
|
296
|
+
animate,
|
|
297
|
+
onLaneSelect,
|
|
298
|
+
onMarkerSelect,
|
|
299
|
+
onAxisChange,
|
|
300
|
+
legend: legendProp,
|
|
301
|
+
onLegendChange,
|
|
302
|
+
children,
|
|
303
|
+
...props
|
|
304
|
+
},
|
|
201
305
|
forwardedRef,
|
|
202
306
|
) => {
|
|
203
|
-
const
|
|
307
|
+
const [legendState, setLegendState] = useState<GanttLegendMode>('title');
|
|
308
|
+
const legend = legendProp ?? legendState;
|
|
309
|
+
const setLegend = useCallback(
|
|
310
|
+
(next: GanttLegendMode) => {
|
|
311
|
+
setLegendState(next);
|
|
312
|
+
onLegendChange?.(next);
|
|
313
|
+
},
|
|
314
|
+
[onLegendChange],
|
|
315
|
+
);
|
|
316
|
+
const [viewport, setViewport] = useState<HTMLDivElement | null>(null);
|
|
317
|
+
const [legendWidth, setLegendWidth] = useState(0);
|
|
318
|
+
const { rows, bands } = useMemo(() => orderRows(groups, lanes), [groups, lanes]);
|
|
204
319
|
const rowById = useMemo(() => new Map(rows.map((row) => [row.lane.id, row])), [rows]);
|
|
205
320
|
const markerById = useMemo(() => new Map(markers.map((marker) => [marker.id, marker])), [markers]);
|
|
206
321
|
const range = useMemo(() => {
|
|
207
322
|
if (rangeProp) {
|
|
208
323
|
return rangeProp;
|
|
209
324
|
}
|
|
210
|
-
const times = [
|
|
325
|
+
const times = [
|
|
326
|
+
...lanes.flatMap((lane) => (lane.segments ?? []).flatMap((segment) => [segment.start, segment.end])),
|
|
327
|
+
...markers.map((marker) => marker.timestamp),
|
|
328
|
+
]
|
|
211
329
|
.filter((time): time is number => time !== undefined)
|
|
212
330
|
.concat(now === undefined ? [] : [now]);
|
|
213
331
|
const start = times.length > 0 ? Math.min(...times) : 0;
|
|
@@ -217,25 +335,35 @@ const GanttRoot = composable<HTMLDivElement, GanttRootProps>(
|
|
|
217
335
|
return (
|
|
218
336
|
<GanttProvider
|
|
219
337
|
rows={rows}
|
|
220
|
-
|
|
338
|
+
bands={bands}
|
|
221
339
|
rowById={rowById}
|
|
222
340
|
markers={markers}
|
|
223
341
|
markerById={markerById}
|
|
224
342
|
range={range}
|
|
225
343
|
now={now}
|
|
226
344
|
showNow={showNow}
|
|
345
|
+
axis={axis}
|
|
346
|
+
unitStep={unitStep}
|
|
347
|
+
animate={animate}
|
|
227
348
|
onLaneSelect={onLaneSelect}
|
|
228
349
|
onMarkerSelect={onMarkerSelect}
|
|
350
|
+
onAxisChange={onAxisChange}
|
|
351
|
+
legend={legend}
|
|
352
|
+
setLegend={setLegend}
|
|
353
|
+
viewport={viewport}
|
|
354
|
+
setLegendWidth={setLegendWidth}
|
|
229
355
|
>
|
|
230
|
-
<
|
|
231
|
-
{...composableProps(props, {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
})}
|
|
356
|
+
<ScrollArea.Root
|
|
357
|
+
{...Util.composableProps(props, { classNames: 'dx-expand text-xs font-mono' })}
|
|
358
|
+
orientation='all'
|
|
359
|
+
trackStart={legendWidth}
|
|
235
360
|
ref={forwardedRef}
|
|
236
361
|
>
|
|
237
|
-
{
|
|
238
|
-
|
|
362
|
+
{/* `items-start`: the parts are drawn in pixel rows, so a stretching host must not spread them. */}
|
|
363
|
+
<ScrollArea.Viewport classNames='flex items-start' ref={setViewport}>
|
|
364
|
+
{children}
|
|
365
|
+
</ScrollArea.Viewport>
|
|
366
|
+
</ScrollArea.Root>
|
|
239
367
|
</GanttProvider>
|
|
240
368
|
);
|
|
241
369
|
},
|
|
@@ -247,25 +375,54 @@ GanttRoot.displayName = 'Gantt.Root';
|
|
|
247
375
|
// Legend
|
|
248
376
|
//
|
|
249
377
|
|
|
250
|
-
type GanttLegendProps = ThemedClassName<
|
|
378
|
+
type GanttLegendProps = Util.ThemedClassName<PropsWithChildren>;
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The lane names, one per row, indented by depth; each row is the lane's keyboard path. Children go
|
|
382
|
+
* in the header row above the names, beside the chart's axis labels (e.g. `Gantt.AxisToggle`).
|
|
383
|
+
*
|
|
384
|
+
* The column shows each lane's title or its stats (`legend` on the root), and whichever it is not
|
|
385
|
+
* showing is in a hover card over the row, so neither costs a column of its own.
|
|
386
|
+
*/
|
|
387
|
+
const GanttLegend = Util.composable<HTMLDivElement, GanttLegendProps>(({ children, ...props }, forwardedRef) => {
|
|
388
|
+
const { rows, legend, onLaneSelect, setLegendWidth } = useGanttContext('Gantt.Legend');
|
|
389
|
+
const legendRef = useRef<HTMLDivElement>(null);
|
|
390
|
+
const ref = useComposedRefs(forwardedRef, legendRef);
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
const element = legendRef.current;
|
|
393
|
+
if (!element) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const observer = new ResizeObserver(() => setLegendWidth(element.offsetWidth));
|
|
397
|
+
observer.observe(element);
|
|
398
|
+
return () => {
|
|
399
|
+
observer.disconnect();
|
|
400
|
+
setLegendWidth(0);
|
|
401
|
+
};
|
|
402
|
+
}, [setLegendWidth]);
|
|
251
403
|
|
|
252
|
-
/** The lane names, one per row, indented by depth; each row is the lane's keyboard path. */
|
|
253
|
-
const GanttLegend = composable<HTMLDivElement, GanttLegendProps>((props, forwardedRef) => {
|
|
254
|
-
const { rows, onLaneSelect } = useGanttContext('Gantt.Legend');
|
|
255
404
|
return (
|
|
256
405
|
<div
|
|
257
|
-
{...composableProps(props, {
|
|
258
|
-
|
|
406
|
+
{...Util.composableProps(props, {
|
|
407
|
+
// Sticky and opaque: the drawing scrolls horizontally beneath it. Sized against the scroll
|
|
408
|
+
// frame (`cqw`) rather than its parent, whose width is the drawing's.
|
|
409
|
+
classNames:
|
|
410
|
+
'sticky left-0 z-[1] shrink-0 w-[min(15rem,20cqw)] min-w-40 flex flex-col font-sans bg-(--surface-bg)',
|
|
411
|
+
})}
|
|
412
|
+
ref={ref}
|
|
259
413
|
>
|
|
260
|
-
<div style={{ height: HEADER_HEIGHT }}
|
|
414
|
+
<div className='flex items-center' style={{ height: HEADER_HEIGHT }}>
|
|
415
|
+
{children}
|
|
416
|
+
</div>
|
|
261
417
|
{rows.map(({ lane, depth }) => (
|
|
262
418
|
<div
|
|
263
419
|
key={lane.id}
|
|
264
420
|
className={mx(
|
|
265
|
-
'flex items-center gap-2
|
|
421
|
+
'flex items-center gap-2 min-w-0',
|
|
266
422
|
onLaneSelect && 'cursor-pointer hover:bg-hover-surface-subtle',
|
|
267
423
|
)}
|
|
268
424
|
style={{ height: ROW_HEIGHT, paddingInlineStart: `${0.5 + depth}rem` }}
|
|
425
|
+
data-testid='gantt.legend.row'
|
|
269
426
|
// Button semantics only when there is something to select: the label row is the lane's
|
|
270
427
|
// keyboard path, and an inert focus stop would be noise.
|
|
271
428
|
{...(onLaneSelect && {
|
|
@@ -280,8 +437,11 @@ const GanttLegend = composable<HTMLDivElement, GanttLegendProps>((props, forward
|
|
|
280
437
|
},
|
|
281
438
|
})}
|
|
282
439
|
>
|
|
283
|
-
<span
|
|
284
|
-
|
|
440
|
+
<span
|
|
441
|
+
className={mx('shrink-0 w-2 h-2 rounded-full bg-current', !lane.hue && STATUS_COLOR[lane.status].text)}
|
|
442
|
+
style={lane.hue ? { color: hueColor(lane.hue) } : undefined}
|
|
443
|
+
/>
|
|
444
|
+
<GanttLaneLabel lane={lane} legend={legend} />
|
|
285
445
|
</div>
|
|
286
446
|
))}
|
|
287
447
|
</div>
|
|
@@ -290,116 +450,532 @@ const GanttLegend = composable<HTMLDivElement, GanttLegendProps>((props, forward
|
|
|
290
450
|
|
|
291
451
|
GanttLegend.displayName = 'Gantt.Legend';
|
|
292
452
|
|
|
453
|
+
/** A legend row's text, and a hover card holding what the column is not showing. */
|
|
454
|
+
const GanttLaneLabel = ({ lane, legend }: { lane: GanttLane; legend: GanttLegendMode }) => {
|
|
455
|
+
const meta = lane.meta ?? [];
|
|
456
|
+
const title = (
|
|
457
|
+
<span className='truncate text-fg' data-testid='gantt.legend.title'>
|
|
458
|
+
{lane.label}
|
|
459
|
+
</span>
|
|
460
|
+
);
|
|
461
|
+
// Grows across the row so a lane with no stats still has something to hover for its title.
|
|
462
|
+
const stats = (
|
|
463
|
+
<span
|
|
464
|
+
className='grow flex gap-2 truncate text-fg-muted whitespace-nowrap tabular-nums'
|
|
465
|
+
data-testid='gantt.legend.stats'
|
|
466
|
+
>
|
|
467
|
+
{meta.map(({ label }, index) => (
|
|
468
|
+
<span key={index}>{label}</span>
|
|
469
|
+
))}
|
|
470
|
+
</span>
|
|
471
|
+
);
|
|
472
|
+
if (legend === 'title' && meta.length === 0) {
|
|
473
|
+
return title;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return (
|
|
477
|
+
<HoverCard.Root>
|
|
478
|
+
<HoverCard.Trigger asChild>{legend === 'title' ? title : stats}</HoverCard.Trigger>
|
|
479
|
+
<HoverCard.Content classNames='p-2 max-w-72 text-xs' data-testid='gantt.legend.card'>
|
|
480
|
+
{legend === 'title' ? (
|
|
481
|
+
meta.map(({ label, title }, index) => (
|
|
482
|
+
<div key={index} className='tabular-nums'>
|
|
483
|
+
{label}
|
|
484
|
+
{title && <span className='text-fg-muted'>{` · ${title}`}</span>}
|
|
485
|
+
</div>
|
|
486
|
+
))
|
|
487
|
+
) : (
|
|
488
|
+
<div className='font-medium'>{lane.label}</div>
|
|
489
|
+
)}
|
|
490
|
+
</HoverCard.Content>
|
|
491
|
+
</HoverCard.Root>
|
|
492
|
+
);
|
|
493
|
+
};
|
|
494
|
+
|
|
293
495
|
//
|
|
294
|
-
//
|
|
496
|
+
// AxisToggle
|
|
295
497
|
//
|
|
296
498
|
|
|
297
|
-
type
|
|
499
|
+
type GanttAxisToggleProps = {};
|
|
298
500
|
|
|
299
|
-
/**
|
|
300
|
-
|
|
301
|
-
|
|
501
|
+
/**
|
|
502
|
+
* Switches the chart between its axes: `time`, the run fitted to the width, and `unit`, one step per
|
|
503
|
+
* event. Renders nothing unless the root was given `onAxisChange`, since the axis is the host's state.
|
|
504
|
+
*/
|
|
505
|
+
const GanttAxisToggle = (_: GanttAxisToggleProps) => {
|
|
506
|
+
const { t } = Hooks.useTranslation(translationKey);
|
|
507
|
+
const { axis = 'unit', onAxisChange } = useGanttContext('Gantt.AxisToggle');
|
|
508
|
+
if (!onAxisChange) {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// The icon names the axis in use; the label names the one a click switches to.
|
|
302
513
|
return (
|
|
303
|
-
<
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
)}
|
|
314
|
-
{lane.toolCalls !== undefined && <span>{lane.toolCalls} tools</span>}
|
|
315
|
-
</div>
|
|
316
|
-
))}
|
|
317
|
-
</div>
|
|
514
|
+
<Button.Root
|
|
515
|
+
variant='ghost'
|
|
516
|
+
size='sm'
|
|
517
|
+
iconSize='xs'
|
|
518
|
+
iconOnly
|
|
519
|
+
icon={axis === 'time' ? 'ph--clock--regular' : 'ph--dots-three-outline--regular'}
|
|
520
|
+
label={t(axis === 'time' ? 'gantt-axis-unit.label' : 'gantt-axis-time.label')}
|
|
521
|
+
onClick={() => onAxisChange(axis === 'time' ? 'unit' : 'time')}
|
|
522
|
+
data-testid='gantt.axisToggle'
|
|
523
|
+
/>
|
|
318
524
|
);
|
|
319
|
-
}
|
|
525
|
+
};
|
|
320
526
|
|
|
321
|
-
|
|
527
|
+
GanttAxisToggle.displayName = 'Gantt.AxisToggle';
|
|
528
|
+
|
|
529
|
+
//
|
|
530
|
+
// LegendToggle
|
|
531
|
+
//
|
|
532
|
+
|
|
533
|
+
type GanttLegendToggleProps = {};
|
|
534
|
+
|
|
535
|
+
/** Switches the legend's column between the lanes' titles and their stats. */
|
|
536
|
+
const GanttLegendToggle = (_: GanttLegendToggleProps) => {
|
|
537
|
+
const { t } = Hooks.useTranslation(translationKey);
|
|
538
|
+
const { legend, setLegend } = useGanttContext('Gantt.LegendToggle');
|
|
539
|
+
|
|
540
|
+
// As the axis toggle: the icon names what is shown, the label what a click shows instead.
|
|
541
|
+
return (
|
|
542
|
+
<Button.Root
|
|
543
|
+
variant='ghost'
|
|
544
|
+
size='sm'
|
|
545
|
+
iconSize='xs'
|
|
546
|
+
iconOnly
|
|
547
|
+
icon={legend === 'title' ? 'ph--text-aa--regular' : 'ph--chart-bar--regular'}
|
|
548
|
+
label={t(legend === 'title' ? 'gantt-legend-stats.label' : 'gantt-legend-title.label')}
|
|
549
|
+
onClick={() => setLegend(legend === 'title' ? 'stats' : 'title')}
|
|
550
|
+
data-testid='gantt.legendToggle'
|
|
551
|
+
/>
|
|
552
|
+
);
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
GanttLegendToggle.displayName = 'Gantt.LegendToggle';
|
|
322
556
|
|
|
323
557
|
//
|
|
324
558
|
// Chart
|
|
325
559
|
//
|
|
326
560
|
|
|
327
|
-
type GanttChartProps = ThemedClassName<{}>;
|
|
561
|
+
type GanttChartProps = Util.ThemedClassName<{}>;
|
|
562
|
+
|
|
563
|
+
/** One drawn stretch of a lane: where its bar runs, and the nodes threaded through it. */
|
|
564
|
+
type Stretch = {
|
|
565
|
+
/** The instants the segment spans, in pixels. */
|
|
566
|
+
from: number;
|
|
567
|
+
to: number;
|
|
568
|
+
/** The drawn positions of the first and last node inside it; absent when it holds fewer than two. */
|
|
569
|
+
threadFrom?: number;
|
|
570
|
+
threadTo?: number;
|
|
571
|
+
};
|
|
328
572
|
|
|
329
573
|
/**
|
|
330
|
-
* The drawing:
|
|
331
|
-
* rectangle encloses
|
|
574
|
+
* The drawing: a lane is a rounded bar per segment with its markers threaded through as nodes, a
|
|
575
|
+
* group's rectangle encloses its band, and connectors draw dependencies and the node a lane opened
|
|
576
|
+
* out of.
|
|
577
|
+
*
|
|
578
|
+
* On the `unit` axis the drawing is as wide as the events need and follows the newest of them in the
|
|
579
|
+
* root's scroll frame, while the legend beside it stays where it is.
|
|
580
|
+
*
|
|
581
|
+
* The drawing is one keyboard stop: clicking a node focuses it, the arrows walk the nodes (left and
|
|
582
|
+
* right along a lane, up and down to the nearest node of the next lane), Space opens the current
|
|
583
|
+
* node's card and Enter selects it — the same keys a legend row answers to.
|
|
332
584
|
*/
|
|
333
|
-
// `forwardRef` rather than `composable`: the drawing is an `svg`, which the HTML-element helpers do not type.
|
|
334
585
|
const GanttChart = forwardRef<SVGSVGElement, GanttChartProps>(({ classNames }, forwardedRef) => {
|
|
335
|
-
const {
|
|
336
|
-
|
|
586
|
+
const {
|
|
587
|
+
rows,
|
|
588
|
+
bands,
|
|
589
|
+
rowById,
|
|
590
|
+
markers,
|
|
591
|
+
markerById,
|
|
592
|
+
range,
|
|
593
|
+
now,
|
|
594
|
+
showNow,
|
|
595
|
+
axis = 'unit',
|
|
596
|
+
unitStep = UNIT_STEP,
|
|
597
|
+
animate,
|
|
598
|
+
onLaneSelect,
|
|
599
|
+
onMarkerSelect,
|
|
600
|
+
viewport,
|
|
601
|
+
} = useGanttContext('Gantt.Chart');
|
|
602
|
+
const { t } = Hooks.useTranslation(translationKey);
|
|
337
603
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
338
|
-
const ref = useComposedRefs(
|
|
604
|
+
const ref = useComposedRefs(forwardedRef, svgRef);
|
|
605
|
+
const nodeIdPrefix = useId();
|
|
606
|
+
/** The node the keyboard is on, and the node whose card is open (by hover or by Space). */
|
|
607
|
+
const [currentId, setCurrentId] = useState<string>();
|
|
608
|
+
const [openId, setOpenId] = useState<string>();
|
|
609
|
+
const followRef = useRef(true);
|
|
610
|
+
const glideRef = useRef(false);
|
|
611
|
+
const frameRef = useRef<number | undefined>(undefined);
|
|
612
|
+
const drawnRef = useRef<number | undefined>(undefined);
|
|
339
613
|
const [width, setWidth] = useState(600);
|
|
340
614
|
useEffect(() => {
|
|
341
|
-
const element =
|
|
615
|
+
const element = viewport;
|
|
342
616
|
if (!element) {
|
|
343
617
|
return;
|
|
344
618
|
}
|
|
345
|
-
|
|
619
|
+
// The time axis is fitted to what the viewport shows beside the legend, not to the drawing, which may be wider.
|
|
620
|
+
const measure = (): void => {
|
|
621
|
+
const svg = svgRef.current;
|
|
622
|
+
const offset = svg
|
|
623
|
+
? svg.getBoundingClientRect().left - element.getBoundingClientRect().left + element.scrollLeft
|
|
624
|
+
: 0;
|
|
625
|
+
setWidth(Math.max(element.clientWidth - offset, 0));
|
|
626
|
+
};
|
|
627
|
+
const observer = new ResizeObserver(measure);
|
|
346
628
|
observer.observe(element);
|
|
347
|
-
|
|
348
|
-
|
|
629
|
+
// Listened for rather than taken as a prop: `ScrollArea.Viewport` accepts only what it slots.
|
|
630
|
+
const onScroll = (): void => {
|
|
631
|
+
const atEdge = element.scrollWidth - element.clientWidth - element.scrollLeft <= FOLLOW_SLACK;
|
|
632
|
+
// A glide of our own emits scroll events all the way there, every one of them short of the
|
|
633
|
+
// edge. Reading those as the reader scrolling away would unpin the chart on the very frame it
|
|
634
|
+
// set out to follow; only its arrival is worth acting on.
|
|
635
|
+
if (glideRef.current) {
|
|
636
|
+
glideRef.current = !atEdge;
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
followRef.current = atEdge;
|
|
640
|
+
};
|
|
641
|
+
element.addEventListener('scroll', onScroll, { passive: true });
|
|
642
|
+
return () => {
|
|
643
|
+
observer.disconnect();
|
|
644
|
+
element.removeEventListener('scroll', onScroll);
|
|
645
|
+
};
|
|
646
|
+
}, [viewport]);
|
|
647
|
+
|
|
648
|
+
/** Each lane's markers in the order they happened: the threads, the bars and the enter origins read it. */
|
|
649
|
+
const laneMarkers = useMemo(() => {
|
|
650
|
+
const byLane = new Map<string, GanttMarker[]>();
|
|
651
|
+
for (const marker of markers) {
|
|
652
|
+
const list = byLane.get(marker.laneId);
|
|
653
|
+
if (list) {
|
|
654
|
+
list.push(marker);
|
|
655
|
+
} else {
|
|
656
|
+
byLane.set(marker.laneId, [marker]);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
for (const list of byLane.values()) {
|
|
660
|
+
list.sort((left, right) => left.timestamp - right.timestamp);
|
|
661
|
+
}
|
|
662
|
+
return byLane;
|
|
663
|
+
}, [markers]);
|
|
349
664
|
|
|
350
|
-
|
|
351
|
-
|
|
665
|
+
// The time axis is fitted to the viewport, so moving bounds rescale everything already drawn;
|
|
666
|
+
// easing the range makes that a slide rather than a jump. The unit axis never rescales.
|
|
667
|
+
// The time axis moves only when something happens: its bounds end at the newest drawn instant (a
|
|
668
|
+
// node or a segment edge) rather than at the host's range, which may follow the wall clock and would
|
|
669
|
+
// rescale an idle chart forever. Open lanes run to that same edge, so none is clipped.
|
|
670
|
+
const lastEvent = useMemo(() => {
|
|
671
|
+
const times = [
|
|
672
|
+
...markers.map((marker) => marker.timestamp),
|
|
673
|
+
...rows.flatMap(({ lane }) => (lane.segments ?? []).flatMap(({ start, end }) => [start, end ?? start])),
|
|
674
|
+
];
|
|
675
|
+
return times.length > 0 ? Math.max(...times) : undefined;
|
|
676
|
+
}, [markers, rows]);
|
|
677
|
+
const targetRange = useMemo(
|
|
678
|
+
() => (lastEvent === undefined ? range : { start: range.start, end: Math.max(lastEvent, range.start) }),
|
|
679
|
+
[range.start, range.end, lastEvent],
|
|
680
|
+
);
|
|
681
|
+
const shownRange = useRangeTween(targetRange, axis === 'time');
|
|
682
|
+
// CSS geometry transitions would chase every frame of that ease and trail behind it, so they run
|
|
683
|
+
// on the unit axis only; the stroke dash drawing a connector on is not geometry and always may.
|
|
684
|
+
const slide = animate === true && axis === 'unit';
|
|
685
|
+
const scale: GanttScale = useMemo(
|
|
686
|
+
() =>
|
|
687
|
+
axis === 'unit'
|
|
688
|
+
? unitScale({ times: markers.map((marker) => marker.timestamp), step: unitStep, pad: PAD_X })
|
|
689
|
+
: timeScale({ range: shownRange, width, pad: PAD_X }),
|
|
690
|
+
[axis, markers, unitStep, shownRange, width],
|
|
691
|
+
);
|
|
692
|
+
const isEntering = useEnter(markers.map((marker) => marker.id));
|
|
693
|
+
|
|
694
|
+
// On the time axis the right edge is "now" — the newest event, where open lanes end. While the range
|
|
695
|
+
// eases toward new bounds, anything newer than the drawn range is held at that edge rather than drawn
|
|
696
|
+
// past it: the new event and the open ends stay pinned while everything older compresses leftward.
|
|
697
|
+
const edge = axis === 'time' ? scale.at(shownRange.end) : undefined;
|
|
698
|
+
const x = (time: number): number => (edge === undefined ? scale.at(time) : Math.min(scale.at(time), edge));
|
|
352
699
|
const rowY = (index: number): number => HEADER_HEIGHT + index * ROW_HEIGHT + ROW_HEIGHT / 2;
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
700
|
+
const openSource = (lane: GanttLane): GanttMarker | undefined =>
|
|
701
|
+
lane.openedFrom && markerById.get(lane.openedFrom.markerId);
|
|
702
|
+
|
|
703
|
+
// Where each node is drawn: its own place, or — for the one frame in which it arrives — the place it
|
|
704
|
+
// came from, which is what the transitions below then travel out of. A lane's first node comes from
|
|
705
|
+
// the node the lane opened out of, so a lane reads as branching from whatever caused it.
|
|
706
|
+
const markerX = new Map<string, number>();
|
|
707
|
+
// The newest node of a lane that is still running: the end the next event will land on, and the
|
|
708
|
+
// one thing in the drawing that is not yet history.
|
|
709
|
+
const activeEdges = new Set<string>();
|
|
710
|
+
for (const [laneId, list] of laneMarkers) {
|
|
711
|
+
const row = rowById.get(laneId);
|
|
712
|
+
if (!row) {
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
const newest = list[list.length - 1];
|
|
716
|
+
if (row.lane.status === 'running' && newest) {
|
|
717
|
+
activeEdges.add(newest.id);
|
|
718
|
+
}
|
|
719
|
+
list.forEach((marker, index) => {
|
|
720
|
+
const previous = index > 0 ? list[index - 1] : undefined;
|
|
721
|
+
const source = previous ? undefined : openSource(row.lane);
|
|
722
|
+
const origin = previous ? x(previous.timestamp) : source && x(source.timestamp);
|
|
723
|
+
const resting = x(marker.timestamp);
|
|
724
|
+
markerX.set(marker.id, slide && origin !== undefined && isEntering(marker.id) ? origin : resting);
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* A lane's drawn stretches, one per segment. An open segment reaches its last node, taking the
|
|
730
|
+
* node's drawn position rather than its instant so the bar grows with an arriving event instead of
|
|
731
|
+
* jumping ahead of it — and on to the chart's newest event when that is later.
|
|
732
|
+
*/
|
|
733
|
+
const stretchesOf = (lane: GanttLane): Stretch[] => {
|
|
734
|
+
const list = laneMarkers.get(lane.id) ?? [];
|
|
735
|
+
const segments = lane.segments ?? [];
|
|
736
|
+
return segments.map((segment, index) => {
|
|
737
|
+
const upper = segment.end ?? segments[index + 1]?.start ?? Number.MAX_SAFE_INTEGER;
|
|
738
|
+
const inside = list.filter((marker) => marker.timestamp >= segment.start && marker.timestamp <= upper);
|
|
739
|
+
const first = inside[0];
|
|
740
|
+
const last = inside[inside.length - 1];
|
|
741
|
+
const from = x(segment.start);
|
|
742
|
+
// An open stretch runs to its newest node, and on to the newest event anywhere on the chart when
|
|
743
|
+
// that is later: a lane still being worked between its own events (a parent task while its
|
|
744
|
+
// sub-tasks run) is not a point.
|
|
745
|
+
const lastX = last ? (markerX.get(last.id) ?? x(last.timestamp)) : undefined;
|
|
746
|
+
const edgeX = lastEvent !== undefined ? x(lastEvent) : undefined;
|
|
747
|
+
const to =
|
|
748
|
+
segment.end !== undefined
|
|
749
|
+
? x(segment.end)
|
|
750
|
+
: lastX !== undefined && edgeX !== undefined
|
|
751
|
+
? Math.max(lastX, edgeX)
|
|
752
|
+
: (lastX ?? edgeX ?? from);
|
|
753
|
+
const threaded = inside.length > 1 && first && last;
|
|
754
|
+
return {
|
|
755
|
+
from,
|
|
756
|
+
to: Math.max(to, from),
|
|
757
|
+
threadFrom: threaded ? markerX.get(first.id) : undefined,
|
|
758
|
+
threadTo: threaded ? markerX.get(last.id) : undefined,
|
|
759
|
+
};
|
|
760
|
+
});
|
|
360
761
|
};
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const edge = x(start) - BAR_OVERHANG;
|
|
368
|
-
// Past the bend the connector runs level for one more radius before the bar begins.
|
|
369
|
-
return source ? Math.max(edge, x(source.timestamp) + 2 * BEND_RADIUS) : edge;
|
|
762
|
+
|
|
763
|
+
// A lane whose first node has just arrived is opening: its bar extends from its own beginning
|
|
764
|
+
// rather than appearing at full length, and the connector that caused it draws out to meet it.
|
|
765
|
+
const isOpening = (lane: GanttLane): boolean => {
|
|
766
|
+
const first = laneMarkers.get(lane.id)?.[0];
|
|
767
|
+
return slide && first !== undefined && isEntering(first.id);
|
|
370
768
|
};
|
|
371
|
-
|
|
372
|
-
// which is where the connector drops, so that node is nudged in past the bar's edge.
|
|
373
|
-
const nodeX = (lane: GanttLane, time: number): number =>
|
|
374
|
-
lane.start === undefined ? x(time) : Math.max(x(time), barStart(lane, lane.start) + BAR_OVERHANG);
|
|
769
|
+
|
|
375
770
|
const height = HEADER_HEIGHT + rows.length * ROW_HEIGHT;
|
|
376
|
-
const
|
|
377
|
-
|
|
771
|
+
const grow = slide && mx('transition-[width]', ENTER_TRANSITION);
|
|
772
|
+
|
|
773
|
+
const nodeIds = useMemo(
|
|
774
|
+
() => new Map(markers.map((marker, index) => [marker.id, `${nodeIdPrefix}-node-${index}`])),
|
|
775
|
+
[markers, nodeIdPrefix],
|
|
776
|
+
);
|
|
777
|
+
const current = currentId === undefined ? undefined : markerById.get(currentId);
|
|
778
|
+
|
|
779
|
+
/** The node an arrow key moves to from `from`; `from` itself where there is nowhere further to go. */
|
|
780
|
+
const neighbour = (from: GanttMarker, key: string): GanttMarker => {
|
|
781
|
+
if (key === 'ArrowLeft' || key === 'ArrowRight') {
|
|
782
|
+
const list = laneMarkers.get(from.laneId) ?? [];
|
|
783
|
+
const index = list.findIndex((marker) => marker.id === from.id);
|
|
784
|
+
return list[index + (key === 'ArrowLeft' ? -1 : 1)] ?? from;
|
|
785
|
+
}
|
|
786
|
+
const row = rowById.get(from.laneId);
|
|
787
|
+
if (!row) {
|
|
788
|
+
return from;
|
|
789
|
+
}
|
|
790
|
+
// The nearest node in drawn position rather than in time: on the unit axis the two disagree, and
|
|
791
|
+
// the reader is following what they see.
|
|
792
|
+
const fromX = markerX.get(from.id) ?? x(from.timestamp);
|
|
793
|
+
const distance = (marker: GanttMarker): number => Math.abs((markerX.get(marker.id) ?? x(marker.timestamp)) - fromX);
|
|
794
|
+
const direction = key === 'ArrowUp' ? -1 : 1;
|
|
795
|
+
// Lanes with no nodes are passed over: there is nothing on them to land on.
|
|
796
|
+
for (let index = row.index + direction; index >= 0 && index < rows.length; index += direction) {
|
|
797
|
+
const list = laneMarkers.get(rows[index].lane.id) ?? [];
|
|
798
|
+
if (list.length > 0) {
|
|
799
|
+
return list.reduce((best, marker) => (distance(marker) < distance(best) ? marker : best));
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return from;
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
/** Scrolls the frame just enough to show `marker` clear of the sticky legend and the frame's edges. */
|
|
806
|
+
const reveal = (marker: GanttMarker): void => {
|
|
807
|
+
const svg = svgRef.current;
|
|
808
|
+
const row = rowById.get(marker.laneId);
|
|
809
|
+
const cx = markerX.get(marker.id);
|
|
810
|
+
if (!viewport || !svg || !row || cx === undefined) {
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const frame = viewport.getBoundingClientRect();
|
|
814
|
+
const drawing = svg.getBoundingClientRect();
|
|
815
|
+
const legendWidth = drawing.left - frame.left + viewport.scrollLeft;
|
|
816
|
+
const nodeX = drawing.left - frame.left + cx;
|
|
817
|
+
const nodeY = drawing.top - frame.top + rowY(row.index);
|
|
818
|
+
const margin = ROW_HEIGHT;
|
|
819
|
+
const left =
|
|
820
|
+
nodeX - margin < legendWidth
|
|
821
|
+
? nodeX - margin - legendWidth
|
|
822
|
+
: nodeX + margin > viewport.clientWidth
|
|
823
|
+
? nodeX + margin - viewport.clientWidth
|
|
824
|
+
: 0;
|
|
825
|
+
const top =
|
|
826
|
+
nodeY - margin < 0
|
|
827
|
+
? nodeY - margin
|
|
828
|
+
: nodeY + margin > viewport.clientHeight
|
|
829
|
+
? nodeY + margin - viewport.clientHeight
|
|
830
|
+
: 0;
|
|
831
|
+
if (left !== 0 || top !== 0) {
|
|
832
|
+
viewport.scrollBy({ left, top });
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
const moveTo = (marker: GanttMarker): void => {
|
|
837
|
+
setCurrentId(marker.id);
|
|
838
|
+
// An open card travels with the current node.
|
|
839
|
+
setOpenId((open) => (open === undefined ? undefined : marker.id));
|
|
840
|
+
reveal(marker);
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
const handleKeyDown = (event: KeyboardEvent<SVGSVGElement>): void => {
|
|
844
|
+
switch (event.key) {
|
|
845
|
+
case 'ArrowLeft':
|
|
846
|
+
case 'ArrowRight':
|
|
847
|
+
case 'ArrowUp':
|
|
848
|
+
case 'ArrowDown': {
|
|
849
|
+
event.preventDefault();
|
|
850
|
+
// With no node yet, the first lane's newest: the unit axis opens on the present.
|
|
851
|
+
const next = current
|
|
852
|
+
? neighbour(current, event.key)
|
|
853
|
+
: rows.map(({ lane }) => laneMarkers.get(lane.id)?.at(-1)).find((marker) => marker !== undefined);
|
|
854
|
+
if (next) {
|
|
855
|
+
moveTo(next);
|
|
856
|
+
}
|
|
857
|
+
break;
|
|
858
|
+
}
|
|
859
|
+
case ' ': {
|
|
860
|
+
event.preventDefault();
|
|
861
|
+
if (current) {
|
|
862
|
+
setOpenId((open) => (open === current.id ? undefined : current.id));
|
|
863
|
+
}
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
case 'Escape': {
|
|
867
|
+
if (openId !== undefined) {
|
|
868
|
+
event.preventDefault();
|
|
869
|
+
setOpenId(undefined);
|
|
870
|
+
}
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
case 'Enter': {
|
|
874
|
+
if (current) {
|
|
875
|
+
event.preventDefault();
|
|
876
|
+
onMarkerSelect?.(current);
|
|
877
|
+
}
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
// The unit axis grows to the right, so the newest event has to be followed — but only while the
|
|
884
|
+
// reader is at its edge; having scrolled back, they are reading, not watching. A layout effect, so
|
|
885
|
+
// the first paint already shows the newest events rather than jumping to them from the start.
|
|
886
|
+
useLayoutEffect(() => {
|
|
887
|
+
const element = viewport;
|
|
888
|
+
// Recorded only once there is a frame to scroll, so the first paint with one still opens on the present.
|
|
889
|
+
if (!element) {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const previous = drawnRef.current;
|
|
893
|
+
drawnRef.current = scale.width;
|
|
894
|
+
|
|
895
|
+
// Opened on the present: what is happening now is what the chart is looked at for. A drawing that
|
|
896
|
+
// fitted until now counts as opening too — its events arrived after mount, and there was no place
|
|
897
|
+
// in it for the reader to keep.
|
|
898
|
+
const grown = previous !== undefined && scale.width > previous;
|
|
899
|
+
if (previous === undefined || (grown && previous <= width)) {
|
|
900
|
+
element.scrollLeft = element.scrollWidth;
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (!grown || !followRef.current) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Glided rather than jumped: the drawing is unchanged to the left of the new event, and a jump
|
|
909
|
+
// asks the reader to re-find their place in it every time one arrives. A reader who asked for
|
|
910
|
+
// less motion gets none.
|
|
911
|
+
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
|
912
|
+
element.scrollLeft = element.scrollWidth;
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Animated here rather than by `scrollTo({ behavior: 'smooth' })`, whose curve is the browser's
|
|
917
|
+
// own and fixed: it arrives in a fraction of the time the event that caused it takes to settle,
|
|
918
|
+
// so the window lurches ahead of the drawing it is following.
|
|
919
|
+
const from = element.scrollLeft;
|
|
920
|
+
const to = element.scrollWidth - element.clientWidth;
|
|
921
|
+
if (to <= from) {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const started = performance.now();
|
|
925
|
+
glideRef.current = true;
|
|
926
|
+
const step = (now: number): void => {
|
|
927
|
+
const progress = Math.min((now - started) / FOLLOW_DURATION, 1);
|
|
928
|
+
// Eased out, the shape everything else in the drawing travels on, so the two agree.
|
|
929
|
+
element.scrollLeft = from + (to - from) * (1 - (1 - progress) ** 3);
|
|
930
|
+
if (progress < 1) {
|
|
931
|
+
frameRef.current = requestAnimationFrame(step);
|
|
932
|
+
} else {
|
|
933
|
+
glideRef.current = false;
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
frameRef.current = requestAnimationFrame(step);
|
|
937
|
+
// A glide still running when the next event arrives is abandoned where it got to; the glide that
|
|
938
|
+
// replaces it starts from there, so the drift retargets rather than restarting.
|
|
939
|
+
return () => {
|
|
940
|
+
if (frameRef.current !== undefined) {
|
|
941
|
+
cancelAnimationFrame(frameRef.current);
|
|
942
|
+
}
|
|
943
|
+
glideRef.current = false;
|
|
944
|
+
};
|
|
945
|
+
}, [scale.width, viewport]);
|
|
378
946
|
|
|
379
947
|
return (
|
|
380
948
|
<svg
|
|
381
|
-
|
|
949
|
+
// Pixel coordinates against the drawing's own width, with no viewBox: a viewBox would
|
|
950
|
+
// letterbox the drawing to the column's aspect ratio and shrink every node with it. Grows into
|
|
951
|
+
// the row the legend leaves when the events need less.
|
|
952
|
+
className={mx('grow shrink-0 dx-focus-ring', classNames)}
|
|
953
|
+
style={{ width: scale.width, height }}
|
|
954
|
+
tabIndex={0}
|
|
955
|
+
role='group'
|
|
956
|
+
aria-label={t('gantt-chart.label')}
|
|
957
|
+
aria-activedescendant={current ? nodeIds.get(current.id) : undefined}
|
|
958
|
+
onKeyDown={handleKeyDown}
|
|
959
|
+
data-testid='gantt.chart'
|
|
382
960
|
ref={ref}
|
|
383
|
-
// Pixel coordinates against the measured width, with no viewBox: a viewBox would letterbox
|
|
384
|
-
// the drawing to the column's aspect ratio and shrink every node with it.
|
|
385
|
-
style={{ height }}
|
|
386
961
|
>
|
|
387
|
-
{ticks.map((
|
|
962
|
+
{scale.ticks.map(({ at, label }, index) => (
|
|
388
963
|
<g key={index}>
|
|
389
|
-
<line x1={
|
|
964
|
+
<line x1={at} x2={at} y1={HEADER_HEIGHT} y2={height} className='stroke-separator' />
|
|
390
965
|
<text
|
|
391
|
-
x={
|
|
966
|
+
x={at}
|
|
392
967
|
y={HEADER_HEIGHT - 6}
|
|
393
|
-
|
|
394
|
-
|
|
968
|
+
// Anchored inward at the drawing's own edges, so no label is drawn outside it.
|
|
969
|
+
textAnchor={at <= PAD_X ? 'start' : at >= scale.width - PAD_X ? 'end' : 'middle'}
|
|
970
|
+
className='fill-current text-fg-subtle'
|
|
395
971
|
>
|
|
396
|
-
{
|
|
972
|
+
{label}
|
|
397
973
|
</text>
|
|
398
974
|
</g>
|
|
399
975
|
))}
|
|
400
976
|
|
|
401
977
|
{/* Under the lanes and connectors: a dependency on a still-running lane anchors at `now`
|
|
402
|
-
|
|
978
|
+
and would otherwise be hidden by this line. */}
|
|
403
979
|
{now !== undefined && showNow && (
|
|
404
980
|
<line
|
|
405
981
|
x1={x(now)}
|
|
@@ -411,44 +987,43 @@ const GanttChart = forwardRef<SVGSVGElement, GanttChartProps>(({ classNames }, f
|
|
|
411
987
|
/>
|
|
412
988
|
)}
|
|
413
989
|
|
|
414
|
-
{/* A
|
|
415
|
-
|
|
416
|
-
{
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
// Only lanes with a bar bound the rectangle: a task not yet started has no extent.
|
|
420
|
-
const ends = members.flatMap((lane) => {
|
|
421
|
-
const end = lane.start === undefined ? undefined : laneEnd(lane);
|
|
422
|
-
return end === undefined ? [] : [x(end) + BAR_OVERHANG];
|
|
423
|
-
});
|
|
424
|
-
if (starts.length === 0 || ends.length === 0) {
|
|
990
|
+
{/* A group's rectangle encloses its band — the lanes it holds, and only those: a nested
|
|
991
|
+
group is a band of its own, drawn further down. */}
|
|
992
|
+
{bands.flatMap(({ group, first, last }) => {
|
|
993
|
+
const stretches = rows.slice(first, last + 1).flatMap((row) => stretchesOf(row.lane));
|
|
994
|
+
if (stretches.length === 0) {
|
|
425
995
|
return [];
|
|
426
996
|
}
|
|
427
|
-
const left = Math.min(...
|
|
428
|
-
const right = Math.max(...
|
|
997
|
+
const left = Math.min(...stretches.map((stretch) => stretch.from)) - BAR_OVERHANG - GROUP_PAD;
|
|
998
|
+
const right = Math.max(...stretches.map((stretch) => stretch.to)) + BAR_OVERHANG + GROUP_PAD;
|
|
429
999
|
return [
|
|
430
1000
|
<rect
|
|
431
|
-
key={`
|
|
1001
|
+
key={`band:${group.id}`}
|
|
432
1002
|
x={left}
|
|
433
1003
|
y={rowY(first) - ROW_HEIGHT / 2 + GROUP_INSET}
|
|
434
1004
|
width={Math.max(right - left, ROW_HEIGHT)}
|
|
435
1005
|
height={(last - first + 1) * ROW_HEIGHT - 2 * GROUP_INSET}
|
|
436
1006
|
rx={GROUP_RADIUS}
|
|
437
|
-
className='fill-input-surface'
|
|
1007
|
+
className={mx('fill-input-surface', grow)}
|
|
438
1008
|
/>,
|
|
439
1009
|
];
|
|
440
1010
|
})}
|
|
441
1011
|
|
|
442
|
-
{/* Connectors next, so the drop to a
|
|
1012
|
+
{/* Connectors next, so the drop to a lane passes beneath any bar it crosses. */}
|
|
443
1013
|
{rows.flatMap(({ lane, index }) =>
|
|
444
1014
|
(lane.blockedOn ?? []).flatMap((depId) => {
|
|
445
1015
|
const dep = rowById.get(depId);
|
|
446
|
-
|
|
447
|
-
const anchor = dep && (laneEnd(dep.lane) ?? dep.lane.start);
|
|
448
|
-
if (!dep || anchor === undefined) {
|
|
1016
|
+
if (!dep) {
|
|
449
1017
|
return [];
|
|
450
1018
|
}
|
|
451
|
-
|
|
1019
|
+
// Anchored on the end of the dependency's last stretch, so the line meets a node rather
|
|
1020
|
+
// than a bar edge.
|
|
1021
|
+
const stretches = stretchesOf(dep.lane);
|
|
1022
|
+
const anchor = stretches[stretches.length - 1];
|
|
1023
|
+
if (!anchor) {
|
|
1024
|
+
return [];
|
|
1025
|
+
}
|
|
1026
|
+
const anchorX = anchor.to;
|
|
452
1027
|
return [
|
|
453
1028
|
<line
|
|
454
1029
|
key={`${lane.id}:${depId}`}
|
|
@@ -461,14 +1036,13 @@ const GanttChart = forwardRef<SVGSVGElement, GanttChartProps>(({ classNames }, f
|
|
|
461
1036
|
/>,
|
|
462
1037
|
// A waiter with no bar has nothing on its row for the line to reach, so it ends on a
|
|
463
1038
|
// hollow node: the point the waiter is held at until the dependency resolves.
|
|
464
|
-
...(lane.
|
|
1039
|
+
...((lane.segments ?? []).length === 0
|
|
465
1040
|
? [
|
|
466
1041
|
<circle
|
|
467
1042
|
key={`${lane.id}:${depId}:hold`}
|
|
468
1043
|
cx={anchorX}
|
|
469
1044
|
cy={rowY(index)}
|
|
470
1045
|
r={NODE_RADIUS}
|
|
471
|
-
strokeDasharray='2 2'
|
|
472
1046
|
className={mx('fill-base-surface', STATUS_COLOR.blocked.thread)}
|
|
473
1047
|
/>,
|
|
474
1048
|
]
|
|
@@ -477,100 +1051,228 @@ const GanttChart = forwardRef<SVGSVGElement, GanttChartProps>(({ classNames }, f
|
|
|
477
1051
|
}),
|
|
478
1052
|
)}
|
|
479
1053
|
|
|
480
|
-
{/* Down from the
|
|
1054
|
+
{/* Down from the node a lane opened out of, a quarter bend, then right to where it begins. */}
|
|
481
1055
|
{rows.flatMap(({ lane, index }) => {
|
|
482
|
-
const source =
|
|
483
|
-
const sourceRow = lane.
|
|
484
|
-
|
|
1056
|
+
const source = openSource(lane);
|
|
1057
|
+
const sourceRow = lane.openedFrom && rowById.get(lane.openedFrom.laneId);
|
|
1058
|
+
const stretches = stretchesOf(lane);
|
|
1059
|
+
if (!source || !sourceRow || stretches.length === 0) {
|
|
485
1060
|
return [];
|
|
486
1061
|
}
|
|
487
1062
|
const sourceX = x(source.timestamp);
|
|
488
1063
|
const y = rowY(index);
|
|
489
|
-
const
|
|
490
|
-
|
|
1064
|
+
const targetX = stretches[0].from;
|
|
1065
|
+
// Down, a quarter bend, then right to where the lane begins — but only while there is
|
|
1066
|
+
// somewhere forward to go. A lane that begins at or before the node that opened it has no
|
|
1067
|
+
// run to make, and bending anyway would hook the connector sideways into the middle of its
|
|
1068
|
+
// own bar; the drop lands on the row instead. The bar stays where the data puts it either
|
|
1069
|
+
// way: a connector doubling back over itself reads as a line to somewhere else entirely.
|
|
1070
|
+
const path =
|
|
1071
|
+
targetX > sourceX + BEND_RADIUS
|
|
1072
|
+
? `M ${sourceX} ${rowY(sourceRow.index)} V ${y - BEND_RADIUS} Q ${sourceX} ${y} ${sourceX + BEND_RADIUS} ${y} H ${targetX}`
|
|
1073
|
+
: `M ${sourceX} ${rowY(sourceRow.index)} V ${y}`;
|
|
491
1074
|
return [
|
|
492
1075
|
<path
|
|
493
|
-
key={`
|
|
494
|
-
d={
|
|
1076
|
+
key={`opened:${lane.id}`}
|
|
1077
|
+
d={path}
|
|
495
1078
|
fill='none'
|
|
496
|
-
|
|
1079
|
+
// Drawn on by its dash rather than by its shape: `d` is beyond what a transition can
|
|
1080
|
+
// reach, while `stroke-dashoffset` is a property every renderer animates. `pathLength`
|
|
1081
|
+
// normalises the path to 1 so one dash covers it whatever its actual length — which is
|
|
1082
|
+
// also why these are inert, and the connector solid, when it is not opening.
|
|
1083
|
+
pathLength={1}
|
|
1084
|
+
strokeDasharray={1}
|
|
1085
|
+
strokeDashoffset={isOpening(lane) ? 1 : 0}
|
|
1086
|
+
className={mx(animate && mx('transition-[stroke-dashoffset]', ENTER_TRANSITION), CONNECTOR_CLASSNAME)}
|
|
497
1087
|
/>,
|
|
498
1088
|
];
|
|
499
1089
|
})}
|
|
500
1090
|
|
|
501
|
-
{
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
1091
|
+
{/* Back out of the lane's last stretch and up into the node its result answered. Dashed, and
|
|
1092
|
+
the spawn is not: one is the moment work was handed over, the other the moment an answer
|
|
1093
|
+
arrived, and a reader following a cascade needs to tell the two directions apart. */}
|
|
1094
|
+
{rows.flatMap(({ lane, index }) => {
|
|
1095
|
+
const target = lane.closedInto && markerById.get(lane.closedInto.markerId);
|
|
1096
|
+
const targetRow = lane.closedInto && rowById.get(lane.closedInto.laneId);
|
|
1097
|
+
const stretches = stretchesOf(lane);
|
|
1098
|
+
const last = stretches[stretches.length - 1];
|
|
1099
|
+
if (!target || !targetRow || !last) {
|
|
1100
|
+
return [];
|
|
505
1101
|
}
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
1102
|
+
const targetX = x(target.timestamp);
|
|
1103
|
+
const y = rowY(index);
|
|
1104
|
+
const targetY = rowY(targetRow.index);
|
|
1105
|
+
// Which way the bend turns depends on whether the answer went up the chart or down it;
|
|
1106
|
+
// a lane is normally below the one it reports to, but nothing in the model requires it.
|
|
1107
|
+
const rise = Math.sign(targetY - y) * BEND_RADIUS;
|
|
1108
|
+
// Forward only, for the same reason the spawn connector is: a run doubling back reads as
|
|
1109
|
+
// a line to somewhere else. With nowhere to run, the rise happens at the answer's own node.
|
|
1110
|
+
const path =
|
|
1111
|
+
targetX > last.to + BEND_RADIUS
|
|
1112
|
+
? `M ${last.to} ${y} H ${targetX - BEND_RADIUS} Q ${targetX} ${y} ${targetX} ${y + rise} V ${targetY}`
|
|
1113
|
+
: `M ${targetX} ${y} V ${targetY}`;
|
|
1114
|
+
return [
|
|
1115
|
+
<path key={`closed:${lane.id}`} d={path} fill='none' strokeDasharray='3 2' className={CONNECTOR_CLASSNAME} />,
|
|
1116
|
+
];
|
|
521
1117
|
})}
|
|
522
1118
|
|
|
523
|
-
{/*
|
|
524
|
-
{rows.
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
1119
|
+
{/* One bar per segment: the gaps between them are the stretches the lane was not worked. */}
|
|
1120
|
+
{rows.flatMap(({ lane, index }) =>
|
|
1121
|
+
stretchesOf(lane).map((stretch, segment) => {
|
|
1122
|
+
const start = stretch.from - BAR_OVERHANG;
|
|
1123
|
+
const bar = {
|
|
1124
|
+
x: start,
|
|
1125
|
+
y: rowY(index) - BAR_HEIGHT / 2,
|
|
1126
|
+
// An opening lane is a dot at its own beginning for one frame, so it extends from where
|
|
1127
|
+
// the connector lands instead of being there at full length before the connector is.
|
|
1128
|
+
width: isOpening(lane) ? BAR_HEIGHT : Math.max(stretch.to + BAR_OVERHANG - start, BAR_HEIGHT),
|
|
1129
|
+
height: BAR_HEIGHT,
|
|
1130
|
+
rx: BAR_HEIGHT / 2,
|
|
1131
|
+
};
|
|
1132
|
+
// An opaque backing under the translucent tint: the connectors pass beneath the bars, and
|
|
1133
|
+
// without it they would show through.
|
|
1134
|
+
return (
|
|
1135
|
+
<g key={`${lane.id}:${segment}`} className='cursor-pointer' onClick={() => onLaneSelect?.(lane)}>
|
|
1136
|
+
<rect {...bar} className={mx('fill-base-surface', grow)} />
|
|
1137
|
+
{lane.hue ? (
|
|
1138
|
+
<rect {...bar} className={mx(grow)} style={{ fill: hueColor(lane.hue), fillOpacity: 0.4 }} />
|
|
1139
|
+
) : (
|
|
1140
|
+
<rect {...bar} className={mx(STATUS_COLOR[lane.status].fill, grow)} />
|
|
1141
|
+
)}
|
|
1142
|
+
</g>
|
|
1143
|
+
);
|
|
1144
|
+
}),
|
|
1145
|
+
)}
|
|
1146
|
+
|
|
1147
|
+
{/* The thread through a segment's nodes, so a stretch reads as a sequence rather than dots. */}
|
|
1148
|
+
{rows.flatMap(({ lane, index }) =>
|
|
1149
|
+
stretchesOf(lane).flatMap((stretch, segment) =>
|
|
1150
|
+
stretch.threadFrom === undefined || stretch.threadTo === undefined
|
|
1151
|
+
? []
|
|
1152
|
+
: [
|
|
1153
|
+
// A hairline rect rather than a line: a line's `x1`/`x2` are attributes and nothing
|
|
1154
|
+
// else, while `x` and `width` are geometry properties a transition can reach, so the
|
|
1155
|
+
// thread grows with the node it is reaching for instead of arriving ahead of it.
|
|
1156
|
+
<rect
|
|
1157
|
+
key={`thread:${lane.id}:${segment}`}
|
|
1158
|
+
x={stretch.threadFrom}
|
|
1159
|
+
y={rowY(index) - THREAD_HEIGHT / 2}
|
|
1160
|
+
width={Math.max(stretch.threadTo - stretch.threadFrom, 0)}
|
|
1161
|
+
height={THREAD_HEIGHT}
|
|
1162
|
+
className={mx(
|
|
1163
|
+
slide && mx('transition-[x,width]', ENTER_TRANSITION),
|
|
1164
|
+
!lane.hue && STATUS_COLOR[lane.status].node,
|
|
1165
|
+
)}
|
|
1166
|
+
style={lane.hue ? { fill: hueColor(lane.hue) } : undefined}
|
|
1167
|
+
/>,
|
|
1168
|
+
],
|
|
1169
|
+
),
|
|
1170
|
+
)}
|
|
1171
|
+
|
|
1172
|
+
{/* An answered wait, dashed from the node that began it to the one that ended it, so the stretch
|
|
1173
|
+
a task spent held up reads as part of its lane rather than a gap in it. */}
|
|
1174
|
+
{markers.flatMap((marker) => {
|
|
1175
|
+
const row = marker.wait && rowById.get(marker.laneId);
|
|
1176
|
+
const from = markerX.get(marker.id);
|
|
1177
|
+
const to = marker.wait && markerX.get(marker.wait.until);
|
|
1178
|
+
if (!row || from === undefined || to === undefined || to <= from) {
|
|
1179
|
+
return [];
|
|
528
1180
|
}
|
|
529
|
-
return
|
|
1181
|
+
return [
|
|
530
1182
|
<line
|
|
531
|
-
key={`
|
|
532
|
-
x1={
|
|
533
|
-
x2={
|
|
534
|
-
y1={rowY(index)}
|
|
535
|
-
y2={rowY(index)}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
1183
|
+
key={`wait:${marker.id}`}
|
|
1184
|
+
x1={from}
|
|
1185
|
+
x2={to}
|
|
1186
|
+
y1={rowY(row.index)}
|
|
1187
|
+
y2={rowY(row.index)}
|
|
1188
|
+
strokeWidth={2}
|
|
1189
|
+
strokeDasharray='4 3'
|
|
1190
|
+
className={row.lane.hue ? undefined : STATUS_COLOR[row.lane.status].thread}
|
|
1191
|
+
style={row.lane.hue ? { stroke: hueStrongColor(row.lane.hue) } : undefined}
|
|
1192
|
+
/>,
|
|
1193
|
+
];
|
|
539
1194
|
})}
|
|
540
1195
|
|
|
541
1196
|
{/* Nodes last, over the bars and every line, so a line reads as ending at a node's centre. */}
|
|
542
1197
|
{markers.map((marker) => {
|
|
543
1198
|
const row = rowById.get(marker.laneId);
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
1199
|
+
const cx = markerX.get(marker.id);
|
|
1200
|
+
return row && cx !== undefined ? (
|
|
1201
|
+
<Fragment key={marker.id}>
|
|
1202
|
+
{/* Pings out from behind the node until answered, so the node itself stays readable. */}
|
|
1203
|
+
{marker.pending && (
|
|
547
1204
|
<circle
|
|
548
|
-
cx={
|
|
1205
|
+
cx={cx}
|
|
549
1206
|
cy={rowY(row.index)}
|
|
550
1207
|
r={NODE_RADIUS}
|
|
551
|
-
className=
|
|
552
|
-
'cursor-pointer stroke-base-surface transition-[stroke-width] hover:stroke-[3px] hover:stroke-base-fg',
|
|
553
|
-
STATUS_COLOR[row.lane.status].node,
|
|
554
|
-
)}
|
|
555
|
-
onClick={() => onMarkerSelect?.(marker)}
|
|
1208
|
+
className='animate-ping transform-fill origin-center pointer-events-none fill-error-500'
|
|
556
1209
|
/>
|
|
557
|
-
|
|
558
|
-
<HoverCard.
|
|
559
|
-
|
|
1210
|
+
)}
|
|
1211
|
+
<HoverCard.Root
|
|
1212
|
+
open={openId === marker.id}
|
|
1213
|
+
onOpenChange={({ open }) =>
|
|
1214
|
+
setOpenId((previous) => (open ? marker.id : previous === marker.id ? undefined : previous))
|
|
1215
|
+
}
|
|
1216
|
+
>
|
|
1217
|
+
<HoverCard.Trigger asChild>
|
|
1218
|
+
<circle
|
|
1219
|
+
id={nodeIds.get(marker.id)}
|
|
1220
|
+
role='img'
|
|
1221
|
+
aria-label={marker.label}
|
|
1222
|
+
data-marker-id={marker.id}
|
|
1223
|
+
data-lane-id={marker.laneId}
|
|
1224
|
+
cx={cx}
|
|
1225
|
+
cy={rowY(row.index)}
|
|
1226
|
+
r={NODE_RADIUS}
|
|
1227
|
+
className={mx(
|
|
1228
|
+
'cursor-pointer hover:stroke-[3px] hover:stroke-fg',
|
|
1229
|
+
// A question or an error is ringed in red; an error pulses while its lane stays
|
|
1230
|
+
// failed (an unanswered question pings instead — the ring drawn behind it).
|
|
1231
|
+
isAlert(marker) ? 'stroke-red-500 stroke-2' : 'stroke-base-surface',
|
|
1232
|
+
marker.level === 'error' && row.lane.status === 'failed' && 'animate-pulse',
|
|
1233
|
+
// `cx` as a transition: where a browser exposes SVG geometry as CSS the node
|
|
1234
|
+
// slides out of the one before it, and where it does not it simply appears.
|
|
1235
|
+
slide ? mx('transition-[stroke-width,cx]', ENTER_TRANSITION) : 'transition-[stroke-width]',
|
|
1236
|
+
// The live end pulses in the lane's stronger shade: scanning a wall of finished
|
|
1237
|
+
// lanes, the ones still moving should be findable without reading the legend.
|
|
1238
|
+
// A question or an error is a solid node in the lane's own hue: the same thread,
|
|
1239
|
+
// so it reads as that task's, but the stronger shade so it is found at a glance.
|
|
1240
|
+
activeEdges.has(marker.id)
|
|
1241
|
+
? mx('animate-pulse', !row.lane.hue && STATUS_COLOR[row.lane.status].edge)
|
|
1242
|
+
: !row.lane.hue &&
|
|
1243
|
+
(isAlert(marker) ? STATUS_COLOR[row.lane.status].edge : STATUS_COLOR[row.lane.status].node),
|
|
1244
|
+
// The keyboard's place, ringed as a hovered node is.
|
|
1245
|
+
marker.id === currentId && 'stroke-[3px] stroke-fg',
|
|
1246
|
+
)}
|
|
1247
|
+
style={
|
|
1248
|
+
row.lane.hue
|
|
1249
|
+
? { fill: isAlert(marker) ? hueStrongColor(row.lane.hue) : hueColor(row.lane.hue) }
|
|
1250
|
+
: undefined
|
|
1251
|
+
}
|
|
1252
|
+
onClick={() => {
|
|
1253
|
+
setCurrentId(marker.id);
|
|
1254
|
+
svgRef.current?.focus({ preventScroll: true });
|
|
1255
|
+
onMarkerSelect?.(marker);
|
|
1256
|
+
}}
|
|
1257
|
+
/>
|
|
1258
|
+
</HoverCard.Trigger>
|
|
1259
|
+
<HoverCard.Content classNames='p-2 max-w-72 text-xs' data-testid='gantt.markerCard'>
|
|
560
1260
|
<div className='font-medium truncate'>{marker.label}</div>
|
|
561
|
-
<div className='text-
|
|
562
|
-
|
|
1261
|
+
<div className='text-fg-muted truncate'>{row.lane.label}</div>
|
|
1262
|
+
<div className='text-fg-muted tabular-nums'>
|
|
1263
|
+
{marker.kind && `${marker.kind} · `}
|
|
1264
|
+
{format(marker.timestamp, 'HH:mm:ss')}
|
|
1265
|
+
{/* Elapsed on the axis's own terms, so it reads against the tick labels. */}
|
|
1266
|
+
{` · +${formatElapsed(marker.timestamp - range.start, { span: marker.timestamp - range.start, step: 1_000 })}`}
|
|
563
1267
|
{marker.level && marker.level !== 'info' && (
|
|
564
1268
|
<span className={mx('ms-2', marker.level === 'error' ? 'text-error-text' : 'text-warning-text')}>
|
|
565
1269
|
{marker.level}
|
|
566
1270
|
</span>
|
|
567
1271
|
)}
|
|
568
1272
|
</div>
|
|
569
|
-
<div className='text-description truncate'>{row.lane.label}</div>
|
|
570
|
-
<HoverCard.Arrow />
|
|
571
1273
|
</HoverCard.Content>
|
|
572
|
-
</HoverCard.
|
|
573
|
-
</
|
|
1274
|
+
</HoverCard.Root>
|
|
1275
|
+
</Fragment>
|
|
574
1276
|
) : null;
|
|
575
1277
|
})}
|
|
576
1278
|
</svg>
|
|
@@ -582,8 +1284,9 @@ GanttChart.displayName = 'Gantt.Chart';
|
|
|
582
1284
|
export const Gantt = {
|
|
583
1285
|
Root: GanttRoot,
|
|
584
1286
|
Legend: GanttLegend,
|
|
1287
|
+
AxisToggle: GanttAxisToggle,
|
|
1288
|
+
LegendToggle: GanttLegendToggle,
|
|
585
1289
|
Chart: GanttChart,
|
|
586
|
-
Meta: GanttMeta,
|
|
587
1290
|
};
|
|
588
1291
|
|
|
589
|
-
export type { GanttChartProps, GanttLegendProps,
|
|
1292
|
+
export type { GanttAxisToggleProps, GanttChartProps, GanttLegendProps, GanttLegendToggleProps, GanttRootProps };
|