@anchrd/intel-ui 0.16.0 → 0.17.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.
Files changed (35) hide show
  1. package/package.json +10 -2
  2. package/src/agent/agent-profile/agent-profile.tsx +2 -0
  3. package/src/board/board-calendar/board-calendar.tsx +89 -0
  4. package/src/board/board-card/board-card.tsx +106 -0
  5. package/src/board/board-data/board-data.ts +241 -0
  6. package/src/board/board-data/board-data.types.ts +63 -0
  7. package/src/board/board-detail/board-detail.tsx +629 -0
  8. package/src/board/board-gantt/board-gantt.ts +545 -0
  9. package/src/board/board-gantt/board-gantt.tsx +286 -0
  10. package/src/board/board-graph/board-graph.ts +174 -0
  11. package/src/board/board-graph/board-graph.tsx +168 -0
  12. package/src/board/board-items/board-items.ts +183 -0
  13. package/src/board/board-kanban/board-kanban.ts +97 -0
  14. package/src/board/board-kanban/board-kanban.tsx +211 -0
  15. package/src/board/board-status/board-status.ts +59 -0
  16. package/src/board/board-statuses/board-statuses.ts +63 -0
  17. package/src/board/board-statuses/board-statuses.tsx +228 -0
  18. package/src/board/board-table/board-table.ts +33 -0
  19. package/src/board/board-table/board-table.tsx +413 -0
  20. package/src/board/board-views/board-views.tsx +68 -0
  21. package/src/board/board-views/board-views.types.ts +29 -0
  22. package/src/board/board.tsx +251 -0
  23. package/src/components/ui/dropdown-menu.tsx +25 -0
  24. package/src/components/ui/item-calendar.tsx +181 -0
  25. package/src/components/ui/item-gantt.tsx +463 -0
  26. package/src/components/ui/kanban.tsx +245 -0
  27. package/src/components/ui/switch.tsx +25 -0
  28. package/src/data/intel-data-provider/intel-data-provider.ts +52 -0
  29. package/src/data/intel-data-provider/intel-data-provider.types.ts +31 -0
  30. package/src/i18n/de.json +88 -1
  31. package/src/i18n/en.json +88 -1
  32. package/src/i18n/es.json +88 -1
  33. package/src/kind-icon.ts +12 -1
  34. package/src/nodes/nodes.tsx +16 -0
  35. package/src/styles.css +33 -0
@@ -0,0 +1,286 @@
1
+ import type { BoardStatus, BoardTask } from "@anchrd/intel-contract";
2
+ import { ChevronDown, ChevronRight } from "lucide-react";
3
+ import { useMemo, useState } from "react";
4
+ import { shownTasks } from "@/board/board-data/board-data.ts";
5
+ import { type BoardItem, boardSchedule } from "@/board/board-items/board-items.ts";
6
+ import { labelOf, toneOf } from "@/board/board-status/board-status.ts";
7
+ import type { BoardViewProps } from "@/board/board-views/board-views.types.ts";
8
+ import { ItemGantt } from "@/components/ui/item-gantt.tsx";
9
+ import type { I18n } from "@/i18n/i18n.types.ts";
10
+ import { useI18n } from "@/i18n/i18n-context.tsx";
11
+ import {
12
+ barDays,
13
+ dayWidth,
14
+ type GanttNode,
15
+ GanttRowHeight,
16
+ type GanttRow as GanttRowModel,
17
+ type GanttScale,
18
+ GanttScales,
19
+ ganttAxis,
20
+ ganttLinks,
21
+ ganttRows,
22
+ ganttTree,
23
+ ganttWrite,
24
+ linkGeometry,
25
+ } from "./board-gantt.ts";
26
+
27
+ /**
28
+ * The board as a timeline, with its dependencies drawn (anchrd/intel#293).
29
+ *
30
+ * ⚠️ It draws `boardSchedule` and nothing else, the same adapter the calendar reads. That is the ONE
31
+ * reading of a task as a dated thing (#286), and a Gantt that read `dueDate` for itself would be the
32
+ * second — the two would then disagree about the case that is actually subtle, a task with a start
33
+ * date and no due date, and they would disagree in a picture.
34
+ *
35
+ * ⚠️ A dependency is an arrow, and it means what the "Blocked" chip and the graph's thick edge mean:
36
+ * `board.blockedBy`, which is the status list's own `terminal` flag (anchrd/intel#311). Three
37
+ * surfaces, one definition. This screen would be the easiest place to invent a fourth, because an
38
+ * arrow is drawn from geometry and nothing about geometry knows what finished means.
39
+ */
40
+ export function BoardGantt({ board, select, showArchived }: BoardViewProps) {
41
+ const i18n = useI18n();
42
+ const [scale, setScale] = useState<GanttScale>("weeks");
43
+ // ⚠️ Component state, not a module-level store. The Gantt from the registry kept its scroll and
44
+ // drag state in atoms declared at module scope, which two boards open at once would have shared —
45
+ // see the header of `item-gantt.tsx` for why that is worse here than it was in the calendar.
46
+ const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
47
+
48
+ const tasks = useMemo(() => shownTasks(board.tasks, showArchived), [board.tasks, showArchived]);
49
+ const tree = useMemo(() => {
50
+ const schedule = boardSchedule(tasks, board.statuses, board.blockedBy);
51
+ const items = new Map<string, BoardItem>(schedule.items.map((item) => [item.id, item]));
52
+ return ganttTree(tasks, items);
53
+ }, [tasks, board.statuses, board.blockedBy]);
54
+
55
+ const rows = useMemo(() => ganttRows(tree.roots, collapsed), [tree.roots, collapsed]);
56
+ const links = useMemo(() => ganttLinks(rows, board.blockedBy), [rows, board.blockedBy]);
57
+
58
+ const width = dayWidth(scale);
59
+ const axis = useMemo(
60
+ () =>
61
+ ganttAxis(
62
+ rows.flatMap((row) => (row.bar === null ? [] : [row.bar])),
63
+ new Date(),
64
+ scale,
65
+ i18n.locale,
66
+ ),
67
+ [rows, scale, i18n.locale],
68
+ );
69
+
70
+ const chartRows = rows.map((row) => ({
71
+ id: row.task.id,
72
+ sidebar: (
73
+ <SidebarRow
74
+ row={row}
75
+ i18n={i18n}
76
+ statuses={board.statuses}
77
+ blockedBy={board.blockedBy}
78
+ onToggle={() =>
79
+ setCollapsed((current) => {
80
+ const next = new Set(current);
81
+ if (next.has(row.task.id)) next.delete(row.task.id);
82
+ else next.add(row.task.id);
83
+ return next;
84
+ })
85
+ }
86
+ onOpen={() => select(row.task.id)}
87
+ />
88
+ ),
89
+ bar:
90
+ row.bar === null
91
+ ? null
92
+ : {
93
+ ...barDays(row.bar, axis.start),
94
+ className: toneOf(row.task.status, board.statuses).dot,
95
+ derived: row.bar.derived,
96
+ },
97
+ }));
98
+
99
+ return (
100
+ <div className="flex min-h-0 flex-1 flex-col gap-3 p-6">
101
+ <div className="flex flex-wrap items-center gap-2">
102
+ <label className="flex items-center gap-2 text-sm">
103
+ <span className="text-muted-foreground">{i18n.t("board.gantt.scale")}</span>
104
+ <select
105
+ value={scale}
106
+ onChange={(event) => setScale(event.currentTarget.value as GanttScale)}
107
+ className="h-8 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
108
+ >
109
+ {GanttScales.map((option) => (
110
+ <option key={option} value={option}>
111
+ {i18n.t(`board.gantt.scale.${option}`)}
112
+ </option>
113
+ ))}
114
+ </select>
115
+ </label>
116
+ {links.hidden > 0 ? (
117
+ <p className="text-sm text-muted-foreground">
118
+ {i18n.t("board.gantt.hiddenLinks", { count: links.hidden })}
119
+ </p>
120
+ ) : null}
121
+ </div>
122
+
123
+ {rows.length === 0 ? (
124
+ <p className="text-sm text-muted-foreground">{i18n.t("board.gantt.nothingDated")}</p>
125
+ ) : (
126
+ <ItemGantt
127
+ rows={chartRows}
128
+ links={linkGeometry(links.links, rows, axis.start, width, GanttRowHeight)}
129
+ groups={axis.groups}
130
+ ticks={axis.ticks}
131
+ days={axis.days}
132
+ dayWidth={width}
133
+ rowHeight={GanttRowHeight}
134
+ today={axis.today}
135
+ labels={{
136
+ today: i18n.t("board.gantt.today"),
137
+ move: (id) => i18n.t("board.gantt.move", { task: titleOf(id, rows) }),
138
+ start: (id) => i18n.t("board.gantt.resizeStart", { task: titleOf(id, rows) }),
139
+ end: (id) => i18n.t("board.gantt.resizeEnd", { task: titleOf(id, rows) }),
140
+ }}
141
+ /**
142
+ * ⚠️ ONE `board_task_update` for a whole gesture, and none at all for a gesture that
143
+ * changed no date. `ganttWrite` answers `null` for a bar put back where it was picked up;
144
+ * sent anyway it would mint a version and stand in the audit as work that did not happen.
145
+ */
146
+ onEdit={(taskId, edit, deltaDays) => {
147
+ const row = rows.find((entry) => entry.task.id === taskId);
148
+ // A derived bar has no `item`, which is the same reason it has no grips: one gesture
149
+ // cannot write the five tasks it covers.
150
+ if (row === undefined || row.item === null) return;
151
+ const write = ganttWrite(row.task, row.item, edit, deltaDays);
152
+ if (write) board.update.mutate(write);
153
+ }}
154
+ />
155
+ )}
156
+
157
+ {/* ⚠️ The leftover column, and the DoD asks for it by name: a task with no date must not
158
+ vanish because a timeline has nowhere to put it. Whole subtrees, indented as they stand on
159
+ the board — a dateless epic with two dateless children is three tasks, and listing only the
160
+ epic would lose the other two just as quietly.
161
+
162
+ ⚠️ Nothing here is a drop target. The kanban learned that a lane which accepts a drop the
163
+ server refuses is worse than one that cannot be dropped on at all; the reason here is a
164
+ different one and it is the reason there is no drag between the two lists in either
165
+ direction. Dragging a bar into this list would mean CLEARING both dates, and a gesture that
166
+ deletes what somebody typed is not a gesture — it is the delete button, which is in the
167
+ panel and asks first. Dates are given in the panel too. */}
168
+ {tree.undated.length === 0 ? null : (
169
+ <section
170
+ aria-label={i18n.t("board.gantt.undated")}
171
+ className="max-h-48 shrink-0 overflow-y-auto rounded-lg border border-dashed bg-muted/30 p-3"
172
+ >
173
+ <h3 className="pb-1 text-xs font-medium text-muted-foreground">
174
+ {i18n.t("board.gantt.undatedCount", { count: countTasks(tree.undated) })}
175
+ </h3>
176
+ <ul className="flex flex-col">
177
+ {ganttRows(tree.undated, new Set()).map((row) => (
178
+ <li key={row.task.id} style={{ paddingInlineStart: `${row.depth * 1}rem` }}>
179
+ <button
180
+ type="button"
181
+ onClick={() => select(row.task.id)}
182
+ className="inline-flex items-center gap-1.5 rounded py-0.5 text-sm outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
183
+ >
184
+ <span
185
+ aria-hidden="true"
186
+ className={`size-2 shrink-0 rounded-full ${toneOf(row.task.status, board.statuses).dot}`}
187
+ />
188
+ <span className="min-w-0 truncate">{row.task.title}</span>
189
+ <span className="sr-only">{labelOf(row.task.status, board.statuses)}</span>
190
+ </button>
191
+ </li>
192
+ ))}
193
+ </ul>
194
+ </section>
195
+ )}
196
+ </div>
197
+ );
198
+ }
199
+
200
+ function titleOf(taskId: string, rows: GanttRowModel[]): string {
201
+ return rows.find((row) => row.task.id === taskId)?.task.title ?? taskId;
202
+ }
203
+
204
+ function countTasks(nodes: GanttNode[]): number {
205
+ return nodes.reduce((total, node) => total + 1 + countTasks(node.children), 0);
206
+ }
207
+
208
+ /**
209
+ * One row's left column.
210
+ *
211
+ * ⚠️ Everything the bar and the arrows show is here in words as well. The chart is a picture, and a
212
+ * picture is the one thing a reader who cannot see it gets nothing from: the status, the dates and
213
+ * what the task waits for are read out here, so the arrows are a second way of saying something
214
+ * rather than the only way.
215
+ */
216
+ function SidebarRow({
217
+ row,
218
+ i18n,
219
+ statuses,
220
+ blockedBy,
221
+ onToggle,
222
+ onOpen,
223
+ }: {
224
+ row: GanttRowModel;
225
+ i18n: I18n;
226
+ statuses: BoardStatus[];
227
+ blockedBy(task: BoardTask): BoardTask[];
228
+ onToggle(): void;
229
+ onOpen(): void;
230
+ }) {
231
+ const open = blockedBy(row.task);
232
+ const span = row.bar;
233
+ const date = (value: Date) => value.toLocaleDateString(i18n.locale);
234
+ return (
235
+ <>
236
+ <span style={{ width: `${row.depth * 0.75}rem` }} aria-hidden="true" className="shrink-0" />
237
+ {row.hasChildren ? (
238
+ <button
239
+ type="button"
240
+ onClick={onToggle}
241
+ aria-expanded={!row.collapsed}
242
+ aria-label={i18n.t("board.toggleSubtasks", { task: row.task.title })}
243
+ className="shrink-0 rounded outline-none focus-visible:ring-2 focus-visible:ring-ring"
244
+ >
245
+ {row.collapsed ? (
246
+ <ChevronRight aria-hidden="true" className="size-4" />
247
+ ) : (
248
+ <ChevronDown aria-hidden="true" className="size-4" />
249
+ )}
250
+ </button>
251
+ ) : (
252
+ <span aria-hidden="true" className="size-4 shrink-0" />
253
+ )}
254
+ <span
255
+ aria-hidden="true"
256
+ className={`size-2 shrink-0 rounded-full ${toneOf(row.task.status, statuses).dot}`}
257
+ />
258
+ <button
259
+ type="button"
260
+ onClick={onOpen}
261
+ className="min-w-0 flex-1 truncate rounded text-left text-sm outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
262
+ >
263
+ {row.task.title}
264
+ <span className="sr-only">
265
+ {` — ${labelOf(row.task.status, statuses)}`}
266
+ {span === null
267
+ ? ""
268
+ : ` — ${i18n.t("board.gantt.spanLabel", {
269
+ start: date(span.startAt),
270
+ end: date(span.endAt),
271
+ })}`}
272
+ {open.length === 0
273
+ ? ""
274
+ : ` — ${i18n.t("board.gantt.waitsFor", {
275
+ tasks: open.map((task) => task.title).join(", "),
276
+ })}`}
277
+ </span>
278
+ </button>
279
+ {open.length > 0 ? (
280
+ <span className="shrink-0 rounded-md border border-destructive px-1 text-xs text-destructive">
281
+ {i18n.t("board.blocked")}
282
+ </span>
283
+ ) : null}
284
+ </>
285
+ );
286
+ }
@@ -0,0 +1,174 @@
1
+ import type { BoardStatus, BoardTask } from "@anchrd/intel-contract";
2
+ import { MultiDirectedGraph } from "graphology";
3
+ import { toneOf } from "@/board/board-status/board-status.ts";
4
+ import { resolveGraphColor } from "@/node-graph/node-graph.ts";
5
+
6
+ /**
7
+ * The two things a board edge can be, and they must never look the same.
8
+ *
9
+ * ⚠️ `parentId` and `dependsOn` are different relations with different rules — one is the
10
+ * hierarchy and is checked for cycles on its own, the other is an order of work and is checked
11
+ * separately, and a subtask waiting on its own epic is legal precisely because they are not one
12
+ * graph (#285). Drawn identically, the picture would claim they are, and the one question a board
13
+ * graph exists to answer — what is waiting on what — would be unreadable.
14
+ */
15
+ export type BoardEdgeKind = "parent" | "depends";
16
+
17
+ // The task and everything under it. `null` is the whole board.
18
+ export function subtreeOf(tasks: BoardTask[], rootId: string | null): BoardTask[] {
19
+ if (rootId === null) return tasks;
20
+ const kept = new Set([rootId]);
21
+ // Bounded by the contract's maximum depth of five, so a sweep per level is cheap and needs no
22
+ // ordering assumptions about the list.
23
+ for (let grew = true; grew; ) {
24
+ grew = false;
25
+ for (const task of tasks) {
26
+ if (task.parentId !== null && kept.has(task.parentId) && !kept.has(task.id)) {
27
+ kept.add(task.id);
28
+ grew = true;
29
+ }
30
+ }
31
+ }
32
+ return tasks.filter((task) => kept.has(task.id));
33
+ }
34
+
35
+ // How deep a task sits, for the layout. Counted against the tasks actually drawn, so a subtree
36
+ // filter puts its own root on the top row instead of indenting it by where it came from.
37
+ function depthsOf(tasks: BoardTask[]): Map<string, number> {
38
+ const byId = new Map(tasks.map((task) => [task.id, task]));
39
+ const depths = new Map<string, number>();
40
+ const depthOf = (task: BoardTask, seen: Set<string>): number => {
41
+ const known = depths.get(task.id);
42
+ if (known !== undefined) return known;
43
+ const parent = task.parentId === null ? undefined : byId.get(task.parentId);
44
+ // A cycle cannot be written (#285); if one ever were, this stops rather than recursing.
45
+ const depth = parent === undefined || seen.has(parent.id) ? 0 : depthOf(parent, seen) + 1;
46
+ seen.add(task.id);
47
+ depths.set(task.id, depth);
48
+ return depth;
49
+ };
50
+ for (const task of tasks) depthOf(task, new Set([task.id]));
51
+ return depths;
52
+ }
53
+
54
+ export interface BoardGraphLabels {
55
+ parent: string;
56
+ depends: string;
57
+ }
58
+
59
+ /**
60
+ * The board as Sigma draws it (anchrd/intel#286).
61
+ *
62
+ * ⚠️ Laid out by depth rather than on a spiral like the relation graph. A board's edges are mostly
63
+ * `parentId`, and a spiral scatters a parent and its children across the circle — the hierarchy is
64
+ * then present in the data and invisible in the picture. Rows by depth put it back, and the
65
+ * dependency edges become what they are: the lines that cut across.
66
+ *
67
+ * ⚠️ An edge is dropped when either end is not drawn, never drawn to an invented node. Under a
68
+ * subtree filter a dependency regularly points outside the subtree, and a placeholder for it would
69
+ * put a task on screen that the filter was asked to leave off.
70
+ */
71
+ export function createBoardGraph(
72
+ tasks: BoardTask[],
73
+ statuses: BoardStatus[],
74
+ blockedIds: ReadonlySet<string>,
75
+ labels: BoardGraphLabels,
76
+ ): MultiDirectedGraph {
77
+ const graph = new MultiDirectedGraph();
78
+ const structure = resolveGraphColor("--border", "--muted-foreground");
79
+ const dependency = resolveGraphColor("--destructive", "--foreground");
80
+
81
+ /**
82
+ * ⚠️ One node per id, however often the list names it (anchrd/intel#321).
83
+ *
84
+ * `graph.addNode` refuses a repeated key the way `addDirectedEdgeWithKey` does — and it runs
85
+ * first, so a board carrying two tasks with one id threw `UsageGraphError` before a single edge
86
+ * was reached and took this view off the screen for everybody looking at that board.
87
+ *
88
+ * ⚠️ Through the app nothing arrives here twice any more: the bundle import refuses such a
89
+ * document, and `orderedTasks` folds a stored one before any view sees it. It stays because this
90
+ * function is EXPORTED and takes whatever list it is handed — a view may not be the thing that
91
+ * turns odd data into a blank screen, the same reason the repeated dependency below is folded
92
+ * rather than trusted (anchrd/intel#318). The FIRST mention wins, which is the entry
93
+ * `orderedTasks` keeps, so the graph and the lanes cannot disagree about which of the pair is on
94
+ * the board.
95
+ *
96
+ * ⚠️ Folded here rather than skipped at `addNode`, because the row widths below are counted from
97
+ * this list too. A repeat that was merely not drawn would leave its level one card wider than it
98
+ * is, and every node on that level would sit off the axis the layout centres on.
99
+ */
100
+ const drawn = new Set<string>();
101
+ const nodes = tasks.filter((task) => {
102
+ if (drawn.has(task.id)) return false;
103
+ drawn.add(task.id);
104
+ return true;
105
+ });
106
+ const depths = depthsOf(nodes);
107
+
108
+ // ⚠️ Counted first, placed second, so every row can be CENTRED. Laid out left to right from zero,
109
+ // a level of six and a level of one form a wedge that reads as a hierarchy which is not there —
110
+ // and Sigma rescales to the bounding box, so one wide level squashes every other one flat.
111
+ const widths = new Map<number, number>();
112
+ for (const task of nodes) {
113
+ const depth = depths.get(task.id) ?? 0;
114
+ widths.set(depth, (widths.get(depth) ?? 0) + 1);
115
+ }
116
+ const placed = new Map<number, number>();
117
+
118
+ for (const task of nodes) {
119
+ const depth = depths.get(task.id) ?? 0;
120
+ const column = placed.get(depth) ?? 0;
121
+ placed.set(depth, column + 1);
122
+ graph.addNode(task.id, {
123
+ label: task.title,
124
+ x: (column - ((widths.get(depth) ?? 1) - 1) / 2) * 2.2,
125
+ // Down the screen as depth grows, which is the direction a hierarchy is read in.
126
+ y: -depth * 2.4,
127
+ // A blocked task is bigger as well as ringed by its edges, so it is findable in a graph one
128
+ // is looking at from far enough away that the incoming arrows are a tangle.
129
+ size: blockedIds.has(task.id) ? 9 : 7,
130
+ // ⚠️ The status, resolved from the SAME ramp the lanes and the calendar use. Sigma paints to
131
+ // a canvas and cannot read a class, so the token is turned into sRGB here — and where a
132
+ // theme has no such token, `undefined` leaves Sigma its own default rather than putting an
133
+ // Anchrd colour into a customer's board.
134
+ color: resolveGraphColor(toneOf(task.status, statuses).token, "--foreground"),
135
+ });
136
+ }
137
+
138
+ for (const task of nodes) {
139
+ if (task.parentId !== null && drawn.has(task.parentId)) {
140
+ graph.addDirectedEdgeWithKey(`parent:${task.id}`, task.parentId, task.id, {
141
+ label: labels.parent,
142
+ // Thin, unlabelled by an arrowhead, in the border colour: structure is the paper the work
143
+ // is drawn on, not a step in it.
144
+ type: "line",
145
+ size: 1,
146
+ ...(structure ? { color: structure } : {}),
147
+ });
148
+ }
149
+ /**
150
+ * ⚠️ One arrow per pair, however often the list names it (anchrd/intel#318).
151
+ *
152
+ * The key is minted from the pair and graphology REFUSES a second one, so a task naming the
153
+ * same dependency twice threw `UsageGraphError` out of this loop and took the graph view of
154
+ * that board off the screen — for everybody looking at it, not only for whoever wrote it. The
155
+ * write path refuses a repeat now and a stored one is folded away on read, so nothing arriving
156
+ * through Intel carries one; this stays because a view may not be the thing that turns odd data
157
+ * into a blank screen, and because the same reading is already in `ganttLinks` (#293): waiting
158
+ * twice for a task is waiting for it once, and the second arrow would draw nothing extra.
159
+ */
160
+ const drawnDependencies = new Set<string>();
161
+ for (const dependency_ of task.dependsOn) {
162
+ if (!drawn.has(dependency_) || drawnDependencies.has(dependency_)) continue;
163
+ drawnDependencies.add(dependency_);
164
+ // From what is waited FOR to what waits, so the arrows run the way the work does.
165
+ graph.addDirectedEdgeWithKey(`depends:${task.id}:${dependency_}`, dependency_, task.id, {
166
+ label: labels.depends,
167
+ type: "arrow",
168
+ size: 2.5,
169
+ ...(dependency ? { color: dependency } : {}),
170
+ });
171
+ }
172
+ }
173
+ return graph;
174
+ }
@@ -0,0 +1,168 @@
1
+ import { ControlsContainer, SigmaContainer, useCamera, useRegisterEvents } from "@react-sigma/core";
2
+ import { LocateFixed, Minus, Plus } from "lucide-react";
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { EdgeArrowProgram, EdgeRectangleProgram } from "sigma/rendering";
5
+ import { shownTasks } from "@/board/board-data/board-data.ts";
6
+ import { toneOf } from "@/board/board-status/board-status.ts";
7
+ import type { BoardViewProps } from "@/board/board-views/board-views.types.ts";
8
+ import { useI18n } from "@/i18n/i18n-context.tsx";
9
+ import { resolveGraphColor } from "@/node-graph/node-graph.ts";
10
+ import { createBoardGraph, subtreeOf } from "./board-graph.ts";
11
+
12
+ function GraphEvents({ select }: { select(taskId: string): void }) {
13
+ const registerEvents = useRegisterEvents();
14
+ useEffect(() => {
15
+ registerEvents({ clickNode: ({ node }) => select(node) });
16
+ }, [registerEvents, select]);
17
+ return null;
18
+ }
19
+
20
+ function GraphControls({ labels }: { labels: { in: string; out: string; fit: string } }) {
21
+ const camera = useCamera({ duration: 180, factor: 1.5 });
22
+ const controls = [
23
+ { label: labels.in, action: camera.zoomIn, icon: Plus },
24
+ { label: labels.out, action: camera.zoomOut, icon: Minus },
25
+ { label: labels.fit, action: camera.reset, icon: LocateFixed },
26
+ ];
27
+ return (
28
+ <ControlsContainer className="!m-4 flex overflow-hidden rounded-lg border bg-card shadow-lg">
29
+ {controls.map(({ label, action, icon: Icon }) => (
30
+ <button
31
+ key={label}
32
+ type="button"
33
+ aria-label={label}
34
+ onClick={() => action()}
35
+ className="grid size-9 place-items-center border-r text-card-foreground outline-none last:border-r-0 hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
36
+ >
37
+ <Icon aria-hidden="true" className="size-4" />
38
+ </button>
39
+ ))}
40
+ </ControlsContainer>
41
+ );
42
+ }
43
+
44
+ /**
45
+ * The board as a graph (anchrd/intel#286).
46
+ *
47
+ * ⚠️ On the Sigma stack the relation graph already stands on — same container, same controls, same
48
+ * token-resolving colour helper. A second renderer for a second kind of graph would be two things
49
+ * to keep looking like Intel, and the first customer theme would separate them.
50
+ */
51
+ export function BoardGraph({ board, select, showArchived }: BoardViewProps) {
52
+ const i18n = useI18n();
53
+ const [root, setRoot] = useState<string>("");
54
+
55
+ const tasks = useMemo(() => shownTasks(board.tasks, showArchived), [board.tasks, showArchived]);
56
+ // Only tasks that have something under them are worth offering: filtering to a leaf draws one
57
+ // node, which is a picture of nothing.
58
+ const roots = useMemo(
59
+ () => tasks.filter((task) => (board.childrenOf.get(task.id) ?? []).length > 0),
60
+ [tasks, board.childrenOf],
61
+ );
62
+ const drawn = useMemo(
63
+ () => subtreeOf(tasks, root === "" || !tasks.some((task) => task.id === root) ? null : root),
64
+ [tasks, root],
65
+ );
66
+ const blockedIds = useMemo(
67
+ () => new Set(drawn.filter((task) => board.blockedBy(task).length > 0).map((task) => task.id)),
68
+ [drawn, board.blockedBy],
69
+ );
70
+ const graph = useMemo(
71
+ () =>
72
+ createBoardGraph(drawn, board.statuses, blockedIds, {
73
+ parent: i18n.t("board.graph.parentEdge"),
74
+ depends: i18n.t("board.graph.dependsEdge"),
75
+ }),
76
+ [drawn, board.statuses, blockedIds, i18n],
77
+ );
78
+
79
+ const label = resolveGraphColor("--foreground");
80
+ const edgeLabel = resolveGraphColor("--muted-foreground", "--foreground");
81
+
82
+ return (
83
+ <section aria-label={i18n.t("board.view.graph")} className="flex min-h-0 flex-1 flex-col">
84
+ <div className="flex flex-wrap items-center gap-4 border-b px-6 py-3">
85
+ <label className="flex items-center gap-2 text-sm">
86
+ <span className="text-muted-foreground">{i18n.t("board.graph.subtree")}</span>
87
+ <select
88
+ value={root}
89
+ onChange={(event) => setRoot(event.currentTarget.value)}
90
+ className="h-8 max-w-56 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
91
+ >
92
+ <option value="">{i18n.t("board.graph.wholeBoard")}</option>
93
+ {roots.map((task) => (
94
+ <option key={task.id} value={task.id}>
95
+ {task.title}
96
+ </option>
97
+ ))}
98
+ </select>
99
+ </label>
100
+ {/* ⚠️ The legend is not decoration: it is what turns a colour into a status and a line into
101
+ a relation for a reader who has not been told. Both edge kinds are in it, because
102
+ telling them apart is the whole reason there are two. */}
103
+ <ul className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
104
+ {board.statuses.map((status) => (
105
+ <li key={status.id} className="flex items-center gap-1.5">
106
+ <span
107
+ aria-hidden="true"
108
+ className={`size-2 rounded-full ${toneOf(status.id, board.statuses).dot}`}
109
+ />
110
+ {status.label}
111
+ </li>
112
+ ))}
113
+ <li className="flex items-center gap-1.5">
114
+ <span aria-hidden="true" className="h-px w-5 bg-border" />
115
+ {i18n.t("board.graph.parentEdge")}
116
+ </li>
117
+ <li className="flex items-center gap-1.5">
118
+ <span aria-hidden="true" className="h-0.5 w-5 bg-destructive" />
119
+ {i18n.t("board.graph.dependsEdge")}
120
+ </li>
121
+ </ul>
122
+ </div>
123
+ <div className="min-h-0 flex-1 bg-muted/20">
124
+ {drawn.length === 0 ? (
125
+ <div className="grid h-full place-items-center p-8 text-sm text-muted-foreground">
126
+ {i18n.t("board.noTasks")}
127
+ </div>
128
+ ) : (
129
+ <SigmaContainer
130
+ // ⚠️ No `key`, deliberately. `SigmaContainer` rebuilds its instance whenever the `graph`
131
+ // prop changes and carries the previous camera state across while doing it, so a new
132
+ // graph is all a changed subtree filter needs — the same way the relation graph passes
133
+ // it (`node-graph.tsx`). A key would remount the component instead, which skips that
134
+ // restore and throws away the reader's zoom and pan on every added or deleted task.
135
+ graph={graph}
136
+ className="h-full w-full"
137
+ settings={{
138
+ labelRenderedSizeThreshold: 4,
139
+ renderEdgeLabels: true,
140
+ edgeLabelSize: 10,
141
+ // ⚠️ Sigma fits the NODES to the canvas and knows nothing about how wide their labels
142
+ // are, so the outermost task of a level always has its name half outside the frame.
143
+ // The padding is what the labels stand in.
144
+ stagePadding: 90,
145
+ // ⚠️ Registered by name rather than left to the defaults: the two edge kinds are told
146
+ // apart by whether they carry an arrowhead, and an unregistered `type` silently falls
147
+ // back to the default program — which would draw both of them the same and undo the
148
+ // one thing this graph has to get right.
149
+ edgeProgramClasses: { line: EdgeRectangleProgram, arrow: EdgeArrowProgram },
150
+ defaultEdgeType: "line",
151
+ ...(label ? { labelColor: { color: label } } : {}),
152
+ ...(edgeLabel ? { edgeLabelColor: { color: edgeLabel } } : {}),
153
+ }}
154
+ >
155
+ <GraphEvents select={select} />
156
+ <GraphControls
157
+ labels={{
158
+ in: i18n.t("node.graphZoomIn"),
159
+ out: i18n.t("node.graphZoomOut"),
160
+ fit: i18n.t("node.graphReset"),
161
+ }}
162
+ />
163
+ </SigmaContainer>
164
+ )}
165
+ </div>
166
+ </section>
167
+ );
168
+ }