@protoboardai/views 0.0.0-stage → 0.2.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 (56) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.md +21 -2
  4. package/package.json +46 -4
  5. package/src/applies.ts +54 -0
  6. package/src/builtins.tsx +89 -0
  7. package/src/drawing/BoardDrawingView.tsx +515 -0
  8. package/src/drawing/CircuitPcbView.tsx +494 -0
  9. package/src/drawing/DrawingControls.tsx +39 -0
  10. package/src/drawing/boards.ts +82 -0
  11. package/src/drawing/circuit.ts +413 -0
  12. package/src/drawing/kicadSchematic.ts +277 -0
  13. package/src/drawing/palettes.ts +150 -0
  14. package/src/drawing/svg.ts +445 -0
  15. package/src/drawing/usePanZoom.ts +400 -0
  16. package/src/icons.tsx +69 -0
  17. package/src/index.ts +33 -0
  18. package/src/inspector/DrcPanel.tsx +302 -0
  19. package/src/inspector/SelectionInspector.tsx +392 -0
  20. package/src/inspector/index.ts +9 -0
  21. package/src/inspector/model.ts +224 -0
  22. package/src/pair/PairView.tsx +314 -0
  23. package/src/pair/model.ts +206 -0
  24. package/src/parts/BomEditor.tsx +564 -0
  25. package/src/parts/CardPages.tsx +317 -0
  26. package/src/parts/CategoryList.tsx +72 -0
  27. package/src/parts/CloudPanel.tsx +160 -0
  28. package/src/parts/GlbPreview.tsx +116 -0
  29. package/src/parts/LibrariesManager.tsx +251 -0
  30. package/src/parts/LibraryBrowser.tsx +191 -0
  31. package/src/parts/LibraryPage.tsx +168 -0
  32. package/src/parts/PartCard.tsx +274 -0
  33. package/src/parts/PartDetail.tsx +613 -0
  34. package/src/parts/PartStatus.tsx +54 -0
  35. package/src/parts/Prices.tsx +241 -0
  36. package/src/parts/ProjectParts.tsx +174 -0
  37. package/src/parts/ProjectPartsPage.tsx +264 -0
  38. package/src/parts/SettingsPage.tsx +268 -0
  39. package/src/parts/Surface.tsx +51 -0
  40. package/src/parts/Windowed.tsx +70 -0
  41. package/src/parts/carousel.tsx +344 -0
  42. package/src/parts/index.ts +32 -0
  43. package/src/parts/library-data.ts +726 -0
  44. package/src/parts/matte.ts +69 -0
  45. package/src/parts/model.ts +590 -0
  46. package/src/parts/part-pages.ts +379 -0
  47. package/src/parts/part-status.ts +88 -0
  48. package/src/parts/revision.ts +11 -0
  49. package/src/parts/three.d.ts +5 -0
  50. package/src/parts/ui.tsx +335 -0
  51. package/src/parts/views.tsx +433 -0
  52. package/src/registry.ts +107 -0
  53. package/src/tabs.ts +118 -0
  54. package/src/theme.ts +225 -0
  55. package/src/types.ts +93 -0
  56. package/src/ui/Select.tsx +99 -0
@@ -0,0 +1,314 @@
1
+ /**
2
+ * The pair view (PB-905): two linked modules side by side, as the website's
3
+ * UHD pair comparator draws them (uhd docs/visualizer-spec.md). Each stored
4
+ * link between them is a band of shared territory across both columns, its
5
+ * pill naming the link, protocol and state; its conductors run straight
6
+ * across, leaf and pin on each side. Below, the pairings DRC would also
7
+ * allow, and every interface of both modules in a census, grouped and
8
+ * filterable, so a part with dozens of pins (an RP2040) stays readable.
9
+ */
10
+ import { useMemo, useState } from "react";
11
+ import { EmptyState, Icon, IconButton, ScrollArea, cx } from "@deltarobotics/uhd-viewer/components";
12
+ import { ViewIcon } from "../icons.js";
13
+ import type { ViewProps } from "../types.js";
14
+ import { Select } from "../ui/Select.js";
15
+ import { censusOf, findPair, pairsOf, potentialsOf, type CensusEntry, type LinkState, type Pair, type PairLink, type PairSide } from "./model.js";
16
+
17
+ const STATE: Record<LinkState, { color: string; label: string }> = {
18
+ configured: { color: "var(--uhd-drc-configured)", label: "configured" },
19
+ partial: { color: "var(--uhd-drc-partial)", label: "partial" },
20
+ incompatible: { color: "var(--uhd-drc-incompatible)", label: "incompatible" },
21
+ unconfigured: { color: "var(--uhd-drc-unconfigured)", label: "not configured" },
22
+ };
23
+
24
+ const soft = (color: string, pct = 8) => `color-mix(in oklab, ${color} ${pct}%, transparent)`;
25
+ const roles = (i: { protocols: { type: string; roles: string[] }[] }) => i.protocols.map((p) => `${p.type} · ${p.roles.join("/")}`).join(", ");
26
+
27
+ export function PairView({ module, bundle, path, host, view, params }: ViewProps) {
28
+ const pairs = useMemo(() => pairsOf(module, bundle, path), [module, bundle, path]);
29
+ const [picked, setPicked] = useState<string | undefined>(undefined);
30
+ const pair = pairs.find((p) => p.key === picked) ?? findPair(pairs, params) ?? pairs[0];
31
+ const [filter, setFilter] = useState("");
32
+
33
+ if (!pair) {
34
+ return (
35
+ <div className="grid size-full place-items-center bg-canvas">
36
+ <EmptyState icon={<ViewIcon name={view.icon} size={28} strokeWidth={1.25} />} title="Pair">
37
+ {module.name} links no two modules yet.
38
+ </EmptyState>
39
+ </div>
40
+ );
41
+ }
42
+ const childLabel = (child: string) => (child ? (module.children?.find((c) => c.id === child)?.name ?? child) : `${module.name} (boundary)`);
43
+ const census = { a: censusOf(module, pair, "a", childLabel), b: censusOf(module, pair, "b", childLabel) };
44
+ const potentials = potentialsOf(pair);
45
+ const linkId = (l: PairLink) => (path ? `${path}/${l.link.id}` : l.link.id);
46
+
47
+ return (
48
+ <div className="pb-pair relative flex size-full min-h-[260px] min-w-[320px] flex-col overflow-hidden bg-canvas text-fg">
49
+ <ScrollArea axis="both" className="min-h-0 flex-1" contentClassName="mx-auto min-w-[560px] max-w-[1180px] px-4 pt-[calc(var(--uhd-inset-top,0px)+14px)] pb-6">
50
+ {pairs.length > 1 && (
51
+ <div className="mb-3 flex items-center gap-2">
52
+ <span className="font-label text-[10px] tracking-[0.1em] text-fg-muted uppercase">Pair</span>
53
+ <Select
54
+ label="Pair"
55
+ tooltip={`${pairs.length} linked pairs on this level`}
56
+ value={pair.key}
57
+ onChange={setPicked}
58
+ options={pairs.map((p) => ({ value: p.key, label: `${p.a.label} ↔ ${p.b.label} · ${p.links.length} link${p.links.length === 1 ? "" : "s"}` }))}
59
+ />
60
+ </div>
61
+ )}
62
+
63
+ {/* the two modules, and the verdict between them */}
64
+ <div className="grid grid-cols-[minmax(0,1fr)_104px_minmax(0,1fr)] items-stretch">
65
+ <ModuleCard side={pair.a} census={census.a} align="left" onSource={host.openSource && (() => host.openSource!({ defId: module.id, instancePath: pair.a.path || undefined }))} />
66
+ <div className="flex flex-col items-center justify-center gap-1 px-1">
67
+ <span className="h-px w-full" style={{ background: STATE[pair.state].color }} />
68
+ <span className="rounded-full border px-2 py-0.5 text-center font-code text-[10px] whitespace-nowrap uppercase" style={{ borderColor: STATE[pair.state].color, color: STATE[pair.state].color }}>
69
+ {pair.links.length} link{pair.links.length === 1 ? "" : "s"}
70
+ </span>
71
+ <span className="font-code text-[10px] tracking-[0.06em] uppercase" style={{ color: STATE[pair.state].color }}>
72
+ {STATE[pair.state].label}
73
+ </span>
74
+ </div>
75
+ <ModuleCard side={pair.b} census={census.b} align="right" onSource={host.openSource && (() => host.openSource!({ defId: module.id, instancePath: pair.b.path || undefined }))} />
76
+ </div>
77
+
78
+ <SectionTitle title="Connections" count={pair.links.length} />
79
+ <div className="flex flex-col gap-3">
80
+ {groupsOf(pair.links).map((g) =>
81
+ g.links.length > GROUP_AT ? (
82
+ <GroupBand key={g.protocol} protocol={g.protocol} links={g.links} onSelect={(l) => host.selectionChanged?.({ kind: "link", id: linkId(l), defId: module.id })} />
83
+ ) : (
84
+ g.links.map((l) => (
85
+ <Band key={l.link.id} l={l} onSelect={() => host.selectionChanged?.({ kind: "link", id: linkId(l), defId: module.id })} onSource={host.openSource && (() => host.openSource!({ defId: module.id, instancePath: path || undefined }))} />
86
+ ))
87
+ ),
88
+ )}
89
+ </div>
90
+
91
+ {potentials.shown.length > 0 && (
92
+ <>
93
+ <SectionTitle title="Could also connect" count={potentials.shown.length + potentials.hidden} />
94
+ <div className="flex flex-col gap-1.5">
95
+ {potentials.shown.map((p) => (
96
+ <div key={`${p.a}~${p.b}`} className="grid grid-cols-[minmax(0,1fr)_minmax(120px,0.6fr)_minmax(0,1fr)] items-center rounded-panel border border-dashed border-line px-3 py-1.5 font-code text-[11px]">
97
+ <span className="truncate text-fg">{p.a}</span>
98
+ <span className="flex items-center gap-1.5 px-2 text-fg-muted">
99
+ <span className="h-0 flex-1 border-t border-dashed" style={{ borderColor: p.clean ? STATE.configured.color : STATE.partial.color }} />
100
+ <span className="shrink-0">{p.protocol}</span>
101
+ <span className="h-0 flex-1 border-t border-dashed" style={{ borderColor: p.clean ? STATE.configured.color : STATE.partial.color }} />
102
+ </span>
103
+ <span className="truncate text-right text-fg">{p.b}</span>
104
+ </div>
105
+ ))}
106
+ {potentials.hidden > 0 && <div className="px-1 font-code text-[10px] text-fg-faint">the {potentials.shown.length} cleanest shown · {potentials.hidden} more</div>}
107
+ </div>
108
+ </>
109
+ )}
110
+
111
+ <div className="mt-6 mb-2 flex items-center gap-3">
112
+ <h3 className="m-0 font-label text-[11px] font-normal tracking-[0.1em] text-fg-muted uppercase">Every interface</h3>
113
+ <span className="h-px flex-1 bg-line" />
114
+ <label className="flex h-7 w-[min(260px,40%)] items-center gap-1.5 rounded-md border border-line bg-sunken px-2 focus-within:outline focus-within:outline-1 focus-within:outline-focus">
115
+ <Icon name="search" size={13} className="shrink-0 text-fg-faint" aria-hidden />
116
+ <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filter interfaces" aria-label="Filter interfaces" className="min-w-0 flex-1 border-0 bg-transparent p-0 font-ui text-[12px] text-fg outline-none placeholder:text-fg-faint" />
117
+ </label>
118
+ </div>
119
+ <div className="grid grid-cols-2 gap-4">
120
+ <Census entries={census.a} filter={filter} />
121
+ <Census entries={census.b} filter={filter} />
122
+ </div>
123
+ </ScrollArea>
124
+ </div>
125
+ );
126
+ }
127
+
128
+ /** More links of one protocol than this between a pair (a part's pins on a board's nets) draw as one band with a row each. */
129
+ const GROUP_AT = 6;
130
+
131
+ function groupsOf(links: PairLink[]): { protocol: string; links: PairLink[] }[] {
132
+ const by = new Map<string, PairLink[]>();
133
+ for (const l of links) by.set(l.result.protocol, [...(by.get(l.result.protocol) ?? []), l]);
134
+ return [...by].map(([protocol, list]) => ({ protocol, links: list }));
135
+ }
136
+
137
+ /** Many links of one protocol: one band, a row per link, each with its state. */
138
+ function GroupBand({ protocol, links, onSelect }: { protocol: string; links: PairLink[]; onSelect: (l: PairLink) => void }) {
139
+ const state = links.some((l) => l.result.state === "incompatible") ? "incompatible" : links.some((l) => l.result.state === "partial") ? "partial" : links.every((l) => l.result.state === "unconfigured") ? "unconfigured" : "configured";
140
+ const s = STATE[state];
141
+ return (
142
+ <section className="relative mt-2 rounded-panel border-[1.5px] px-3 pt-4 pb-2" style={{ borderColor: s.color, background: soft(s.color, 6) }}>
143
+ <div className="absolute -top-2.5 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full border bg-surface px-2.5 py-0.5 font-code text-[10px] whitespace-nowrap uppercase" style={{ borderColor: s.color, color: s.color }}>
144
+ {protocol} · {links.length} links · {s.label}
145
+ </div>
146
+ <div className="grid grid-cols-[minmax(0,1fr)_minmax(80px,0.55fr)_minmax(0,1fr)] items-center gap-y-0.5 font-code text-[11px]">
147
+ {links.map((l) => {
148
+ const c = STATE[l.result.state].color;
149
+ return (
150
+ <div key={l.link.id} className="contents cursor-pointer" onClick={() => onSelect(l)} title={l.link.name ?? l.link.id}>
151
+ <div className="flex min-w-0 items-center justify-end gap-1.5 text-fg">
152
+ <span className="truncate">{l.aIface.name ?? l.aIface.id}</span>
153
+ <span className="size-[7px] shrink-0 rounded-full" style={{ background: c }} />
154
+ </div>
155
+ <div className="relative flex h-5 items-center">
156
+ <span className="absolute inset-x-0 top-1/2 h-[1.5px] -translate-y-1/2" style={{ background: c }} />
157
+ </div>
158
+ <div className="flex min-w-0 items-center gap-1.5 text-fg">
159
+ <span className="size-[7px] shrink-0 rounded-full" style={{ background: c }} />
160
+ <span className="truncate">{l.bIface.name ?? l.bIface.id}</span>
161
+ </div>
162
+ </div>
163
+ );
164
+ })}
165
+ </div>
166
+ </section>
167
+ );
168
+ }
169
+
170
+ function SectionTitle({ title, count }: { title: string; count?: number }) {
171
+ return (
172
+ <div className="mt-6 mb-2 flex items-center gap-3">
173
+ <h3 className="m-0 font-label text-[11px] font-normal tracking-[0.1em] text-fg-muted uppercase">
174
+ {title}
175
+ {count !== undefined && <span className="text-fg-faint"> · {count}</span>}
176
+ </h3>
177
+ <span className="h-px flex-1 bg-line" />
178
+ </div>
179
+ );
180
+ }
181
+
182
+ function ModuleCard({ side, census, align, onSource }: { side: PairSide; census: CensusEntry[]; align: "left" | "right"; onSource?: () => void }) {
183
+ const here = census.filter((c) => c.status === "here").length;
184
+ const elsewhere = census.filter((c) => c.status === "elsewhere").length;
185
+ const icon = side.def.display?.icon ?? "box";
186
+ return (
187
+ <div className={cx("flex min-w-0 flex-col gap-1 rounded-panel border border-line bg-surface px-3 py-2.5 shadow-panel outline outline-1 -outline-offset-1 outline-contrast", align === "right" && "items-end text-right")}>
188
+ <div className={cx("flex w-full min-w-0 items-center gap-2", align === "right" && "flex-row-reverse")}>
189
+ <Icon name={icon} size={16} className="shrink-0 text-accent" aria-hidden />
190
+ <span className="min-w-0 flex-1 truncate font-label text-[13px] tracking-[0.04em] uppercase">{side.label}</span>
191
+ {onSource && <IconButton icon="file-code" size="sm" label="Open source" onClick={onSource} />}
192
+ </div>
193
+ <div className="w-full truncate text-[11px] text-fg-muted">{side.def.name}</div>
194
+ <div className="w-full truncate font-code text-[10px] text-fg-faint">{side.def.id}</div>
195
+ <div className={cx("mt-1 flex flex-wrap gap-x-3 gap-y-0.5 font-code text-[10px] text-fg-muted", align === "right" && "justify-end")}>
196
+ <span>{census.length} interfaces</span>
197
+ <span style={{ color: here ? STATE.configured.color : undefined }}>{here} linked here</span>
198
+ {elsewhere > 0 && <span>{elsewhere} elsewhere</span>}
199
+ </div>
200
+ </div>
201
+ );
202
+ }
203
+
204
+ /** One link as shared territory across both columns. */
205
+ function Band({ l, onSelect, onSource }: { l: PairLink; onSelect: () => void; onSource?: () => void }) {
206
+ const s = STATE[l.result.state];
207
+ const name = l.link.name ?? l.link.id;
208
+ const errors = l.result.diagnostics.filter((d) => d.severity !== "info");
209
+ return (
210
+ <section
211
+ className="relative mt-2 rounded-panel border-[1.5px] px-3 pt-4 pb-2.5 outline-offset-2 hover:outline hover:outline-1 hover:outline-dashed hover:outline-contrast-active"
212
+ style={{ borderColor: s.color, background: soft(s.color, 7) }}
213
+ onClick={onSelect}
214
+ aria-label={`${name}: ${l.result.protocol}, ${s.label}`}
215
+ >
216
+ <div className="absolute -top-2.5 left-1/2 flex max-w-[80%] -translate-x-1/2 items-center gap-1 rounded-full border bg-surface px-2.5 py-0.5 font-code text-[10px] whitespace-nowrap uppercase" style={{ borderColor: s.color, color: s.color }}>
217
+ <span className="truncate">{name}</span>
218
+ <span className="opacity-70">· {l.result.protocol} · {s.label}</span>
219
+ {l.link.childLinks && <Icon name="lock" size={10} aria-label="stored conductor map" />}
220
+ </div>
221
+ {onSource && (
222
+ <span className="absolute -top-3.5 right-2 rounded-md bg-surface">
223
+ <IconButton icon="file-code" size="sm" label="Open the link's source" onClick={(e) => (e.stopPropagation(), onSource())} />
224
+ </span>
225
+ )}
226
+ <div className="grid grid-cols-[minmax(0,1fr)_minmax(80px,0.55fr)_minmax(0,1fr)] items-center gap-y-0.5 font-code text-[11px]">
227
+ <div className="min-w-0 truncate text-fg" title={roles(l.aIface)}>
228
+ {l.aIface.name ?? l.aIface.id} <span className="text-fg-faint">{roles(l.aIface)}</span>
229
+ </div>
230
+ <span className="mx-2 h-[3px] rounded-full" style={{ background: s.color }} />
231
+ <div className="min-w-0 truncate text-right text-fg" title={roles(l.bIface)}>
232
+ <span className="text-fg-faint">{roles(l.bIface)}</span> {l.bIface.name ?? l.bIface.id}
233
+ </div>
234
+ {l.rows.map((r, i) => (
235
+ <Row key={i} r={r} color={s.color} />
236
+ ))}
237
+ </div>
238
+ {errors.length > 0 && (
239
+ <ul className="m-0 mt-2 list-none p-0">
240
+ {errors.map((d, i) => (
241
+ <li key={i} className="flex gap-1.5 text-[11px]" style={{ color: d.severity === "error" ? "var(--uhd-severity-error)" : "var(--uhd-severity-warning)" }}>
242
+ <Icon name={d.severity === "error" ? "circle-x" : "triangle-alert"} size={12} className="mt-0.5 shrink-0" aria-hidden />
243
+ <span className="text-fg-muted">{d.message}</span>
244
+ </li>
245
+ ))}
246
+ </ul>
247
+ )}
248
+ </section>
249
+ );
250
+ }
251
+
252
+ function Row({ r, color }: { r: PairLink["rows"][number]; color: string }) {
253
+ return (
254
+ <>
255
+ <div className="flex min-w-0 items-center justify-end gap-1.5 text-fg-muted">
256
+ <span className="truncate">{r.a}</span>
257
+ {r.aPin && <span className="shrink-0 text-fg-faint">{r.aPin}</span>}
258
+ <span className="size-[7px] shrink-0 rounded-full" style={{ background: color }} />
259
+ </div>
260
+ <div className="relative mx-0 flex h-5 items-center">
261
+ <span className="absolute inset-x-0 top-1/2 h-[1.5px] -translate-y-1/2" style={{ background: color }} />
262
+ <span className="relative mx-auto max-w-full truncate rounded-sm bg-surface px-1 text-[9.5px] text-fg-faint">
263
+ {r.a} → {r.b}
264
+ {r.locked && " · locked"}
265
+ </span>
266
+ </div>
267
+ <div className="flex min-w-0 items-center gap-1.5 text-fg-muted">
268
+ <span className="size-[7px] shrink-0 rounded-full" style={{ background: color }} />
269
+ {r.bPin && <span className="shrink-0 text-fg-faint">{r.bPin}</span>}
270
+ <span className="truncate">{r.b}</span>
271
+ </div>
272
+ </>
273
+ );
274
+ }
275
+
276
+ const GLYPH: Record<CensusEntry["status"], string> = { here: "circle-dot", elsewhere: "circle-arrow-out-up-right", free: "circle" };
277
+
278
+ function Census({ entries, filter }: { entries: CensusEntry[]; filter: string }) {
279
+ const q = filter.trim().toLowerCase();
280
+ const shown = q ? entries.filter((e) => `${e.iface.id} ${e.iface.name ?? ""} ${e.group} ${e.others.join(" ")}`.toLowerCase().includes(q)) : entries;
281
+ const groups = new Map<string, CensusEntry[]>();
282
+ for (const e of shown) groups.set(e.group, [...(groups.get(e.group) ?? []), e]);
283
+ const rank = (list: CensusEntry[]) => (list.some((e) => e.status === "here") ? 0 : list.some((e) => e.status === "elsewhere") ? 1 : 2);
284
+ const ordered = [...groups].sort((x, y) => rank(x[1]) - rank(y[1]) || x[0].localeCompare(y[0]));
285
+ if (!shown.length) return <div className="rounded-panel border border-line px-3 py-3 text-[11px] text-fg-faint">No interface matches.</div>;
286
+ return (
287
+ <div className="flex min-w-0 flex-col gap-2">
288
+ {ordered.map(([group, list]) => (
289
+ <details key={group} open={list.length <= 16 || rank(list) < 2 || Boolean(q)} className="group rounded-panel border border-line bg-surface outline outline-1 -outline-offset-1 outline-contrast">
290
+ <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-1.5 font-label text-[10px] tracking-[0.08em] text-fg-muted uppercase [&::-webkit-details-marker]:hidden">
291
+ <Icon name="chevron-right" size={12} className="shrink-0 transition-transform group-open:rotate-90" aria-hidden />
292
+ <span className="min-w-0 flex-1 truncate">{group}</span>
293
+ <span className="text-fg-faint">{list.length}</span>
294
+ </summary>
295
+ <ul className="m-0 list-none border-t border-line-subtle px-3 py-1">
296
+ {list.map((e) => (
297
+ <li key={e.iface.id} className="flex min-w-0 items-center gap-2 py-0.5 font-code text-[11px]">
298
+ <Icon
299
+ name={GLYPH[e.status]}
300
+ size={12}
301
+ className="shrink-0"
302
+ style={{ color: e.status === "here" ? STATE[e.state ?? "configured"].color : e.status === "elsewhere" ? "var(--uhd-text-muted)" : "var(--uhd-text-faint)" }}
303
+ aria-label={e.status === "here" ? "linked to the other module" : e.status === "elsewhere" ? "linked elsewhere" : "not linked"}
304
+ />
305
+ <span className={cx("min-w-0 truncate", e.status === "free" ? "text-fg-muted" : "text-fg")}>{e.iface.name ?? e.iface.id}</span>
306
+ <span className="ml-auto min-w-0 shrink truncate text-right text-[10px] text-fg-faint">{e.status === "elsewhere" ? `→ ${e.others.join(", ")}` : e.iface.protocols.map((p) => p.roles.join("/")).join(", ")}</span>
307
+ </li>
308
+ ))}
309
+ </ul>
310
+ </details>
311
+ ))}
312
+ </div>
313
+ );
314
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The pair view's model (PB-905): the pairs of modules a level links, every
3
+ * stored link between the two of a pair validated as the system checks
4
+ * validate it, and each module's interfaces in a census (linked to the other
5
+ * module, linked elsewhere, or free), with the pairings DRC would also allow.
6
+ * No React here.
7
+ */
8
+ import { validateLink, validatePair, type InterfaceDef, type InterfaceLink, type LinkResult, type ModuleDef, type UhdBundle } from "@deltarobotics/uhd";
9
+ import { definition } from "../applies.js";
10
+
11
+ /** One side of a pair: a child of the level, or the level's own boundary. */
12
+ export interface PairSide {
13
+ /** The child id; "" for the level's own interfaces (its boundary). */
14
+ child: string;
15
+ /** Instance path from the root. */
16
+ path: string;
17
+ def: ModuleDef;
18
+ label: string;
19
+ }
20
+
21
+ export type LinkState = LinkResult["state"];
22
+
23
+ export interface PairLink {
24
+ link: InterfaceLink;
25
+ result: LinkResult;
26
+ /** The link's ends as this pair is drawn (`a` on the left). */
27
+ aIface: InterfaceDef;
28
+ bIface: InterfaceDef;
29
+ /** The stored link has the pair's `b` on its `a` end. */
30
+ flipped: boolean;
31
+ /** One row per conductor, left and right as drawn. */
32
+ rows: { a: string; aPin?: string; b: string; bPin?: string; method: string; locked: boolean }[];
33
+ }
34
+
35
+ export interface Pair {
36
+ /** `a~b` by child id (sorted, so each pair once). */
37
+ key: string;
38
+ a: PairSide;
39
+ b: PairSide;
40
+ links: PairLink[];
41
+ /** The worst link state. */
42
+ state: LinkState;
43
+ }
44
+
45
+ const ORDER: LinkState[] = ["incompatible", "partial", "unconfigured", "configured"];
46
+ export const worst = (states: LinkState[]): LinkState => ORDER.find((s) => states.includes(s)) ?? "configured";
47
+
48
+ const endChild = (end: InterfaceLink["a"]) => ("child" in end ? end.child : "");
49
+ const join = (a: string, b: string) => (a ? `${a}/${b}` : b);
50
+
51
+ /** Every pair of modules the level's stored links join, the pair with the most links first. */
52
+ export function pairsOf(module: ModuleDef, bundle: UhdBundle, path: string): Pair[] {
53
+ const lookup = (id: string) => definition(bundle, id);
54
+ const side = (child: string): PairSide | undefined => {
55
+ if (!child) return { child: "", path, def: module, label: `${module.name} (boundary)` };
56
+ const ref = module.children?.find((c) => c.id === child);
57
+ const def = ref ? lookup(ref.moduleDefId) : undefined;
58
+ return ref && def ? { child, path: join(path, child), def, label: ref.name ?? def.name } : undefined;
59
+ };
60
+ const pairs = new Map<string, Pair>();
61
+ for (const link of module.links ?? []) {
62
+ const ca = endChild(link.a);
63
+ const cb = endChild(link.b);
64
+ if (ca === cb) continue;
65
+ const [first, second] = [ca, cb].sort();
66
+ const key = `${first}~${second}`;
67
+ let pair = pairs.get(key);
68
+ if (!pair) {
69
+ const a = side(first);
70
+ const b = side(second);
71
+ if (!a || !b) continue;
72
+ pair = { key, a, b, links: [], state: "configured" };
73
+ pairs.set(key, pair);
74
+ }
75
+ let result: LinkResult;
76
+ try {
77
+ result = validateLink(module, link, lookup);
78
+ } catch {
79
+ continue;
80
+ }
81
+ const flipped = ca !== pair.a.child;
82
+ const [ea, eb] = flipped ? [result.b, result.a] : [result.a, result.b];
83
+ pair.links.push({
84
+ link,
85
+ result,
86
+ aIface: ea.iface,
87
+ bIface: eb.iface,
88
+ flipped,
89
+ rows: result.children.map((c) => {
90
+ const [x, y] = flipped ? [c.b, c.a] : [c.a, c.b];
91
+ return { a: x.slotId ?? x.leafId, aPin: x.pin, b: y.slotId ?? y.leafId, bPin: y.pin, method: c.method, locked: c.locked };
92
+ }),
93
+ });
94
+ }
95
+ const out = [...pairs.values()].filter((p) => p.links.length);
96
+ for (const p of out) p.state = worst(p.links.map((l) => l.result.state));
97
+ // two parts before a part and the level's own boundary (a board's nets), then by how much joins them
98
+ const boundary = (p: Pair) => Number(p.a.child === "" || p.b.child === "");
99
+ return out.sort((x, y) => boundary(x) - boundary(y) || y.links.length - x.links.length || x.key.localeCompare(y.key));
100
+ }
101
+
102
+ /** A pair by what a view was opened with: `link` (a link id, or `owner/link`), or `a` and `b` (instance paths or child ids). */
103
+ export function findPair(pairs: Pair[], params: Readonly<Record<string, string>> | undefined): Pair | undefined {
104
+ if (!params) return undefined;
105
+ if (params.link) {
106
+ const id = params.link.split("/").pop();
107
+ const hit = pairs.find((p) => p.links.some((l) => l.link.id === id));
108
+ if (hit) return hit;
109
+ }
110
+ if (params.a !== undefined && params.b !== undefined) {
111
+ const name = (s: string) => s.split("/").pop() ?? s;
112
+ const want = new Set([params.a, params.b].flatMap((s) => [s, name(s)]));
113
+ return pairs.find((p) => [p.a, p.b].every((s) => want.has(s.path) || want.has(s.child)));
114
+ }
115
+ return undefined;
116
+ }
117
+
118
+ export type CensusStatus = "here" | "elsewhere" | "free";
119
+
120
+ export interface CensusEntry {
121
+ iface: InterfaceDef;
122
+ status: CensusStatus;
123
+ /** For `elsewhere`: the modules it is linked to (labels). */
124
+ others: string[];
125
+ /** For `here`: the link's state. */
126
+ state?: LinkState;
127
+ /** The group it is listed under (its domain and first protocol). */
128
+ group: string;
129
+ }
130
+
131
+ /**
132
+ * Every interface of one side: linked to the other side of the pair, linked
133
+ * to another module of the level, or free. Leaves composed into another
134
+ * interface of the same module (an I2C bus's SDA) are listed under it, not
135
+ * on their own.
136
+ */
137
+ export function censusOf(module: ModuleDef, pair: Pair, which: "a" | "b", labels: (child: string) => string): CensusEntry[] {
138
+ const me = pair[which];
139
+ const other = pair[which === "a" ? "b" : "a"];
140
+ const here = new Map<string, LinkState>();
141
+ const elsewhere = new Map<string, Set<string>>();
142
+ for (const link of module.links ?? []) {
143
+ for (const [end, far] of [
144
+ [link.a, link.b],
145
+ [link.b, link.a],
146
+ ] as const) {
147
+ if (endChild(end) !== me.child) continue;
148
+ const id = end.interfaceId;
149
+ if (endChild(far) === other.child) {
150
+ const state = pair.links.find((l) => l.link === link)?.result.state ?? "configured";
151
+ here.set(id, here.has(id) ? worst([here.get(id)!, state]) : state);
152
+ } else {
153
+ const s = elsewhere.get(id) ?? new Set<string>();
154
+ s.add(labels(endChild(far)));
155
+ elsewhere.set(id, s);
156
+ }
157
+ }
158
+ }
159
+ const composedInto = new Set(me.def.interfaces.flatMap((i) => (i.profiles?.[0] ? Object.values(i.profiles[0].bindings ?? {}).filter((b): b is string => typeof b === "string") : [])));
160
+ // an assembly links through its parts' interfaces (`fc.stack_mount`): those count as its own here
161
+ const own = new Set(me.def.interfaces.map((i) => i.id));
162
+ const through: InterfaceDef[] = [];
163
+ for (const l of pair.links) {
164
+ const end = (which === "a") !== l.flipped ? l.link.a : l.link.b;
165
+ const iface = which === "a" ? l.aIface : l.bIface;
166
+ if (!own.has(end.interfaceId) && !through.some((t) => t.id === end.interfaceId)) through.push({ ...iface, id: end.interfaceId, name: iface.name ?? end.interfaceId });
167
+ }
168
+ return [...me.def.interfaces, ...through]
169
+ .filter((i) => !composedInto.has(i.id) || here.has(i.id) || elsewhere.has(i.id))
170
+ .map((iface) => ({
171
+ iface,
172
+ status: here.has(iface.id) ? ("here" as const) : elsewhere.has(iface.id) ? ("elsewhere" as const) : ("free" as const),
173
+ others: [...(elsewhere.get(iface.id) ?? [])],
174
+ state: here.get(iface.id),
175
+ group: `${iface.domain} · ${iface.protocols[0]?.type ?? "custom"}`,
176
+ }));
177
+ }
178
+
179
+ export interface Potential {
180
+ a: string;
181
+ b: string;
182
+ protocol: string;
183
+ clean: boolean;
184
+ }
185
+
186
+ /** Pairings DRC would also allow between the two, top level only, cleanest first, without the interfaces already linked. */
187
+ export function potentialsOf(pair: Pair, limit = 3): { shown: Potential[]; hidden: number } {
188
+ let result;
189
+ try {
190
+ result = validatePair(pair.a.def, pair.b.def);
191
+ } catch {
192
+ return { shown: [], hidden: 0 };
193
+ }
194
+ const used = new Set(pair.links.flatMap((l) => [`a:${l.aIface.id}`, `b:${l.bIface.id}`]));
195
+ const seen = new Set<string>();
196
+ const all: Potential[] = [];
197
+ for (const p of result.potentials) {
198
+ if (p.a.regionPath.length !== 1 || p.b.regionPath.length !== 1) continue;
199
+ const [a, b] = p.a.moduleId === pair.a.def.id ? [p.a.regionPath[0], p.b.regionPath[0]] : [p.b.regionPath[0], p.a.regionPath[0]];
200
+ if (used.has(`a:${a}`) || used.has(`b:${b}`) || seen.has(`${a}~${b}`)) continue;
201
+ seen.add(`${a}~${b}`);
202
+ all.push({ a, b, protocol: p.protocol, clean: p.clean });
203
+ }
204
+ all.sort((x, y) => Number(y.clean) - Number(x.clean) || x.a.localeCompare(y.a));
205
+ return { shown: all.slice(0, limit), hidden: Math.max(0, all.length - limit) };
206
+ }