@robotaccomplice/architext 1.0.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 (43) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +497 -0
  3. package/docs/architecture/AGENTS_APPENDIX.md +39 -0
  4. package/docs/architecture/ARCHITECTURE_PLAN.md +520 -0
  5. package/docs/architecture/LLM_ARCHITEXT.md +95 -0
  6. package/docs/architext/AGENTS_APPENDIX.md +39 -0
  7. package/docs/architext/LLM_ARCHITEXT.md +64 -0
  8. package/docs/architext/README.md +120 -0
  9. package/docs/architext/data/data-classification.json +34 -0
  10. package/docs/architext/data/decisions.json +54 -0
  11. package/docs/architext/data/flows.json +114 -0
  12. package/docs/architext/data/glossary.json +24 -0
  13. package/docs/architext/data/manifest.json +23 -0
  14. package/docs/architext/data/nodes.json +194 -0
  15. package/docs/architext/data/risks.json +59 -0
  16. package/docs/architext/data/views.json +91 -0
  17. package/docs/architext/dist/assets/index-BWZ6sEpA.js +51 -0
  18. package/docs/architext/dist/assets/index-iWLms0Pa.css +1 -0
  19. package/docs/architext/dist/compass.svg +9 -0
  20. package/docs/architext/dist/index.html +14 -0
  21. package/docs/architext/index.html +13 -0
  22. package/docs/architext/package-lock.json +1822 -0
  23. package/docs/architext/package.json +28 -0
  24. package/docs/architext/public/compass.svg +9 -0
  25. package/docs/architext/schema/data-classification.schema.json +28 -0
  26. package/docs/architext/schema/decisions.schema.json +33 -0
  27. package/docs/architext/schema/flows.schema.json +72 -0
  28. package/docs/architext/schema/glossary.schema.json +22 -0
  29. package/docs/architext/schema/manifest.schema.json +47 -0
  30. package/docs/architext/schema/nodes.schema.json +69 -0
  31. package/docs/architext/schema/risks.schema.json +34 -0
  32. package/docs/architext/schema/views.schema.json +48 -0
  33. package/docs/architext/src/main.tsx +2133 -0
  34. package/docs/architext/src/styles.css +1475 -0
  35. package/docs/architext/tools/validate-architext.mjs +163 -0
  36. package/docs/architext/tsconfig.json +21 -0
  37. package/docs/architext/vite.config.ts +47 -0
  38. package/docs/assets/screenshots/architext-c4.png +0 -0
  39. package/docs/assets/screenshots/architext-data-risks.png +0 -0
  40. package/docs/assets/screenshots/architext-flows.png +0 -0
  41. package/docs/assets/screenshots/architext-sequence.png +0 -0
  42. package/package.json +81 -0
  43. package/tools/architext-adopt.mjs +874 -0
@@ -0,0 +1,2133 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import type { Root } from "react-dom/client";
4
+ import "./styles.css";
5
+
6
+ type Id = string;
7
+
8
+ type Manifest = {
9
+ schemaVersion: string;
10
+ project: {
11
+ id: Id;
12
+ name: string;
13
+ summary: string;
14
+ };
15
+ generatedAt: string;
16
+ defaultViewId: Id;
17
+ files: {
18
+ nodes: string;
19
+ flows: string;
20
+ views: string;
21
+ dataClassification: string;
22
+ decisions: string;
23
+ risks: string;
24
+ glossary: string;
25
+ };
26
+ notes: string[];
27
+ };
28
+
29
+ type NodeType =
30
+ | "actor"
31
+ | "software-system"
32
+ | "client"
33
+ | "service"
34
+ | "module"
35
+ | "worker"
36
+ | "queue"
37
+ | "data-store"
38
+ | "external-service"
39
+ | "deployment-unit"
40
+ | "trust-boundary";
41
+
42
+ type ArchNode = {
43
+ id: Id;
44
+ type: NodeType;
45
+ name: string;
46
+ summary: string;
47
+ responsibilities: string[];
48
+ owner: string;
49
+ sourcePaths: string[];
50
+ runtime: string;
51
+ interfaces: string[];
52
+ dependencies: Id[];
53
+ dataHandled: Id[];
54
+ security: string[];
55
+ observability: string[];
56
+ relatedFlows: Id[];
57
+ relatedDecisions: Id[];
58
+ knownRisks: Id[];
59
+ verification: string[];
60
+ };
61
+
62
+ type FlowStep = {
63
+ id: Id;
64
+ from: Id;
65
+ to: Id;
66
+ action: Id;
67
+ summary: string;
68
+ data: Id[];
69
+ };
70
+
71
+ type Flow = {
72
+ id: Id;
73
+ name: string;
74
+ status: "planned" | "partial" | "implemented";
75
+ summary: string;
76
+ trigger: string;
77
+ actors: Id[];
78
+ steps: FlowStep[];
79
+ guarantees: string[];
80
+ failureBehavior: string[];
81
+ observability: string[];
82
+ verification: string[];
83
+ knownGaps: string[];
84
+ };
85
+
86
+ type View = {
87
+ id: Id;
88
+ name: string;
89
+ type: string;
90
+ summary: string;
91
+ lanes: Array<{
92
+ id: Id;
93
+ name: string;
94
+ nodeIds: Id[];
95
+ }>;
96
+ };
97
+
98
+ type DataClass = {
99
+ id: Id;
100
+ name: string;
101
+ sensitivity: "low" | "medium" | "high" | "critical";
102
+ handling: string;
103
+ };
104
+
105
+ type Decision = {
106
+ id: Id;
107
+ status: string;
108
+ title: string;
109
+ context: string;
110
+ decision: string;
111
+ consequences: string[];
112
+ relatedNodes: Id[];
113
+ relatedFlows: Id[];
114
+ };
115
+
116
+ type Risk = {
117
+ id: Id;
118
+ title: string;
119
+ category: string;
120
+ severity: "low" | "medium" | "high" | "critical";
121
+ status: string;
122
+ summary: string;
123
+ mitigations: string[];
124
+ relatedNodes: Id[];
125
+ relatedFlows: Id[];
126
+ };
127
+
128
+ type Model = {
129
+ manifest: Manifest;
130
+ nodes: ArchNode[];
131
+ flows: Flow[];
132
+ views: View[];
133
+ dataClasses: DataClass[];
134
+ decisions: Decision[];
135
+ risks: Risk[];
136
+ };
137
+
138
+ type Selection =
139
+ | { kind: "node"; id: Id }
140
+ | { kind: "flow"; id: Id }
141
+ | { kind: "step"; flowId: Id; stepId: Id }
142
+ | { kind: "relationship"; from: Id; to: Id; label: string; relationshipType: "flow" | "structural"; stepId?: Id; flowId?: Id };
143
+
144
+ type Mode = "flows" | "sequence" | "c4" | "deployment" | "data-risks";
145
+ type DiagramTransform = {
146
+ zoom: number;
147
+ focused: boolean;
148
+ };
149
+
150
+ type ViewportSize = {
151
+ width: number;
152
+ height: number;
153
+ };
154
+
155
+ type Relationship = {
156
+ id: Id;
157
+ from: Id;
158
+ to: Id;
159
+ label: string;
160
+ summary: string;
161
+ relationshipType: "flow" | "structural";
162
+ stepId?: Id;
163
+ flowId?: Id;
164
+ };
165
+
166
+ const modeLabels: Record<Mode, string> = {
167
+ flows: "Flows",
168
+ sequence: "Sequence",
169
+ c4: "C4",
170
+ deployment: "Deployment",
171
+ "data-risks": "Data/Risks"
172
+ };
173
+
174
+ const statusLabels: Record<Flow["status"], string> = {
175
+ implemented: "Implemented",
176
+ partial: "Partial",
177
+ planned: "Planned"
178
+ };
179
+
180
+ const modeViewTypes: Record<Mode, string[]> = {
181
+ flows: ["system-map", "flow-explorer", "dataflow"],
182
+ sequence: ["sequence"],
183
+ c4: ["c4-context", "c4-container", "c4-component"],
184
+ deployment: ["deployment"],
185
+ "data-risks": ["risk-overlay", "dataflow"]
186
+ };
187
+
188
+ function modeForView(view: View | undefined): Mode {
189
+ if (!view) return "flows";
190
+ if (view.type === "sequence") return "sequence";
191
+ if (view.type.startsWith("c4-")) return "c4";
192
+ if (view.type === "deployment") return "deployment";
193
+ if (view.type === "risk-overlay") return "data-risks";
194
+ return "flows";
195
+ }
196
+
197
+ function defaultViewForMode(mode: Mode, views: View[], fallback: View): View {
198
+ const types = modeViewTypes[mode];
199
+ return views.find((view) => types.includes(view.type)) ?? fallback;
200
+ }
201
+
202
+ function relationshipLabel(from: ArchNode | undefined, to: ArchNode | undefined): string {
203
+ if (!from || !to) return "relates to";
204
+ if (to.type === "data-store") return "reads/writes";
205
+ if (to.type === "queue") return "publishes";
206
+ if (to.type === "external-service") return "uses";
207
+ if (from.type === "actor") return "uses";
208
+ return "depends on";
209
+ }
210
+
211
+ async function fetchJson<T>(path: string): Promise<T> {
212
+ const response = await fetch(path);
213
+ if (!response.ok) {
214
+ throw new Error(`Failed to load ${path}: ${response.status} ${response.statusText}`);
215
+ }
216
+ return response.json() as Promise<T>;
217
+ }
218
+
219
+ function validateModel(model: Model): string[] {
220
+ const errors: string[] = [];
221
+ const nodeIds = new Set(model.nodes.map((node) => node.id));
222
+ const flowIds = new Set(model.flows.map((flow) => flow.id));
223
+ const dataIds = new Set(model.dataClasses.map((item) => item.id));
224
+ const decisionIds = new Set(model.decisions.map((item) => item.id));
225
+ const riskIds = new Set(model.risks.map((item) => item.id));
226
+ const viewIds = new Set(model.views.map((item) => item.id));
227
+
228
+ const requireKnown = (id: Id, known: Set<Id>, context: string) => {
229
+ if (!known.has(id)) errors.push(`${context} references unknown id "${id}"`);
230
+ };
231
+
232
+ requireKnown(model.manifest.defaultViewId, viewIds, "manifest.defaultViewId");
233
+
234
+ for (const node of model.nodes) {
235
+ for (const id of node.dependencies) requireKnown(id, nodeIds, `node ${node.id}.dependencies`);
236
+ for (const id of node.dataHandled) requireKnown(id, dataIds, `node ${node.id}.dataHandled`);
237
+ for (const id of node.relatedFlows) requireKnown(id, flowIds, `node ${node.id}.relatedFlows`);
238
+ for (const id of node.relatedDecisions) requireKnown(id, decisionIds, `node ${node.id}.relatedDecisions`);
239
+ for (const id of node.knownRisks) requireKnown(id, riskIds, `node ${node.id}.knownRisks`);
240
+ }
241
+
242
+ for (const flow of model.flows) {
243
+ for (const id of flow.actors) requireKnown(id, nodeIds, `flow ${flow.id}.actors`);
244
+ for (const step of flow.steps) {
245
+ requireKnown(step.from, nodeIds, `flow ${flow.id} step ${step.id}.from`);
246
+ requireKnown(step.to, nodeIds, `flow ${flow.id} step ${step.id}.to`);
247
+ for (const id of step.data) requireKnown(id, dataIds, `flow ${flow.id} step ${step.id}.data`);
248
+ }
249
+ }
250
+
251
+ for (const view of model.views) {
252
+ for (const lane of view.lanes) {
253
+ for (const id of lane.nodeIds) requireKnown(id, nodeIds, `view ${view.id} lane ${lane.id}`);
254
+ }
255
+ }
256
+
257
+ return errors;
258
+ }
259
+
260
+ async function loadModel(): Promise<Model> {
261
+ const manifest = await fetchJson<Manifest>("/data/manifest.json");
262
+ const base = "/data/";
263
+ const [nodes, flows, views, dataClassification, decisions, risks] = await Promise.all([
264
+ fetchJson<{ nodes: ArchNode[] }>(base + manifest.files.nodes),
265
+ fetchJson<{ flows: Flow[] }>(base + manifest.files.flows),
266
+ fetchJson<{ views: View[] }>(base + manifest.files.views),
267
+ fetchJson<{ classes: DataClass[] }>(base + manifest.files.dataClassification),
268
+ fetchJson<{ decisions: Decision[] }>(base + manifest.files.decisions),
269
+ fetchJson<{ risks: Risk[] }>(base + manifest.files.risks)
270
+ ]);
271
+ const model = {
272
+ manifest,
273
+ nodes: nodes.nodes,
274
+ flows: flows.flows,
275
+ views: views.views,
276
+ dataClasses: dataClassification.classes,
277
+ decisions: decisions.decisions,
278
+ risks: risks.risks
279
+ };
280
+ const errors = validateModel(model);
281
+ if (errors.length > 0) {
282
+ throw new Error(`Architext data failed viewer validation:\n${errors.join("\n")}`);
283
+ }
284
+ return model;
285
+ }
286
+
287
+ function byId<T extends { id: Id }>(items: T[]): Map<Id, T> {
288
+ return new Map(items.map((item) => [item.id, item]));
289
+ }
290
+
291
+ function Badge({ children, tone }: { children: React.ReactNode; tone?: string }) {
292
+ return <span className={`badge ${tone ?? ""}`}>{children}</span>;
293
+ }
294
+
295
+ function useElementSize<T extends HTMLElement>() {
296
+ const ref = useRef<T | null>(null);
297
+ const [size, setSize] = useState<ViewportSize>({ width: 0, height: 0 });
298
+
299
+ useEffect(() => {
300
+ if (!ref.current) return;
301
+ const observer = new ResizeObserver(([entry]) => {
302
+ const { width, height } = entry.contentRect;
303
+ setSize({ width, height });
304
+ });
305
+ observer.observe(ref.current);
306
+ return () => observer.disconnect();
307
+ }, []);
308
+
309
+ return [ref, size] as const;
310
+ }
311
+
312
+ function sectionId(title: string): string {
313
+ const normalized = title.toLowerCase();
314
+ if (normalized.includes("runtime")) return "runtime";
315
+ if (normalized.includes("interface")) return "interfaces";
316
+ if (normalized.includes("data")) return "data";
317
+ if (normalized.includes("security")) return "security";
318
+ if (normalized.includes("observability")) return "observability";
319
+ if (normalized.includes("risk") || normalized.includes("gap")) return "risks";
320
+ if (normalized.includes("decision")) return "decisions";
321
+ if (normalized.includes("verification")) return "verification";
322
+ if (normalized.includes("summary") || normalized.includes("trigger") || normalized.includes("guarantee") || normalized.includes("failure")) return "summary";
323
+ return normalized.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
324
+ }
325
+
326
+ function FieldList({ title, items }: { title: string; items: string[] }) {
327
+ return (
328
+ <section className="detail-section" id={sectionId(title)}>
329
+ <h3>{title}</h3>
330
+ {items.length > 0 ? (
331
+ <ul>
332
+ {items.map((item) => (
333
+ <li key={item}>{item}</li>
334
+ ))}
335
+ </ul>
336
+ ) : (
337
+ <p className="muted">None recorded.</p>
338
+ )}
339
+ </section>
340
+ );
341
+ }
342
+
343
+ function App() {
344
+ const [model, setModel] = useState<Model | null>(null);
345
+ const [error, setError] = useState<string | null>(null);
346
+ const [navCollapsed, setNavCollapsed] = useState(() => localStorage.getItem("architext-left-collapsed") === "true");
347
+ const [rightCollapsed, setRightCollapsed] = useState(() => localStorage.getItem("architext-right-collapsed") === "true");
348
+ const [query, setQuery] = useState("");
349
+ const [activeMode, setActiveMode] = useState<Mode>("flows");
350
+ const [activeViewId, setActiveViewId] = useState<Id>("");
351
+ const [activeFlowId, setActiveFlowId] = useState<Id>("");
352
+ const [selection, setSelection] = useState<Selection | null>(null);
353
+ const [diagramTransform, setDiagramTransform] = useState<DiagramTransform>({ zoom: 1, focused: false });
354
+ const [riskFilter, setRiskFilter] = useState("all");
355
+ const [stepsCollapsed, setStepsCollapsed] = useState(false);
356
+ const [diagramViewportRef, diagramViewportSize] = useElementSize<HTMLElement>();
357
+
358
+ useEffect(() => {
359
+ loadModel()
360
+ .then((loaded) => {
361
+ setModel(loaded);
362
+ setActiveViewId(loaded.manifest.defaultViewId);
363
+ setActiveMode(modeForView(loaded.views.find((view) => view.id === loaded.manifest.defaultViewId)));
364
+ setActiveFlowId(loaded.flows[0]?.id ?? "");
365
+ setSelection({ kind: "flow", id: loaded.flows[0]?.id ?? "" });
366
+ })
367
+ .catch((loadError: unknown) => {
368
+ setError(loadError instanceof Error ? loadError.message : String(loadError));
369
+ });
370
+ }, []);
371
+
372
+ useEffect(() => {
373
+ localStorage.setItem("architext-left-collapsed", String(navCollapsed));
374
+ }, [navCollapsed]);
375
+
376
+ useEffect(() => {
377
+ localStorage.setItem("architext-right-collapsed", String(rightCollapsed));
378
+ }, [rightCollapsed]);
379
+
380
+ useEffect(() => {
381
+ const narrowWidth = window.matchMedia("(max-width: 760px)");
382
+ const laptopWidth = window.matchMedia("(max-width: 1180px)");
383
+ const collapseForViewport = () => {
384
+ if (narrowWidth.matches) {
385
+ setNavCollapsed(true);
386
+ setRightCollapsed(true);
387
+ } else if (laptopWidth.matches) {
388
+ setRightCollapsed(true);
389
+ }
390
+ };
391
+
392
+ collapseForViewport();
393
+ narrowWidth.addEventListener("change", collapseForViewport);
394
+ laptopWidth.addEventListener("change", collapseForViewport);
395
+ return () => {
396
+ narrowWidth.removeEventListener("change", collapseForViewport);
397
+ laptopWidth.removeEventListener("change", collapseForViewport);
398
+ };
399
+ }, []);
400
+
401
+ if (error) {
402
+ return (
403
+ <main className="fatal">
404
+ <h1>Architext failed to load</h1>
405
+ <pre>{error}</pre>
406
+ </main>
407
+ );
408
+ }
409
+
410
+ if (!model) {
411
+ return (
412
+ <main className="loading">
413
+ <h1>Loading Architext</h1>
414
+ </main>
415
+ );
416
+ }
417
+
418
+ const nodesById = byId<ArchNode>(model.nodes);
419
+ const flowsById = byId<Flow>(model.flows);
420
+ const viewsById = byId<View>(model.views);
421
+ const dataById = byId<DataClass>(model.dataClasses);
422
+ const decisionsById = byId<Decision>(model.decisions);
423
+ const risksById = byId<Risk>(model.risks);
424
+ const activeFlow = flowsById.get(activeFlowId) ?? model.flows[0];
425
+ const fallbackView = model.views[0];
426
+ const selectedView = viewsById.get(activeViewId);
427
+ const activeView = selectedView && modeViewTypes[activeMode].includes(selectedView.type)
428
+ ? selectedView
429
+ : defaultViewForMode(activeMode, model.views, fallbackView);
430
+ const isC4View = activeMode === "c4";
431
+ const isSequenceView = activeMode === "sequence";
432
+ const showStepSummary = activeMode === "flows" || activeMode === "sequence" || activeMode === "deployment";
433
+ const flowNodeIds = new Set(activeFlow.steps.flatMap((step) => [step.from, step.to]));
434
+ const selectedNodeId = selection?.kind === "node" ? selection.id : null;
435
+ const selectedStepId = selection?.kind === "step"
436
+ ? selection.stepId
437
+ : selection?.kind === "relationship"
438
+ ? selection.stepId ?? null
439
+ : null;
440
+ const selectedFlowForStep = selection?.kind === "step"
441
+ ? flowsById.get(selection.flowId)
442
+ : selection?.kind === "relationship" && selection.flowId
443
+ ? flowsById.get(selection.flowId)
444
+ : null;
445
+ const selectedStep = selectedStepId
446
+ ? selectedFlowForStep?.steps.find((step) => step.id === selectedStepId) ?? null
447
+ : null;
448
+
449
+ const filteredFlows = model.flows.filter((flow) => {
450
+ const text = [flow.name, flow.summary, flow.status, flow.trigger, ...flow.knownGaps].join(" ").toLowerCase();
451
+ return text.includes(query.toLowerCase());
452
+ });
453
+
454
+ const estimateCanvasSize = (mode: Mode, view: View, flow: Flow): ViewportSize => {
455
+ if (mode === "sequence") {
456
+ const participantCount = new Set(flow.steps.flatMap((step) => [step.from, step.to])).size;
457
+ return {
458
+ width: 56 + participantCount * 146,
459
+ height: 88 + flow.steps.length * 56 + 56
460
+ };
461
+ }
462
+
463
+ if (mode === "c4") {
464
+ return {
465
+ width: Math.max(760, 112 + view.lanes.length * 210),
466
+ height: Math.max(440, 72 + Math.max(...view.lanes.map((lane) => lane.nodeIds.length), 1) * 86 + 96)
467
+ };
468
+ }
469
+
470
+ return {
471
+ width: 192 + view.lanes.length * 210,
472
+ height: Math.max(380, 86 + Math.max(...view.lanes.map((lane) => lane.nodeIds.length), 1) * 84 + 104)
473
+ };
474
+ };
475
+
476
+ const fitZoomFor = (mode: Mode, view: View, flow: Flow) => {
477
+ const estimate = estimateCanvasSize(mode, view, flow);
478
+ const availableWidth = Math.max(diagramViewportSize.width - 24, 1);
479
+ const availableHeight = Math.max(diagramViewportSize.height - 24, 1);
480
+ const nextZoom = Math.min(availableWidth / estimate.width, availableHeight / estimate.height);
481
+ return Math.min(1, Math.max(0.6, Number(nextZoom.toFixed(2))));
482
+ };
483
+
484
+ const switchMode = (mode: Mode) => {
485
+ const nextView = defaultViewForMode(mode, model.views, fallbackView);
486
+ setActiveMode(mode);
487
+ setActiveViewId(nextView.id);
488
+ if (diagramViewportSize.width && diagramViewportSize.height) {
489
+ const nextZoom = fitZoomFor(mode, nextView, activeFlow);
490
+ setDiagramTransform((value) => ({ ...value, zoom: Math.min(value.zoom, nextZoom) }));
491
+ }
492
+ if (mode === "c4") {
493
+ setSelection({ kind: "node", id: nextView.lanes.flatMap((lane) => lane.nodeIds)[0] ?? model.nodes[0]?.id ?? "" });
494
+ } else if (mode === "data-risks") {
495
+ setSelection({ kind: "flow", id: activeFlow.id });
496
+ }
497
+ };
498
+
499
+ const setC4View = (viewId: Id) => {
500
+ setActiveMode("c4");
501
+ setActiveViewId(viewId);
502
+ const view = viewsById.get(viewId);
503
+ const firstNodeId = view?.lanes.flatMap((lane) => lane.nodeIds)[0];
504
+ if (firstNodeId) setSelection({ kind: "node", id: firstNodeId });
505
+ };
506
+
507
+ const selectRelationship = (relationship: Relationship) => {
508
+ setSelection({
509
+ kind: "relationship",
510
+ from: relationship.from,
511
+ to: relationship.to,
512
+ label: relationship.label,
513
+ relationshipType: relationship.relationshipType,
514
+ stepId: relationship.stepId,
515
+ flowId: relationship.flowId
516
+ });
517
+ };
518
+
519
+ return (
520
+ <div className={`app ${navCollapsed ? "left-collapsed" : ""} ${rightCollapsed ? "right-collapsed" : ""} ${diagramTransform.focused ? "diagram-focused" : ""}`}>
521
+ <header className="topbar">
522
+ <div>
523
+ <p className="eyebrow">Architext / {model.manifest.schemaVersion}</p>
524
+ <div className="project-title-line">
525
+ <h1>{model.manifest.project.name}</h1>
526
+ <p>{model.manifest.project.summary}</p>
527
+ </div>
528
+ </div>
529
+ <div className="topbar-actions">
530
+ <div className="mode-tabs" role="tablist" aria-label="Architext modes">
531
+ {(Object.keys(modeLabels) as Mode[]).map((mode) => (
532
+ <button
533
+ key={mode}
534
+ type="button"
535
+ role="tab"
536
+ aria-selected={activeMode === mode}
537
+ className={activeMode === mode ? "active" : ""}
538
+ onClick={() => switchMode(mode)}
539
+ >
540
+ {modeLabels[mode]}
541
+ </button>
542
+ ))}
543
+ </div>
544
+ </div>
545
+ </header>
546
+
547
+ <aside className="left-nav">
548
+ <button
549
+ type="button"
550
+ className="side-toggle left-side-toggle"
551
+ onClick={() => setNavCollapsed((value) => !value)}
552
+ aria-label={navCollapsed ? "Expand left navigation" : "Collapse left navigation"}
553
+ title={navCollapsed ? "Expand left navigation" : "Collapse left navigation"}
554
+ data-tooltip={navCollapsed ? "Expand" : "Collapse"}
555
+ >
556
+ {navCollapsed ? "›" : "‹"}
557
+ </button>
558
+ {navCollapsed ? (
559
+ <div className="panel-rail">{modeLabels[activeMode]}</div>
560
+ ) : (
561
+ <LeftPanel
562
+ mode={activeMode}
563
+ query={query}
564
+ onQueryChange={setQuery}
565
+ flows={filteredFlows}
566
+ allFlows={model.flows}
567
+ activeFlow={activeFlow}
568
+ views={model.views}
569
+ activeView={activeView}
570
+ nodes={model.nodes}
571
+ dataClasses={model.dataClasses}
572
+ risks={model.risks}
573
+ riskFilter={riskFilter}
574
+ onRiskFilterChange={setRiskFilter}
575
+ onSelectFlow={(flowId) => {
576
+ setActiveFlowId(flowId);
577
+ setSelection({ kind: "flow", id: flowId });
578
+ }}
579
+ onSelectView={setC4View}
580
+ onSelectNode={(id) => setSelection({ kind: "node", id })}
581
+ />
582
+ )}
583
+ </aside>
584
+
585
+ <main className="diagram-area">
586
+ <section className="diagram-header">
587
+ <div className="diagram-title-line">
588
+ <h2>{activeView.name}</h2>
589
+ <p>{activeView.summary}</p>
590
+ </div>
591
+ <DiagramControls
592
+ transform={diagramTransform}
593
+ onZoomIn={() => setDiagramTransform((value) => ({ ...value, zoom: Math.min(1.6, Number((value.zoom + 0.1).toFixed(2))) }))}
594
+ onZoomOut={() => setDiagramTransform((value) => ({ ...value, zoom: Math.max(0.7, Number((value.zoom - 0.1).toFixed(2))) }))}
595
+ onFit={() => setDiagramTransform((value) => ({ ...value, zoom: fitZoomFor(activeMode, activeView, activeFlow) }))}
596
+ onReset={() => setDiagramTransform((value) => ({ ...value, zoom: 1 }))}
597
+ onToggleFocus={() => setDiagramTransform((value) => {
598
+ const focused = !value.focused;
599
+ return { ...value, focused, zoom: focused ? fitZoomFor(activeMode, activeView, activeFlow) : value.zoom };
600
+ })}
601
+ />
602
+ <details className="legend">
603
+ <summary>Legend</summary>
604
+ <div>
605
+ {(["actor", "software-system", "client", "service", "worker", "queue", "data-store", "external-service"] as NodeType[]).map((type) => (
606
+ <span key={type}><i className={`dot ${type}`} />{type}</span>
607
+ ))}
608
+ </div>
609
+ </details>
610
+ </section>
611
+
612
+ <section className="diagram-viewport" ref={diagramViewportRef}>
613
+ {isSequenceView ? (
614
+ <SequenceDiagram
615
+ activeFlow={activeFlow}
616
+ nodesById={nodesById}
617
+ dataById={dataById}
618
+ selectedStepId={selectedStepId}
619
+ transform={diagramTransform}
620
+ onSelectStep={(stepId) => setSelection({ kind: "step", flowId: activeFlow.id, stepId })}
621
+ onSelectRelationship={selectRelationship}
622
+ />
623
+ ) : isC4View ? (
624
+ <C4Diagram
625
+ view={activeView}
626
+ nodesById={nodesById}
627
+ selectedNodeId={selectedNodeId}
628
+ selectedRelationship={selection?.kind === "relationship" ? selection : null}
629
+ transform={diagramTransform}
630
+ onSelectNode={(id) => setSelection({ kind: "node", id })}
631
+ onSelectRelationship={selectRelationship}
632
+ />
633
+ ) : (
634
+ <SystemMap
635
+ view={activeView}
636
+ nodesById={nodesById}
637
+ activeFlow={isC4View ? null : activeFlow}
638
+ showStructuralConnections={isC4View}
639
+ selectedStepId={selectedStepId}
640
+ selectedRelationship={selection?.kind === "relationship" ? selection : null}
641
+ selectedNodeId={selectedNodeId}
642
+ transform={diagramTransform}
643
+ onSelectNode={(id) => setSelection({ kind: "node", id })}
644
+ onSelectRelationship={selectRelationship}
645
+ />
646
+ )}
647
+ </section>
648
+
649
+ {showStepSummary && (
650
+ <section className={`steps ${stepsCollapsed ? "collapsed" : ""}`}>
651
+ <div className="steps-head">
652
+ <div className="steps-title-line">
653
+ <h2>{activeFlow.name}</h2>
654
+ {!stepsCollapsed && <p>{activeFlow.summary}</p>}
655
+ </div>
656
+ <div className="steps-actions">
657
+ <Badge tone={activeFlow.status}>{statusLabels[activeFlow.status]}</Badge>
658
+ <button type="button" onClick={() => setStepsCollapsed((value) => !value)}>
659
+ {stepsCollapsed ? "Show steps" : "Hide steps"}
660
+ </button>
661
+ </div>
662
+ </div>
663
+ {!stepsCollapsed && (
664
+ <>
665
+ <div className="step-list">
666
+ {activeFlow.steps.map((step, index) => (
667
+ <button
668
+ key={step.id}
669
+ type="button"
670
+ className={`step-card ${selectedStepId === step.id ? "active" : ""}`}
671
+ onClick={() => setSelection({ kind: "step", flowId: activeFlow.id, stepId: step.id })}
672
+ >
673
+ <span className="step-number">{index + 1}</span>
674
+ <strong>{nodesById.get(step.from)?.name ?? step.from} {"→"} {nodesById.get(step.to)?.name ?? step.to}</strong>
675
+ <span>{step.action}</span>
676
+ </button>
677
+ ))}
678
+ </div>
679
+ </>
680
+ )}
681
+ </section>
682
+ )}
683
+ </main>
684
+
685
+ <aside className="details">
686
+ <button
687
+ type="button"
688
+ className="side-toggle right-side-toggle"
689
+ onClick={() => setRightCollapsed((value) => !value)}
690
+ aria-label={rightCollapsed ? "Expand right details" : "Collapse right details"}
691
+ title={rightCollapsed ? "Expand right details" : "Collapse right details"}
692
+ data-tooltip={rightCollapsed ? "Expand" : "Collapse"}
693
+ >
694
+ {rightCollapsed ? "‹" : "›"}
695
+ </button>
696
+ {rightCollapsed ? (
697
+ <div className="panel-rail">Details</div>
698
+ ) : (
699
+ <DetailPanel
700
+ model={model}
701
+ nodesById={nodesById}
702
+ flowsById={flowsById}
703
+ dataById={dataById}
704
+ decisionsById={decisionsById}
705
+ risksById={risksById}
706
+ flowNodeIds={flowNodeIds}
707
+ selection={selection}
708
+ selectedStep={selectedStep}
709
+ activeFlow={activeFlow}
710
+ onSelectNode={(id) => setSelection({ kind: "node", id })}
711
+ onSelectFlow={(id) => {
712
+ setActiveFlowId(id);
713
+ setSelection({ kind: "flow", id });
714
+ }}
715
+ />
716
+ )}
717
+ </aside>
718
+ </div>
719
+ );
720
+ }
721
+
722
+ function LeftPanel({
723
+ mode,
724
+ query,
725
+ onQueryChange,
726
+ flows,
727
+ allFlows,
728
+ activeFlow,
729
+ views,
730
+ activeView,
731
+ nodes,
732
+ dataClasses,
733
+ risks,
734
+ riskFilter,
735
+ onRiskFilterChange,
736
+ onSelectFlow,
737
+ onSelectView,
738
+ onSelectNode
739
+ }: {
740
+ mode: Mode;
741
+ query: string;
742
+ onQueryChange: (value: string) => void;
743
+ flows: Flow[];
744
+ allFlows: Flow[];
745
+ activeFlow: Flow;
746
+ views: View[];
747
+ activeView: View;
748
+ nodes: ArchNode[];
749
+ dataClasses: DataClass[];
750
+ risks: Risk[];
751
+ riskFilter: string;
752
+ onRiskFilterChange: (value: string) => void;
753
+ onSelectFlow: (id: Id) => void;
754
+ onSelectView: (id: Id) => void;
755
+ onSelectNode: (id: Id) => void;
756
+ }) {
757
+ if (mode === "c4") {
758
+ const c4Views = views.filter((view) => view.type.startsWith("c4-"));
759
+ return (
760
+ <>
761
+ <div className="panel-head">
762
+ <h2>C4 Drilldown</h2>
763
+ <p>Structural levels, not workflows.</p>
764
+ </div>
765
+ <div className="entity-list">
766
+ {c4Views.map((view) => (
767
+ <button
768
+ key={view.id}
769
+ type="button"
770
+ className={`entity-card ${activeView.id === view.id ? "active" : ""}`}
771
+ onClick={() => onSelectView(view.id)}
772
+ >
773
+ <strong>{view.name.replace("C4 ", "")}</strong>
774
+ <span>{view.summary}</span>
775
+ </button>
776
+ ))}
777
+ </div>
778
+ </>
779
+ );
780
+ }
781
+
782
+ if (mode === "deployment") {
783
+ const deploymentNodes = nodes.filter((node) => ["client", "service", "worker", "queue", "data-store", "external-service", "deployment-unit"].includes(node.type));
784
+ return (
785
+ <>
786
+ <div className="panel-head">
787
+ <h2>Runtime Units</h2>
788
+ <p>{deploymentNodes.length} nodes in deployment scope.</p>
789
+ </div>
790
+ <div className="entity-list">
791
+ {deploymentNodes.map((node) => (
792
+ <button key={node.id} type="button" className="entity-card" onClick={() => onSelectNode(node.id)}>
793
+ <strong>{node.name}</strong>
794
+ <span>{node.runtime}</span>
795
+ <Badge>{node.type}</Badge>
796
+ </button>
797
+ ))}
798
+ </div>
799
+ </>
800
+ );
801
+ }
802
+
803
+ if (mode === "data-risks") {
804
+ const riskTones = ["all", "critical", "high", "medium", "low"];
805
+ const normalizedQuery = query.toLowerCase();
806
+ const filteredDataClasses = dataClasses.filter((item) => (
807
+ [item.name, item.handling, item.sensitivity].join(" ").toLowerCase().includes(normalizedQuery)
808
+ ));
809
+ const filteredRisks = risks.filter((risk) => {
810
+ const matchesText = [risk.title, risk.summary, risk.severity].join(" ").toLowerCase().includes(normalizedQuery);
811
+ const matchesTone = riskFilter === "all" || risk.severity === riskFilter;
812
+ return matchesText && matchesTone;
813
+ });
814
+ return (
815
+ <>
816
+ <div className="panel-head">
817
+ <h2>Data / Risks</h2>
818
+ <input
819
+ type="search"
820
+ value={query}
821
+ placeholder="Filter data or risks"
822
+ aria-label="Filter data or risks"
823
+ onChange={(event) => onQueryChange(event.target.value)}
824
+ />
825
+ <div className="filter-row" aria-label="Risk severity filters">
826
+ {riskTones.map((tone) => (
827
+ <button
828
+ key={tone}
829
+ type="button"
830
+ className={riskFilter === tone ? "active" : ""}
831
+ onClick={() => onRiskFilterChange(tone)}
832
+ >
833
+ {tone}
834
+ </button>
835
+ ))}
836
+ </div>
837
+ <p>{dataClasses.length} data classes · {risks.length} risks</p>
838
+ </div>
839
+ <div className="entity-list">
840
+ <h3>Data Classes</h3>
841
+ {filteredDataClasses.map((item) => (
842
+ <article className="entity-card passive" key={item.id}>
843
+ <strong>{item.name}</strong>
844
+ <span>{item.handling}</span>
845
+ <Badge tone={item.sensitivity}>{item.sensitivity}</Badge>
846
+ </article>
847
+ ))}
848
+ <h3>Risks</h3>
849
+ {filteredRisks.map((risk) => (
850
+ <article className="entity-card passive" key={risk.id}>
851
+ <strong>{risk.title}</strong>
852
+ <span>{risk.summary}</span>
853
+ <Badge tone={risk.severity}>{risk.severity}</Badge>
854
+ </article>
855
+ ))}
856
+ </div>
857
+ </>
858
+ );
859
+ }
860
+
861
+ return (
862
+ <>
863
+ <div className="panel-head">
864
+ <h2>{mode === "sequence" ? "Sequence Flow" : "Flows"}</h2>
865
+ <input
866
+ type="search"
867
+ value={query}
868
+ placeholder="Search flows"
869
+ aria-label="Search flows"
870
+ onChange={(event) => onQueryChange(event.target.value)}
871
+ />
872
+ <p>{flows.length} of {allFlows.length} flows</p>
873
+ </div>
874
+ <div className="flow-list">
875
+ {flows.map((flow) => (
876
+ <button
877
+ key={flow.id}
878
+ type="button"
879
+ className={`flow-card ${flow.id === activeFlow.id ? "active" : ""}`}
880
+ onClick={() => onSelectFlow(flow.id)}
881
+ >
882
+ <strong>{flow.name}</strong>
883
+ <span>{flow.summary}</span>
884
+ <Badge tone={flow.status}>{statusLabels[flow.status]}</Badge>
885
+ </button>
886
+ ))}
887
+ </div>
888
+ </>
889
+ );
890
+ }
891
+
892
+ function DiagramControls({
893
+ transform,
894
+ onZoomIn,
895
+ onZoomOut,
896
+ onFit,
897
+ onReset,
898
+ onToggleFocus
899
+ }: {
900
+ transform: DiagramTransform;
901
+ onZoomIn: () => void;
902
+ onZoomOut: () => void;
903
+ onFit: () => void;
904
+ onReset: () => void;
905
+ onToggleFocus: () => void;
906
+ }) {
907
+ return (
908
+ <div className="diagram-controls" aria-label="Diagram controls">
909
+ <button type="button" onClick={onZoomOut} aria-label="Zoom out">-</button>
910
+ <span>{Math.round(transform.zoom * 100)}%</span>
911
+ <button type="button" onClick={onZoomIn} aria-label="Zoom in">+</button>
912
+ <button type="button" onClick={onFit}>Fit</button>
913
+ <button type="button" onClick={onReset}>Reset</button>
914
+ <button type="button" onClick={onToggleFocus}>{transform.focused ? "Exit focus" : "Focus"}</button>
915
+ </div>
916
+ );
917
+ }
918
+
919
+ function SystemMap({
920
+ view,
921
+ nodesById,
922
+ activeFlow,
923
+ showStructuralConnections,
924
+ selectedStepId,
925
+ selectedRelationship,
926
+ selectedNodeId,
927
+ transform,
928
+ onSelectRelationship,
929
+ onSelectNode
930
+ }: {
931
+ view: View;
932
+ nodesById: Map<Id, ArchNode>;
933
+ activeFlow: Flow | null;
934
+ showStructuralConnections: boolean;
935
+ selectedStepId: Id | null;
936
+ selectedRelationship: Extract<Selection, { kind: "relationship" }> | null;
937
+ selectedNodeId: Id | null;
938
+ transform: DiagramTransform;
939
+ onSelectRelationship: (relationship: Relationship) => void;
940
+ onSelectNode: (id: Id) => void;
941
+ }) {
942
+ const visibleNodeIds = new Set(view.lanes.flatMap((lane) => lane.nodeIds));
943
+ const flowNodeIds = new Set(activeFlow ? activeFlow.steps.flatMap((step) => [step.from, step.to]) : Array.from(visibleNodeIds));
944
+ const nodeWidth = 136;
945
+ const nodeHeight = 54;
946
+ const laneWidth = 210;
947
+ const rowGap = 84;
948
+ const routeGutter = 96;
949
+ const marginX = routeGutter + 48;
950
+ const marginY = 76;
951
+ const laneIndexByNode = new Map<Id, number>();
952
+ const rowIndexByNode = new Map<Id, number>();
953
+
954
+ view.lanes.forEach((lane, laneIndex) => {
955
+ lane.nodeIds.forEach((nodeId, rowIndex) => {
956
+ laneIndexByNode.set(nodeId, laneIndex);
957
+ rowIndexByNode.set(nodeId, rowIndex);
958
+ });
959
+ });
960
+
961
+ const laneHeight = Math.max(...view.lanes.map((lane) => lane.nodeIds.length), 1) * rowGap + marginY + 24;
962
+ const canvasWidth = marginX * 2 + view.lanes.length * laneWidth + 40;
963
+ const canvasHeight = Math.max(340, laneHeight + 64);
964
+ const nodePosition = (nodeId: Id) => {
965
+ const laneIndex = laneIndexByNode.get(nodeId) ?? 0;
966
+ const rowIndex = rowIndexByNode.get(nodeId) ?? 0;
967
+ return {
968
+ x: marginX + laneIndex * laneWidth,
969
+ y: marginY + rowIndex * rowGap
970
+ };
971
+ };
972
+
973
+ type Side = "left" | "right" | "top" | "bottom";
974
+ type Point = { x: number; y: number };
975
+ type Route = { d: string; labelX: number; labelY: number; cost: number; samples: Point[] };
976
+
977
+ const rectFor = (nodeId: Id) => {
978
+ const position = nodePosition(nodeId);
979
+ return {
980
+ x: position.x,
981
+ y: position.y,
982
+ width: nodeWidth,
983
+ height: nodeHeight
984
+ };
985
+ };
986
+
987
+ const anchorFor = (rect: ReturnType<typeof rectFor>, side: Side): Point => {
988
+ if (side === "left") return { x: rect.x, y: rect.y + rect.height / 2 };
989
+ if (side === "right") return { x: rect.x + rect.width, y: rect.y + rect.height / 2 };
990
+ if (side === "top") return { x: rect.x + rect.width / 2, y: rect.y };
991
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height };
992
+ };
993
+
994
+ const cubicPoint = (start: Point, controlA: Point, controlB: Point, end: Point, t: number): Point => {
995
+ const i = 1 - t;
996
+ return {
997
+ x: i ** 3 * start.x + 3 * i ** 2 * t * controlA.x + 3 * i * t ** 2 * controlB.x + t ** 3 * end.x,
998
+ y: i ** 3 * start.y + 3 * i ** 2 * t * controlA.y + 3 * i * t ** 2 * controlB.y + t ** 3 * end.y
999
+ };
1000
+ };
1001
+
1002
+ const distanceToRect = (point: Point, rect: ReturnType<typeof rectFor>) => {
1003
+ const dx = Math.max(rect.x - point.x, 0, point.x - (rect.x + rect.width));
1004
+ const dy = Math.max(rect.y - point.y, 0, point.y - (rect.y + rect.height));
1005
+ return Math.hypot(dx, dy);
1006
+ };
1007
+
1008
+ const routeCollidesWithNode = (samples: Point[], fromId: Id, toId: Id, padding = 8) => {
1009
+ const blockers = Array.from(visibleNodeIds)
1010
+ .filter((nodeId) => nodeId !== fromId && nodeId !== toId)
1011
+ .map(rectFor);
1012
+ return samples.some((point) => blockers.some((rect) =>
1013
+ point.x >= rect.x - padding &&
1014
+ point.x <= rect.x + rect.width + padding &&
1015
+ point.y >= rect.y - padding &&
1016
+ point.y <= rect.y + rect.height + padding
1017
+ ));
1018
+ };
1019
+
1020
+ const routeCost = (
1021
+ start: Point,
1022
+ controlA: Point,
1023
+ controlB: Point,
1024
+ end: Point,
1025
+ label: Point,
1026
+ fromId: Id,
1027
+ toId: Id,
1028
+ usedRoutes: Point[][]
1029
+ ) => {
1030
+ const blockers = Array.from(visibleNodeIds)
1031
+ .filter((nodeId) => nodeId !== fromId && nodeId !== toId)
1032
+ .map(rectFor);
1033
+ let cost = Math.hypot(end.x - start.x, end.y - start.y);
1034
+ const samples: Point[] = [];
1035
+ let previous = start;
1036
+ for (let step = 1; step < 48; step += 1) {
1037
+ const point = cubicPoint(start, controlA, controlB, end, step / 48);
1038
+ samples.push(point);
1039
+ cost += Math.hypot(point.x - previous.x, point.y - previous.y) * 1.4;
1040
+ previous = point;
1041
+ if (point.y < 28 || point.x < 12 || point.x > canvasWidth - 12 || point.y > canvasHeight - 12) {
1042
+ cost += 12000;
1043
+ }
1044
+ for (const rect of blockers) {
1045
+ const padding = 16;
1046
+ const inside =
1047
+ point.x >= rect.x - padding &&
1048
+ point.x <= rect.x + rect.width + padding &&
1049
+ point.y >= rect.y - padding &&
1050
+ point.y <= rect.y + rect.height + padding;
1051
+ if (inside) cost += 8000;
1052
+
1053
+ const distance = distanceToRect(point, rect);
1054
+ if (distance < 34) cost += (34 - distance) * 90;
1055
+ }
1056
+
1057
+ for (const usedRoute of usedRoutes) {
1058
+ for (let usedIndex = 0; usedIndex < usedRoute.length; usedIndex += 3) {
1059
+ const used = usedRoute[usedIndex];
1060
+ const distance = Math.hypot(point.x - used.x, point.y - used.y);
1061
+ if (distance < 22) cost += 350;
1062
+ if (distance < 10) cost += 1400;
1063
+ }
1064
+ }
1065
+ }
1066
+
1067
+ for (const rect of blockers) {
1068
+ if (distanceToRect(label, rect) < 32) {
1069
+ cost += 20000;
1070
+ }
1071
+ }
1072
+
1073
+ return { cost, samples };
1074
+ };
1075
+
1076
+ const nearestSample = (samples: Point[], target: Point): Point => {
1077
+ return samples.reduce((nearest, sample) => {
1078
+ const nearestDistance = Math.hypot(nearest.x - target.x, nearest.y - target.y);
1079
+ const sampleDistance = Math.hypot(sample.x - target.x, sample.y - target.y);
1080
+ return sampleDistance < nearestDistance ? sample : nearest;
1081
+ }, samples[0] ?? target);
1082
+ };
1083
+
1084
+ const cubicRoute = (
1085
+ fromId: Id,
1086
+ toId: Id,
1087
+ startSide: Side,
1088
+ endSide: Side,
1089
+ controlA: Point,
1090
+ controlB: Point,
1091
+ label: Point,
1092
+ usedRoutes: Point[][]
1093
+ ): Route => {
1094
+ const start = anchorFor(rectFor(fromId), startSide);
1095
+ const end = anchorFor(rectFor(toId), endSide);
1096
+ const scored = routeCost(start, controlA, controlB, end, label, fromId, toId, usedRoutes);
1097
+ const startDirection = sideVector(startSide);
1098
+ const endDirection = sideVector(endSide);
1099
+ const targetVector = { x: end.x - start.x, y: end.y - start.y };
1100
+ const incomingVector = { x: start.x - end.x, y: start.y - end.y };
1101
+ if (startDirection.x * targetVector.x + startDirection.y * targetVector.y < 0) {
1102
+ scored.cost += 60000;
1103
+ }
1104
+ if (endDirection.x * incomingVector.x + endDirection.y * incomingVector.y < 0) {
1105
+ scored.cost += 60000;
1106
+ }
1107
+ const labelPoint = nearestSample(scored.samples, label);
1108
+ return {
1109
+ d: `M ${start.x} ${start.y} C ${controlA.x} ${controlA.y}, ${controlB.x} ${controlB.y}, ${end.x} ${end.y}`,
1110
+ labelX: labelPoint.x,
1111
+ labelY: labelPoint.y,
1112
+ cost: scored.cost,
1113
+ samples: scored.samples
1114
+ };
1115
+ };
1116
+
1117
+ const tangentFor = (side: Side, bend: number): Point => {
1118
+ if (side === "left") return { x: -bend, y: 0 };
1119
+ if (side === "right") return { x: bend, y: 0 };
1120
+ if (side === "top") return { x: 0, y: -bend };
1121
+ return { x: 0, y: bend };
1122
+ };
1123
+
1124
+ const sideVector = (side: Side): Point => {
1125
+ if (side === "left") return { x: -1, y: 0 };
1126
+ if (side === "right") return { x: 1, y: 0 };
1127
+ if (side === "top") return { x: 0, y: -1 };
1128
+ return { x: 0, y: 1 };
1129
+ };
1130
+
1131
+ const lineSamples = (points: Point[]): Point[] => {
1132
+ const samples: Point[] = [];
1133
+ for (let index = 0; index < points.length - 1; index += 1) {
1134
+ const start = points[index];
1135
+ const end = points[index + 1];
1136
+ for (let step = 1; step <= 10; step += 1) {
1137
+ const t = step / 10;
1138
+ samples.push({
1139
+ x: start.x + (end.x - start.x) * t,
1140
+ y: start.y + (end.y - start.y) * t
1141
+ });
1142
+ }
1143
+ }
1144
+ return samples;
1145
+ };
1146
+
1147
+ const routeCostFromSamples = (
1148
+ samples: Point[],
1149
+ label: Point,
1150
+ fromId: Id,
1151
+ toId: Id,
1152
+ usedRoutes: Point[][]
1153
+ ) => {
1154
+ const blockers = Array.from(visibleNodeIds)
1155
+ .filter((nodeId) => nodeId !== fromId && nodeId !== toId)
1156
+ .map(rectFor);
1157
+ let cost = 0;
1158
+ for (let index = 0; index < samples.length - 1; index += 1) {
1159
+ cost += Math.hypot(samples[index + 1].x - samples[index].x, samples[index + 1].y - samples[index].y);
1160
+ }
1161
+ for (const point of samples) {
1162
+ if (point.y < 30 || point.x < 16 || point.x > canvasWidth - 16 || point.y > canvasHeight - 16) {
1163
+ cost += 14000;
1164
+ }
1165
+ for (const rect of blockers) {
1166
+ const distance = distanceToRect(point, rect);
1167
+ if (distance < 14) cost += 12000;
1168
+ if (distance < 30) cost += (30 - distance) * 120;
1169
+ }
1170
+ for (const usedRoute of usedRoutes) {
1171
+ for (let usedIndex = 0; usedIndex < usedRoute.length; usedIndex += 2) {
1172
+ const used = usedRoute[usedIndex];
1173
+ const distance = Math.hypot(point.x - used.x, point.y - used.y);
1174
+ if (distance < 26) cost += 450;
1175
+ if (distance < 12) cost += 1600;
1176
+ }
1177
+ }
1178
+ }
1179
+ for (const rect of blockers) {
1180
+ if (distanceToRect(label, rect) < 34) cost += 24000;
1181
+ }
1182
+ return cost;
1183
+ };
1184
+
1185
+ const outerGutterRoute = (fromId: Id, toId: Id, bottomCorridor: number, routeOffset: number): Route => {
1186
+ const fromRectLocal = rectFor(fromId);
1187
+ const toRectLocal = rectFor(toId);
1188
+ const fromCenterLocal = { x: fromRectLocal.x + fromRectLocal.width / 2, y: fromRectLocal.y + fromRectLocal.height / 2 };
1189
+ const toCenterLocal = { x: toRectLocal.x + toRectLocal.width / 2, y: toRectLocal.y + toRectLocal.height / 2 };
1190
+ const routeOnRight = fromCenterLocal.x >= toCenterLocal.x;
1191
+ const start = anchorFor(fromRectLocal, routeOnRight ? "right" : "left");
1192
+ const end = anchorFor(toRectLocal, "bottom");
1193
+ const requestedGutterX = routeOnRight
1194
+ ? Math.max(fromRectLocal.x + fromRectLocal.width, toRectLocal.x + toRectLocal.width) + 54 + routeOffset
1195
+ : Math.min(fromRectLocal.x, toRectLocal.x) - 54 - routeOffset;
1196
+ const gutterX = Math.min(Math.max(requestedGutterX, 28), canvasWidth - 28);
1197
+ const points = [
1198
+ start,
1199
+ { x: gutterX, y: start.y },
1200
+ { x: gutterX, y: bottomCorridor },
1201
+ { x: end.x, y: bottomCorridor },
1202
+ end
1203
+ ];
1204
+ const samples = lineSamples(points);
1205
+ return {
1206
+ d: `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y} L ${points[2].x} ${points[2].y} L ${points[3].x} ${points[3].y} L ${points[4].x} ${points[4].y}`,
1207
+ labelX: (gutterX + end.x) / 2,
1208
+ labelY: bottomCorridor - 8,
1209
+ cost: routeCostFromSamples(samples, { x: (gutterX + end.x) / 2, y: bottomCorridor - 8 }, fromId, toId, []),
1210
+ samples
1211
+ };
1212
+ };
1213
+
1214
+ const sideGutterRoute = (fromId: Id, toId: Id, routeOffset: number): Route => {
1215
+ const fromRectLocal = rectFor(fromId);
1216
+ const toRectLocal = rectFor(toId);
1217
+ const start = anchorFor(fromRectLocal, "left");
1218
+ const end = anchorFor(toRectLocal, "left");
1219
+ const requestedGutterX = Math.min(fromRectLocal.x, toRectLocal.x) - 54 - routeOffset;
1220
+ const gutterX = Math.max(28, requestedGutterX);
1221
+ const points = [
1222
+ start,
1223
+ { x: gutterX, y: start.y },
1224
+ { x: gutterX, y: end.y },
1225
+ end
1226
+ ];
1227
+ const samples = lineSamples(points);
1228
+ return {
1229
+ d: `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y} L ${points[2].x} ${points[2].y} L ${points[3].x} ${points[3].y}`,
1230
+ labelX: gutterX,
1231
+ labelY: (start.y + end.y) / 2,
1232
+ cost: routeCostFromSamples(samples, { x: gutterX, y: (start.y + end.y) / 2 }, fromId, toId, []),
1233
+ samples
1234
+ };
1235
+ };
1236
+
1237
+ const edgePath = (fromId: Id, toId: Id, index: number, pairIndex: number, usedRoutes: Point[][]) => {
1238
+ const from = nodePosition(fromId);
1239
+ const to = nodePosition(toId);
1240
+ const fromLane = laneIndexByNode.get(fromId) ?? 0;
1241
+ const toLane = laneIndexByNode.get(toId) ?? 0;
1242
+ const fromRect = rectFor(fromId);
1243
+ const toRect = rectFor(toId);
1244
+ const fromCenter = { x: from.x + nodeWidth / 2, y: from.y + nodeHeight / 2 };
1245
+ const toCenter = { x: to.x + nodeWidth / 2, y: to.y + nodeHeight / 2 };
1246
+ const mid = { x: (fromCenter.x + toCenter.x) / 2, y: (fromCenter.y + toCenter.y) / 2 };
1247
+ const candidates: Route[] = [];
1248
+ const routeOffset = pairIndex * 40 + (index % 2) * 10;
1249
+ const spanMinX = Math.min(fromCenter.x, toCenter.x);
1250
+ const spanMaxX = Math.max(fromCenter.x, toCenter.x);
1251
+ const spanBlockers = Array.from(visibleNodeIds)
1252
+ .filter((nodeId) => nodeId !== fromId && nodeId !== toId)
1253
+ .map(rectFor)
1254
+ .filter((rect) => rect.x < spanMaxX && rect.x + rect.width > spanMinX);
1255
+ const topCorridor = Math.max(
1256
+ marginY - 16,
1257
+ Math.min(fromRect.y, toRect.y, ...spanBlockers.map((rect) => rect.y)) - 42 - routeOffset
1258
+ );
1259
+ const bottomCorridor = Math.max(
1260
+ fromRect.y + nodeHeight,
1261
+ toRect.y + nodeHeight,
1262
+ ...spanBlockers.map((rect) => rect.y + rect.height)
1263
+ ) + 42 + routeOffset;
1264
+
1265
+ const directStartSide: Side = Math.abs(toCenter.x - fromCenter.x) >= Math.abs(toCenter.y - fromCenter.y)
1266
+ ? toCenter.x >= fromCenter.x ? "right" : "left"
1267
+ : toCenter.y >= fromCenter.y ? "bottom" : "top";
1268
+ const directEndSide: Side = directStartSide === "right"
1269
+ ? "left"
1270
+ : directStartSide === "left"
1271
+ ? "right"
1272
+ : directStartSide === "bottom"
1273
+ ? "top"
1274
+ : "bottom";
1275
+ const directStart = anchorFor(fromRect, directStartSide);
1276
+ const directEnd = anchorFor(toRect, directEndSide);
1277
+ const directRoute = cubicRoute(
1278
+ fromId,
1279
+ toId,
1280
+ directStartSide,
1281
+ directEndSide,
1282
+ { x: (directStart.x + directEnd.x) / 2, y: directStart.y },
1283
+ { x: (directStart.x + directEnd.x) / 2, y: directEnd.y },
1284
+ mid,
1285
+ usedRoutes
1286
+ );
1287
+ if (!routeCollidesWithNode(directRoute.samples, fromId, toId, 8)) {
1288
+ directRoute.cost -= 90000;
1289
+ }
1290
+ candidates.push(directRoute);
1291
+
1292
+ const rowDelta = (rowIndexByNode.get(toId) ?? 0) - (rowIndexByNode.get(fromId) ?? 0);
1293
+ if (Math.abs(rowDelta) > 1) {
1294
+ candidates.push(cubicRoute(
1295
+ fromId,
1296
+ toId,
1297
+ rowDelta < 0 ? "top" : "bottom",
1298
+ rowDelta < 0 ? "top" : "bottom",
1299
+ { x: fromCenter.x, y: rowDelta < 0 ? topCorridor : bottomCorridor },
1300
+ { x: toCenter.x, y: rowDelta < 0 ? topCorridor : bottomCorridor },
1301
+ { x: mid.x, y: rowDelta < 0 ? topCorridor : bottomCorridor },
1302
+ usedRoutes
1303
+ ));
1304
+ }
1305
+
1306
+ if (Math.abs(toLane - fromLane) > 1) {
1307
+ candidates.push(cubicRoute(
1308
+ fromId,
1309
+ toId,
1310
+ "top",
1311
+ "top",
1312
+ { x: fromCenter.x, y: topCorridor },
1313
+ { x: toCenter.x, y: topCorridor },
1314
+ { x: mid.x, y: topCorridor },
1315
+ usedRoutes
1316
+ ));
1317
+ }
1318
+
1319
+ (["left", "right", "top", "bottom"] as Side[]).forEach((startSide) => {
1320
+ (["left", "right", "top", "bottom"] as Side[]).forEach((endSide) => {
1321
+ const start = anchorFor(fromRect, startSide);
1322
+ const end = anchorFor(toRect, endSide);
1323
+ const bend = Math.min(180, Math.max(42, Math.hypot(end.x - start.x, end.y - start.y) * 0.28 + routeOffset));
1324
+ const startTangent = tangentFor(startSide, bend);
1325
+ const endTangent = tangentFor(endSide, bend);
1326
+ candidates.push(cubicRoute(
1327
+ fromId,
1328
+ toId,
1329
+ startSide,
1330
+ endSide,
1331
+ { x: start.x + startTangent.x, y: start.y + startTangent.y },
1332
+ { x: end.x + endTangent.x, y: end.y + endTangent.y },
1333
+ mid,
1334
+ usedRoutes
1335
+ ));
1336
+ });
1337
+ });
1338
+
1339
+ if (fromLane === toLane) {
1340
+ const leftGutter = Math.min(fromRect.x, toRect.x) - 36 - routeOffset;
1341
+ const rightGutter = Math.max(fromRect.x + nodeWidth, toRect.x + nodeWidth) + 36 + routeOffset;
1342
+ candidates.push(
1343
+ cubicRoute(
1344
+ fromId,
1345
+ toId,
1346
+ "left",
1347
+ "left",
1348
+ { x: leftGutter, y: fromCenter.y },
1349
+ { x: leftGutter, y: toCenter.y },
1350
+ { x: leftGutter, y: mid.y },
1351
+ usedRoutes
1352
+ ),
1353
+ cubicRoute(
1354
+ fromId,
1355
+ toId,
1356
+ "right",
1357
+ "right",
1358
+ { x: rightGutter, y: fromCenter.y },
1359
+ { x: rightGutter, y: toCenter.y },
1360
+ { x: rightGutter, y: mid.y },
1361
+ usedRoutes
1362
+ )
1363
+ );
1364
+ }
1365
+
1366
+ if (toLane > fromLane) {
1367
+ const bend = Math.max(48, Math.abs(to.x - (from.x + nodeWidth)) * 0.42 + routeOffset);
1368
+ candidates.push(cubicRoute(
1369
+ fromId,
1370
+ toId,
1371
+ "right",
1372
+ "left",
1373
+ { x: from.x + nodeWidth + bend, y: fromCenter.y },
1374
+ { x: to.x - bend, y: toCenter.y },
1375
+ mid,
1376
+ usedRoutes
1377
+ ));
1378
+ }
1379
+
1380
+ if (toLane < fromLane) {
1381
+ const bend = Math.max(48, Math.abs(from.x - (to.x + nodeWidth)) * 0.42 + routeOffset);
1382
+ candidates.push(cubicRoute(
1383
+ fromId,
1384
+ toId,
1385
+ "left",
1386
+ "right",
1387
+ { x: from.x - bend, y: fromCenter.y },
1388
+ { x: to.x + nodeWidth + bend, y: toCenter.y },
1389
+ mid,
1390
+ usedRoutes
1391
+ ));
1392
+ }
1393
+
1394
+ candidates.push(
1395
+ cubicRoute(
1396
+ fromId,
1397
+ toId,
1398
+ "top",
1399
+ "top",
1400
+ { x: fromCenter.x, y: topCorridor },
1401
+ { x: toCenter.x, y: topCorridor },
1402
+ { x: mid.x, y: topCorridor },
1403
+ usedRoutes
1404
+ ),
1405
+ cubicRoute(
1406
+ fromId,
1407
+ toId,
1408
+ "bottom",
1409
+ "bottom",
1410
+ { x: fromCenter.x, y: bottomCorridor },
1411
+ { x: toCenter.x, y: bottomCorridor },
1412
+ { x: mid.x, y: bottomCorridor },
1413
+ usedRoutes
1414
+ )
1415
+ );
1416
+
1417
+ const topLimit = Math.min(fromRect.y, toRect.y);
1418
+ const bottomLimit = Math.max(fromRect.y + nodeHeight, toRect.y + nodeHeight);
1419
+ candidates.forEach((candidate) => {
1420
+ const travelsTop = candidate.samples.some((point) => point.y < topLimit - 4);
1421
+ const travelsBottom = candidate.samples.some((point) => point.y > bottomLimit + 4);
1422
+ if (pairIndex % 2 === 1 && travelsTop) {
1423
+ candidate.cost += 25000;
1424
+ }
1425
+ if (pairIndex % 2 === 1 && !travelsBottom) {
1426
+ candidate.cost += 4000;
1427
+ }
1428
+ if (pairIndex % 2 === 0 && travelsBottom) {
1429
+ candidate.cost += 600;
1430
+ }
1431
+ });
1432
+
1433
+ return candidates.sort((a, b) => a.cost - b.cost)[0];
1434
+ };
1435
+
1436
+ const structuralRelationships = Array.from(visibleNodeIds).flatMap((nodeId) => {
1437
+ const node = nodesById.get(nodeId);
1438
+ return (node?.dependencies ?? [])
1439
+ .filter((dependencyId) => visibleNodeIds.has(dependencyId))
1440
+ .map((dependencyId) => {
1441
+ const to = nodesById.get(dependencyId);
1442
+ const label = relationshipLabel(node, to);
1443
+ return {
1444
+ id: `${nodeId}-${dependencyId}`,
1445
+ from: nodeId,
1446
+ to: dependencyId,
1447
+ label,
1448
+ summary: `${node?.name ?? nodeId} ${label} ${to?.name ?? dependencyId}`,
1449
+ relationshipType: "structural" as const
1450
+ };
1451
+ });
1452
+ });
1453
+
1454
+ const flowRelationships = activeFlow?.steps.map((step, index) => {
1455
+ const from = nodesById.get(step.from);
1456
+ const to = nodesById.get(step.to);
1457
+ return {
1458
+ id: step.id,
1459
+ from: step.from,
1460
+ to: step.to,
1461
+ label: `${index + 1}. ${step.action}`,
1462
+ summary: step.summary,
1463
+ relationshipType: "flow" as const,
1464
+ stepId: step.id,
1465
+ flowId: activeFlow.id
1466
+ };
1467
+ }) ?? [];
1468
+
1469
+ const planRoutes = (relationships: Relationship[]) => {
1470
+ const usedRoutes: Point[][] = [];
1471
+ const pairCounts = new Map<string, number>();
1472
+ const routes = new Map<Id, Route>();
1473
+
1474
+ relationships.forEach((relationship, index) => {
1475
+ if (!laneIndexByNode.has(relationship.from) || !laneIndexByNode.has(relationship.to)) {
1476
+ return;
1477
+ }
1478
+
1479
+ const pairKey = [relationship.from, relationship.to].sort().join("<->");
1480
+ const pairIndex = pairCounts.get(pairKey) ?? 0;
1481
+ pairCounts.set(pairKey, pairIndex + 1);
1482
+
1483
+ const route = edgePath(relationship.from, relationship.to, index, pairIndex, usedRoutes);
1484
+ routes.set(relationship.id, route);
1485
+ usedRoutes.push(route.samples);
1486
+ });
1487
+
1488
+ return routes;
1489
+ };
1490
+
1491
+ const structuralRoutes = planRoutes(structuralRelationships);
1492
+ const flowRoutes = planRoutes(flowRelationships);
1493
+
1494
+ return (
1495
+ <section className="map-shell">
1496
+ <div
1497
+ className="diagram-canvas"
1498
+ style={{ width: canvasWidth, height: canvasHeight, transform: `scale(${transform.zoom})`, transformOrigin: "0 0" }}
1499
+ >
1500
+ <svg className="flow-lines" width={canvasWidth} height={canvasHeight} aria-hidden="false" role="group" aria-label={`${view.name} relationships`}>
1501
+ <defs>
1502
+ <marker id="arrowhead" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
1503
+ <path d="M 0 0 L 8 4 L 0 8 z" />
1504
+ </marker>
1505
+ <marker id="arrowhead-selected" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
1506
+ <path d="M 0 0 L 8 4 L 0 8 z" />
1507
+ </marker>
1508
+ </defs>
1509
+ {showStructuralConnections && structuralRelationships.map((connection, index) => {
1510
+ const route = structuralRoutes.get(connection.id);
1511
+ if (!route) return null;
1512
+ const selected = selectedRelationship?.from === connection.from && selectedRelationship.to === connection.to;
1513
+ return (
1514
+ <g
1515
+ key={`${connection.from}-${connection.to}`}
1516
+ className={selected ? "relationship-edge selected" : "relationship-edge"}
1517
+ role="button"
1518
+ tabIndex={0}
1519
+ aria-label={connection.summary}
1520
+ onClick={() => onSelectRelationship(connection)}
1521
+ onKeyDown={(event) => {
1522
+ if (event.key === "Enter" || event.key === " ") onSelectRelationship(connection);
1523
+ }}
1524
+ >
1525
+ <path
1526
+ className="structural-line"
1527
+ d={route.d}
1528
+ markerEnd={selected ? "url(#arrowhead-selected)" : "url(#arrowhead)"}
1529
+ />
1530
+ <text className="relationship-label" x={route.labelX} y={route.labelY - 8}>{connection.label}</text>
1531
+ </g>
1532
+ );
1533
+ })}
1534
+ {!showStructuralConnections && flowRelationships.map((relationship, index) => {
1535
+ if (!laneIndexByNode.has(relationship.from) || !laneIndexByNode.has(relationship.to)) {
1536
+ return null;
1537
+ }
1538
+ const route = flowRoutes.get(relationship.id);
1539
+ if (!route) return null;
1540
+ const isSelected = selectedStepId === relationship.stepId || (
1541
+ selectedRelationship?.from === relationship.from &&
1542
+ selectedRelationship.to === relationship.to &&
1543
+ selectedRelationship.stepId === relationship.stepId
1544
+ );
1545
+ return (
1546
+ <g
1547
+ key={relationship.id}
1548
+ className={isSelected ? "flow-edge selected" : "flow-edge"}
1549
+ role="button"
1550
+ tabIndex={0}
1551
+ aria-label={relationship.summary}
1552
+ onClick={() => onSelectRelationship(relationship)}
1553
+ onKeyDown={(event) => {
1554
+ if (event.key === "Enter" || event.key === " ") onSelectRelationship(relationship);
1555
+ }}
1556
+ >
1557
+ <path
1558
+ className="flow-line"
1559
+ d={route.d}
1560
+ markerEnd={isSelected ? "url(#arrowhead-selected)" : "url(#arrowhead)"}
1561
+ />
1562
+ <rect className="flow-step-dot" x={route.labelX - 10} y={route.labelY - 10} width="20" height="20" />
1563
+ <text className="flow-step-label" x={route.labelX} y={route.labelY + 4}>{index + 1}</text>
1564
+ </g>
1565
+ );
1566
+ })}
1567
+ </svg>
1568
+ {view.lanes.map((lane, laneIndex) => (
1569
+ <div
1570
+ className="lane-column"
1571
+ key={lane.id}
1572
+ style={{ left: marginX + laneIndex * laneWidth, width: nodeWidth, height: canvasHeight - 20 }}
1573
+ >
1574
+ <h3>{lane.name}</h3>
1575
+ </div>
1576
+ ))}
1577
+ {view.lanes.flatMap((lane) => lane.nodeIds).map((nodeId) => {
1578
+ const node = nodesById.get(nodeId);
1579
+ if (!node) return null;
1580
+ const isActive = flowNodeIds.has(node.id);
1581
+ const isSelected = selectedNodeId === node.id;
1582
+ const position = nodePosition(node.id);
1583
+ return (
1584
+ <button
1585
+ key={node.id}
1586
+ type="button"
1587
+ className={`node-card ${node.type} ${isActive ? "in-flow" : ""} ${isSelected ? "selected" : ""}`}
1588
+ style={{ left: position.x, top: position.y, width: nodeWidth, height: nodeHeight }}
1589
+ onClick={() => onSelectNode(node.id)}
1590
+ >
1591
+ <strong>{node.name}</strong>
1592
+ <span>{node.type}</span>
1593
+ </button>
1594
+ );
1595
+ })}
1596
+ </div>
1597
+ {activeFlow ? (
1598
+ <div className="edge-strip">
1599
+ {flowRelationships.map((relationship, index) => (
1600
+ <button
1601
+ type="button"
1602
+ className={`edge-chip ${selectedStepId === relationship.stepId ? "active" : ""}`}
1603
+ key={relationship.id}
1604
+ title={relationship.summary}
1605
+ onClick={() => onSelectRelationship(relationship)}
1606
+ >
1607
+ {index + 1}. {relationship.from} {"→"} {relationship.to}
1608
+ </button>
1609
+ ))}
1610
+ <span className="edge-count">{activeFlow.steps.length} ordered transitions</span>
1611
+ </div>
1612
+ ) : (
1613
+ <div className="edge-strip">
1614
+ <span className="edge-count">Structural connections only</span>
1615
+ </div>
1616
+ )}
1617
+ </section>
1618
+ );
1619
+ }
1620
+
1621
+ function C4Diagram({
1622
+ view,
1623
+ nodesById,
1624
+ selectedNodeId,
1625
+ selectedRelationship,
1626
+ transform,
1627
+ onSelectNode,
1628
+ onSelectRelationship
1629
+ }: {
1630
+ view: View;
1631
+ nodesById: Map<Id, ArchNode>;
1632
+ selectedNodeId: Id | null;
1633
+ selectedRelationship: Extract<Selection, { kind: "relationship" }> | null;
1634
+ transform: DiagramTransform;
1635
+ onSelectNode: (id: Id) => void;
1636
+ onSelectRelationship: (relationship: Relationship) => void;
1637
+ }) {
1638
+ const nodeWidth = 156;
1639
+ const nodeHeight = 62;
1640
+ const laneWidth = 210;
1641
+ const marginX = 56;
1642
+ const marginY = 76;
1643
+ const rowGap = 86;
1644
+ const allNodeIds = view.lanes.flatMap((lane) => lane.nodeIds);
1645
+ const visibleNodeIds = new Set(allNodeIds);
1646
+ const canvasWidth = Math.max(760, marginX * 2 + view.lanes.length * laneWidth + 40);
1647
+ const canvasHeight = Math.max(440, marginY + Math.max(...view.lanes.map((lane) => lane.nodeIds.length), 1) * rowGap + 88);
1648
+ const positionFor = (nodeId: Id) => {
1649
+ const laneIndex = view.lanes.findIndex((lane) => lane.nodeIds.includes(nodeId));
1650
+ const rowIndex = view.lanes[Math.max(laneIndex, 0)]?.nodeIds.indexOf(nodeId) ?? 0;
1651
+ return {
1652
+ x: marginX + Math.max(laneIndex, 0) * laneWidth,
1653
+ y: marginY + Math.max(rowIndex, 0) * rowGap
1654
+ };
1655
+ };
1656
+ const centerFor = (nodeId: Id) => {
1657
+ const position = positionFor(nodeId);
1658
+ return { x: position.x + nodeWidth / 2, y: position.y + nodeHeight / 2 };
1659
+ };
1660
+ const relationships = allNodeIds.flatMap((nodeId) => {
1661
+ const node = nodesById.get(nodeId);
1662
+ return (node?.dependencies ?? [])
1663
+ .filter((dependencyId) => visibleNodeIds.has(dependencyId))
1664
+ .map((dependencyId) => {
1665
+ const to = nodesById.get(dependencyId);
1666
+ const label = relationshipLabel(node, to);
1667
+ return {
1668
+ id: `${nodeId}-${dependencyId}`,
1669
+ from: nodeId,
1670
+ to: dependencyId,
1671
+ label,
1672
+ summary: `${node?.name ?? nodeId} ${label} ${to?.name ?? dependencyId}`,
1673
+ relationshipType: "structural" as const
1674
+ };
1675
+ });
1676
+ });
1677
+
1678
+ const pathFor = (relationship: Relationship, index: number) => {
1679
+ const from = centerFor(relationship.from);
1680
+ const to = centerFor(relationship.to);
1681
+ const direction = to.x >= from.x ? 1 : -1;
1682
+ const offset = (index % 3 - 1) * 18;
1683
+ const startX = from.x + direction * (nodeWidth / 2);
1684
+ const endX = to.x - direction * (nodeWidth / 2);
1685
+ const startY = from.y + offset;
1686
+ const endY = to.y + offset;
1687
+ const midX = (startX + endX) / 2;
1688
+ return {
1689
+ d: `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`,
1690
+ labelX: midX,
1691
+ labelY: Math.min(startY, endY) - 10
1692
+ };
1693
+ };
1694
+
1695
+ return (
1696
+ <section className="map-shell c4-shell">
1697
+ <div
1698
+ className={`c4-canvas ${view.type}`}
1699
+ style={{ width: canvasWidth, height: canvasHeight, transform: `scale(${transform.zoom})`, transformOrigin: "0 0" }}
1700
+ >
1701
+ <svg className="flow-lines c4-lines" width={canvasWidth} height={canvasHeight} role="group" aria-label={`${view.name} structural relationships`}>
1702
+ <defs>
1703
+ <marker id="c4-arrowhead" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
1704
+ <path d="M 0 0 L 8 4 L 0 8 z" />
1705
+ </marker>
1706
+ <marker id="c4-arrowhead-selected" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
1707
+ <path d="M 0 0 L 8 4 L 0 8 z" />
1708
+ </marker>
1709
+ </defs>
1710
+ {relationships.map((relationship, index) => {
1711
+ const route = pathFor(relationship, index);
1712
+ const selected = selectedRelationship?.from === relationship.from && selectedRelationship.to === relationship.to;
1713
+ return (
1714
+ <g
1715
+ key={relationship.id}
1716
+ className={selected ? "relationship-edge selected" : "relationship-edge"}
1717
+ role="button"
1718
+ tabIndex={0}
1719
+ aria-label={relationship.summary}
1720
+ onClick={() => onSelectRelationship(relationship)}
1721
+ onKeyDown={(event) => {
1722
+ if (event.key === "Enter" || event.key === " ") onSelectRelationship(relationship);
1723
+ }}
1724
+ >
1725
+ <path
1726
+ className="c4-relationship"
1727
+ d={route.d}
1728
+ markerEnd={selected ? "url(#c4-arrowhead-selected)" : "url(#c4-arrowhead)"}
1729
+ />
1730
+ <text className="relationship-label c4-label" x={route.labelX} y={route.labelY}>{relationship.label}</text>
1731
+ </g>
1732
+ );
1733
+ })}
1734
+ </svg>
1735
+ <div className="c4-boundary">
1736
+ <span>{view.type === "c4-context" ? "System boundary" : view.type === "c4-container" ? "Container boundary" : "Component scope"}</span>
1737
+ </div>
1738
+ {view.lanes.map((lane, laneIndex) => (
1739
+ <div
1740
+ className="c4-lane-label"
1741
+ key={lane.id}
1742
+ style={{ left: marginX + laneIndex * laneWidth, top: 34, width: nodeWidth }}
1743
+ >
1744
+ {lane.name}
1745
+ </div>
1746
+ ))}
1747
+ {allNodeIds.map((nodeId) => {
1748
+ const node = nodesById.get(nodeId);
1749
+ if (!node) return null;
1750
+ const position = positionFor(nodeId);
1751
+ return (
1752
+ <button
1753
+ key={node.id}
1754
+ type="button"
1755
+ className={`c4-node ${node.type} ${selectedNodeId === node.id ? "selected" : ""}`}
1756
+ style={{ left: position.x, top: position.y, width: nodeWidth, minHeight: nodeHeight }}
1757
+ onClick={() => onSelectNode(node.id)}
1758
+ aria-label={`${node.name}, ${node.type}. ${node.summary}`}
1759
+ >
1760
+ <strong>{node.name}</strong>
1761
+ <span>{node.type}</span>
1762
+ <small>{node.summary}</small>
1763
+ </button>
1764
+ );
1765
+ })}
1766
+ </div>
1767
+ <div className="edge-strip">
1768
+ <span className="edge-count">{relationships.length} labeled structural relationships</span>
1769
+ </div>
1770
+ </section>
1771
+ );
1772
+ }
1773
+
1774
+ function SequenceDiagram({
1775
+ activeFlow,
1776
+ nodesById,
1777
+ dataById,
1778
+ selectedStepId,
1779
+ transform,
1780
+ onSelectRelationship,
1781
+ onSelectStep
1782
+ }: {
1783
+ activeFlow: Flow;
1784
+ nodesById: Map<Id, ArchNode>;
1785
+ dataById: Map<Id, DataClass>;
1786
+ selectedStepId: Id | null;
1787
+ transform: DiagramTransform;
1788
+ onSelectRelationship: (relationship: Relationship) => void;
1789
+ onSelectStep: (stepId: Id) => void;
1790
+ }) {
1791
+ const participantIds = Array.from(new Set(activeFlow.steps.flatMap((step) => [step.from, step.to])));
1792
+ const participantWidth = 146;
1793
+ const rowHeight = 56;
1794
+ const marginX = 28;
1795
+ const headerY = 18;
1796
+ const messageStartY = 68;
1797
+ const width = marginX * 2 + participantIds.length * participantWidth;
1798
+ const height = messageStartY + activeFlow.steps.length * rowHeight + 38;
1799
+ const xFor = (id: Id) => marginX + participantIds.indexOf(id) * participantWidth + participantWidth / 2;
1800
+
1801
+ return (
1802
+ <section className="map-shell sequence-shell">
1803
+ <div
1804
+ className="sequence-participant-rail"
1805
+ style={{ width, transform: `scale(${transform.zoom})`, transformOrigin: "0 0" }}
1806
+ >
1807
+ {participantIds.map((id) => {
1808
+ const node = nodesById.get(id);
1809
+ const x = xFor(id);
1810
+ return (
1811
+ <button
1812
+ key={id}
1813
+ type="button"
1814
+ className={`sequence-participant-card ${node?.type ?? ""}`}
1815
+ style={{ left: x - 58, width: 116 }}
1816
+ aria-label={`${node?.name ?? id} participant`}
1817
+ >
1818
+ <strong>{node?.name ?? id}</strong>
1819
+ <span>{node?.type ?? "node"}</span>
1820
+ </button>
1821
+ );
1822
+ })}
1823
+ </div>
1824
+ <svg
1825
+ className="sequence-canvas"
1826
+ width={width}
1827
+ height={height}
1828
+ role="img"
1829
+ aria-label={`${activeFlow.name} sequence diagram`}
1830
+ style={{ transform: `scale(${transform.zoom})`, transformOrigin: "0 0" }}
1831
+ >
1832
+ <defs>
1833
+ <marker id="sequence-arrowhead" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
1834
+ <path d="M 0 0 L 8 4 L 0 8 z" />
1835
+ </marker>
1836
+ <marker id="sequence-arrowhead-response" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
1837
+ <path d="M 0 0 L 8 4 L 0 8 z" />
1838
+ </marker>
1839
+ <marker id="sequence-arrowhead-persistence" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
1840
+ <path d="M 0 0 L 8 4 L 0 8 z" />
1841
+ </marker>
1842
+ <marker id="sequence-arrowhead-selected" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
1843
+ <path d="M 0 0 L 8 4 L 0 8 z" />
1844
+ </marker>
1845
+ </defs>
1846
+ {participantIds.map((id) => {
1847
+ const node = nodesById.get(id);
1848
+ const x = xFor(id);
1849
+ return (
1850
+ <g key={id}>
1851
+ <line className="lifeline" x1={x} y1={headerY + 48} x2={x} y2={height - 22} />
1852
+ </g>
1853
+ );
1854
+ })}
1855
+ {activeFlow.steps.map((step, index) => {
1856
+ const fromX = xFor(step.from);
1857
+ const toX = xFor(step.to);
1858
+ const y = messageStartY + index * rowHeight;
1859
+ const midX = (fromX + toX) / 2;
1860
+ const dataLabel = step.data.map((id) => dataById.get(id)?.name ?? id).join(", ");
1861
+ const messageKind = step.to.includes("queue") ? "async" : step.to.includes("db") || step.to.includes("store") ? "persistence" : toX < fromX ? "response" : "request";
1862
+ const markerId = selectedStepId === step.id
1863
+ ? "sequence-arrowhead-selected"
1864
+ : messageKind === "response"
1865
+ ? "sequence-arrowhead-response"
1866
+ : messageKind === "persistence"
1867
+ ? "sequence-arrowhead-persistence"
1868
+ : "sequence-arrowhead";
1869
+ const relationship = {
1870
+ id: step.id,
1871
+ from: step.from,
1872
+ to: step.to,
1873
+ label: step.action,
1874
+ summary: step.summary,
1875
+ relationshipType: "flow" as const,
1876
+ stepId: step.id,
1877
+ flowId: activeFlow.id
1878
+ };
1879
+ return (
1880
+ <g
1881
+ key={step.id}
1882
+ className={`sequence-message ${messageKind} ${selectedStepId === step.id ? "selected" : ""}`}
1883
+ role="button"
1884
+ tabIndex={0}
1885
+ aria-label={`${step.action}: ${step.summary}`}
1886
+ onClick={() => {
1887
+ onSelectStep(step.id);
1888
+ onSelectRelationship(relationship);
1889
+ }}
1890
+ onKeyDown={(event) => {
1891
+ if (event.key === "Enter" || event.key === " ") {
1892
+ onSelectStep(step.id);
1893
+ onSelectRelationship(relationship);
1894
+ }
1895
+ }}
1896
+ >
1897
+ <line
1898
+ className="sequence-line"
1899
+ x1={fromX}
1900
+ y1={y}
1901
+ x2={toX}
1902
+ y2={y}
1903
+ markerEnd={`url(#${markerId})`}
1904
+ />
1905
+ <rect className="sequence-step-dot" x={midX - 10} y={y - 10} width="20" height="20" />
1906
+ <text className="sequence-step-label" x={midX} y={y + 4}>{index + 1}</text>
1907
+ <text className="sequence-action" x={midX} y={y - 17}>{step.action.length > 26 ? `${step.action.slice(0, 23)}...` : step.action}</text>
1908
+ <text className="sequence-data" x={midX} y={y + 30}>{dataLabel}</text>
1909
+ </g>
1910
+ );
1911
+ })}
1912
+ </svg>
1913
+ </section>
1914
+ );
1915
+ }
1916
+
1917
+ function DetailPanel({
1918
+ nodesById,
1919
+ flowsById,
1920
+ dataById,
1921
+ decisionsById,
1922
+ risksById,
1923
+ selection,
1924
+ selectedStep,
1925
+ activeFlow,
1926
+ onSelectNode,
1927
+ onSelectFlow
1928
+ }: {
1929
+ model: Model;
1930
+ nodesById: Map<Id, ArchNode>;
1931
+ flowsById: Map<Id, Flow>;
1932
+ dataById: Map<Id, DataClass>;
1933
+ decisionsById: Map<Id, Decision>;
1934
+ risksById: Map<Id, Risk>;
1935
+ flowNodeIds: Set<Id>;
1936
+ selection: Selection | null;
1937
+ selectedStep: FlowStep | null | undefined;
1938
+ activeFlow: Flow;
1939
+ onSelectNode: (id: Id) => void;
1940
+ onSelectFlow: (id: Id) => void;
1941
+ }) {
1942
+ if (selection?.kind === "relationship") {
1943
+ const from = nodesById.get(selection.from);
1944
+ const to = nodesById.get(selection.to);
1945
+ const relatedStep = selection.flowId && selection.stepId
1946
+ ? flowsById.get(selection.flowId)?.steps.find((step) => step.id === selection.stepId)
1947
+ : null;
1948
+ const flow = selection.flowId ? flowsById.get(selection.flowId) : null;
1949
+ return (
1950
+ <DetailShell eyebrow="Relationship" title={`${from?.name ?? selection.from} → ${to?.name ?? selection.to}`} summary={selection.label}>
1951
+ <div className="badge-row">
1952
+ <Badge>{selection.relationshipType}</Badge>
1953
+ {flow && <Badge>{flow.name}</Badge>}
1954
+ </div>
1955
+ <FieldList title="Summary" items={[relatedStep?.summary ?? `${from?.name ?? selection.from} ${selection.label} ${to?.name ?? selection.to}`]} />
1956
+ <section className="detail-section" id="runtime">
1957
+ <h3>Endpoints</h3>
1958
+ <div className="path-pair">
1959
+ <button type="button" onClick={() => onSelectNode(selection.from)}>{from?.name ?? selection.from}</button>
1960
+ <span>{"→"}</span>
1961
+ <button type="button" onClick={() => onSelectNode(selection.to)}>{to?.name ?? selection.to}</button>
1962
+ </div>
1963
+ </section>
1964
+ {relatedStep && (
1965
+ <FieldList title="Data" items={relatedStep.data.map((id) => dataById.get(id)?.name ?? id)} />
1966
+ )}
1967
+ </DetailShell>
1968
+ );
1969
+ }
1970
+
1971
+ if (selection?.kind === "node") {
1972
+ const node = nodesById.get(selection.id);
1973
+ if (!node) return <EmptyDetail />;
1974
+ const relatedFlows = node.relatedFlows.map((id) => flowsById.get(id)).filter(Boolean) as Flow[];
1975
+ const decisions = node.relatedDecisions.map((id) => decisionsById.get(id)).filter(Boolean) as Decision[];
1976
+ const risks = node.knownRisks.map((id) => risksById.get(id)).filter(Boolean) as Risk[];
1977
+ const dataClasses = node.dataHandled.map((id) => dataById.get(id)).filter(Boolean) as DataClass[];
1978
+
1979
+ return (
1980
+ <DetailShell eyebrow={node.type} title={node.name} summary={node.summary}>
1981
+ <div className="badge-row">
1982
+ <Badge>{node.owner}</Badge>
1983
+ {dataClasses.map((item) => <Badge key={item.id} tone={item.sensitivity}>{item.name}</Badge>)}
1984
+ </div>
1985
+ <FieldList title="Responsibilities" items={node.responsibilities} />
1986
+ <FieldList title="Source paths" items={node.sourcePaths} />
1987
+ <FieldList title="Runtime / deployment" items={[node.runtime]} />
1988
+ <FieldList title="Interfaces" items={node.interfaces} />
1989
+ <FieldList title="Dependencies" items={node.dependencies.map((id) => nodesById.get(id)?.name ?? id)} />
1990
+ <FieldList title="Security / trust" items={node.security} />
1991
+ <FieldList title="Observability" items={node.observability} />
1992
+ <LinkList title="Related flows" items={relatedFlows.map((flow) => ({ id: flow.id, label: flow.name }))} onClick={onSelectFlow} />
1993
+ <DecisionList decisions={decisions} />
1994
+ <RiskList risks={risks} />
1995
+ <FieldList title="Verification" items={node.verification} />
1996
+ </DetailShell>
1997
+ );
1998
+ }
1999
+
2000
+ if (selection?.kind === "step" && selectedStep) {
2001
+ const from = nodesById.get(selectedStep.from);
2002
+ const to = nodesById.get(selectedStep.to);
2003
+ const dataClasses = selectedStep.data.map((id) => dataById.get(id)).filter(Boolean) as DataClass[];
2004
+ return (
2005
+ <DetailShell eyebrow="Flow step" title={selectedStep.action} summary={selectedStep.summary}>
2006
+ <div className="path-pair">
2007
+ <button type="button" onClick={() => onSelectNode(selectedStep.from)}>{from?.name ?? selectedStep.from}</button>
2008
+ <span>{"→"}</span>
2009
+ <button type="button" onClick={() => onSelectNode(selectedStep.to)}>{to?.name ?? selectedStep.to}</button>
2010
+ </div>
2011
+ <section className="detail-section">
2012
+ <h3>Data moved</h3>
2013
+ {dataClasses.map((item) => (
2014
+ <article className="data-class" key={item.id}>
2015
+ <strong>{item.name}</strong>
2016
+ <Badge tone={item.sensitivity}>{item.sensitivity}</Badge>
2017
+ <p>{item.handling}</p>
2018
+ </article>
2019
+ ))}
2020
+ </section>
2021
+ </DetailShell>
2022
+ );
2023
+ }
2024
+
2025
+ const flow = selection?.kind === "flow" ? flowsById.get(selection.id) ?? activeFlow : activeFlow;
2026
+ return (
2027
+ <DetailShell eyebrow="Flow" title={flow.name} summary={flow.summary}>
2028
+ <div className="badge-row">
2029
+ <Badge tone={flow.status}>{statusLabels[flow.status]}</Badge>
2030
+ <Badge>{flow.steps.length} steps</Badge>
2031
+ </div>
2032
+ <FieldList title="Trigger" items={[flow.trigger]} />
2033
+ <FieldList title="Guarantees" items={flow.guarantees} />
2034
+ <FieldList title="Failure behavior" items={flow.failureBehavior} />
2035
+ <FieldList title="Observability" items={flow.observability} />
2036
+ <FieldList title="Known gaps" items={flow.knownGaps} />
2037
+ <FieldList title="Verification" items={flow.verification} />
2038
+ </DetailShell>
2039
+ );
2040
+ }
2041
+
2042
+ function DetailShell({ eyebrow, title, summary, children }: { eyebrow: string; title: string; summary: string; children: React.ReactNode }) {
2043
+ const sections = ["Summary", "Runtime", "Interfaces", "Data", "Security", "Observability", "Risks", "Decisions", "Verification"];
2044
+ return (
2045
+ <div className="detail-content">
2046
+ <div className="detail-sticky">
2047
+ <p className="eyebrow">{eyebrow}</p>
2048
+ <h2>{title}</h2>
2049
+ <p>{summary}</p>
2050
+ <nav className="detail-index" aria-label="Detail sections">
2051
+ {sections.map((section) => (
2052
+ <a key={section} href={`#${section.toLowerCase().replaceAll(" ", "-")}`}>{section}</a>
2053
+ ))}
2054
+ </nav>
2055
+ </div>
2056
+ {children}
2057
+ </div>
2058
+ );
2059
+ }
2060
+
2061
+ function LinkList({ title, items, onClick }: { title: string; items: Array<{ id: Id; label: string }>; onClick: (id: Id) => void }) {
2062
+ return (
2063
+ <section className="detail-section">
2064
+ <h3>{title}</h3>
2065
+ {items.length > 0 ? items.map((item) => (
2066
+ <button className="text-link" type="button" key={item.id} onClick={() => onClick(item.id)}>
2067
+ {item.label}
2068
+ </button>
2069
+ )) : <p className="muted">None recorded.</p>}
2070
+ </section>
2071
+ );
2072
+ }
2073
+
2074
+ function DecisionList({ decisions }: { decisions: Decision[] }) {
2075
+ return (
2076
+ <section className="detail-section" id="decisions">
2077
+ <h3>Related decisions</h3>
2078
+ {decisions.length > 0 ? decisions.map((decision) => (
2079
+ <article className="mini-record" key={decision.id}>
2080
+ <strong>{decision.title}</strong>
2081
+ <p>{decision.decision}</p>
2082
+ </article>
2083
+ )) : <p className="muted">None recorded.</p>}
2084
+ </section>
2085
+ );
2086
+ }
2087
+
2088
+ function RiskList({ risks }: { risks: Risk[] }) {
2089
+ return (
2090
+ <section className="detail-section" id="risks">
2091
+ <h3>Known risks</h3>
2092
+ {risks.length > 0 ? risks.map((risk) => (
2093
+ <article className="mini-record" key={risk.id}>
2094
+ <strong>{risk.title}</strong>
2095
+ <Badge tone={risk.severity}>{risk.severity}</Badge>
2096
+ <p>{risk.summary}</p>
2097
+ </article>
2098
+ )) : <p className="muted">None recorded.</p>}
2099
+ </section>
2100
+ );
2101
+ }
2102
+
2103
+ function EmptyDetail() {
2104
+ return (
2105
+ <div className="detail-content">
2106
+ <h2>No selection</h2>
2107
+ <p>Select a node, flow, or step to inspect details.</p>
2108
+ </div>
2109
+ );
2110
+ }
2111
+
2112
+ type ArchitextWindow = Window & { __architextRoot?: Root };
2113
+ type ArchitextHotData = { root?: Root };
2114
+ type ArchitextImportMeta = ImportMeta & { hot?: { data: ArchitextHotData } };
2115
+ type ArchitextRootElement = HTMLElement & { __architextRoot?: Root };
2116
+
2117
+ const rootElement = document.getElementById("root") as ArchitextRootElement;
2118
+ const hot = (import.meta as ArchitextImportMeta).hot;
2119
+ const hotData = hot?.data;
2120
+ const existingRoot = rootElement.__architextRoot ?? hotData?.root ?? (window as ArchitextWindow).__architextRoot;
2121
+ const root = existingRoot ?? createRoot(rootElement);
2122
+
2123
+ rootElement.__architextRoot = root;
2124
+ (window as ArchitextWindow).__architextRoot = root;
2125
+ if (hot) {
2126
+ hot.data.root = root;
2127
+ }
2128
+
2129
+ root.render(
2130
+ <React.StrictMode>
2131
+ <App />
2132
+ </React.StrictMode>
2133
+ );