@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.
- package/LICENSE +22 -0
- package/README.md +497 -0
- package/docs/architecture/AGENTS_APPENDIX.md +39 -0
- package/docs/architecture/ARCHITECTURE_PLAN.md +520 -0
- package/docs/architecture/LLM_ARCHITEXT.md +95 -0
- package/docs/architext/AGENTS_APPENDIX.md +39 -0
- package/docs/architext/LLM_ARCHITEXT.md +64 -0
- package/docs/architext/README.md +120 -0
- package/docs/architext/data/data-classification.json +34 -0
- package/docs/architext/data/decisions.json +54 -0
- package/docs/architext/data/flows.json +114 -0
- package/docs/architext/data/glossary.json +24 -0
- package/docs/architext/data/manifest.json +23 -0
- package/docs/architext/data/nodes.json +194 -0
- package/docs/architext/data/risks.json +59 -0
- package/docs/architext/data/views.json +91 -0
- package/docs/architext/dist/assets/index-BWZ6sEpA.js +51 -0
- package/docs/architext/dist/assets/index-iWLms0Pa.css +1 -0
- package/docs/architext/dist/compass.svg +9 -0
- package/docs/architext/dist/index.html +14 -0
- package/docs/architext/index.html +13 -0
- package/docs/architext/package-lock.json +1822 -0
- package/docs/architext/package.json +28 -0
- package/docs/architext/public/compass.svg +9 -0
- package/docs/architext/schema/data-classification.schema.json +28 -0
- package/docs/architext/schema/decisions.schema.json +33 -0
- package/docs/architext/schema/flows.schema.json +72 -0
- package/docs/architext/schema/glossary.schema.json +22 -0
- package/docs/architext/schema/manifest.schema.json +47 -0
- package/docs/architext/schema/nodes.schema.json +69 -0
- package/docs/architext/schema/risks.schema.json +34 -0
- package/docs/architext/schema/views.schema.json +48 -0
- package/docs/architext/src/main.tsx +2133 -0
- package/docs/architext/src/styles.css +1475 -0
- package/docs/architext/tools/validate-architext.mjs +163 -0
- package/docs/architext/tsconfig.json +21 -0
- package/docs/architext/vite.config.ts +47 -0
- package/docs/assets/screenshots/architext-c4.png +0 -0
- package/docs/assets/screenshots/architext-data-risks.png +0 -0
- package/docs/assets/screenshots/architext-flows.png +0 -0
- package/docs/assets/screenshots/architext-sequence.png +0 -0
- package/package.json +81 -0
- 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
|
+
);
|