@robotaccomplice/architext 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -4
- package/THIRD_PARTY_NOTICES.md +59 -0
- package/docs/architecture/ROUTING_FRAMEWORK_COMPARISON.md +284 -0
- package/docs/architecture/ROUTING_PLAN.md +480 -0
- package/docs/architext/dist/assets/index-DMbdxljw.js +51 -0
- package/docs/architext/dist/assets/index-DvokFPhn.css +1 -0
- package/docs/architext/dist/assets/planningWorker-DY_nEecj.js +1 -0
- package/docs/architext/dist/index.html +2 -2
- package/docs/architext/src/main.tsx +450 -556
- package/docs/architext/src/routing/planDiagram.js +208 -0
- package/docs/architext/src/routing/planningWorker.js +15 -0
- package/docs/architext/src/routing/routeEdges.js +1484 -0
- package/docs/architext/src/styles.css +180 -1
- package/docs/architext/tsconfig.json +1 -2
- 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 +9 -4
- package/docs/architext/dist/assets/index-BWZ6sEpA.js +0 -51
- package/docs/architext/dist/assets/index-iWLms0Pa.css +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { createRoot } from "react-dom/client";
|
|
3
3
|
import type { Root } from "react-dom/client";
|
|
4
|
+
import { planDiagram } from "./routing/planDiagram.js";
|
|
4
5
|
import "./styles.css";
|
|
5
6
|
|
|
6
7
|
type Id = string;
|
|
@@ -142,6 +143,7 @@ type Selection =
|
|
|
142
143
|
| { kind: "relationship"; from: Id; to: Id; label: string; relationshipType: "flow" | "structural"; stepId?: Id; flowId?: Id };
|
|
143
144
|
|
|
144
145
|
type Mode = "flows" | "sequence" | "c4" | "deployment" | "data-risks";
|
|
146
|
+
type RoutingStyle = "orthogonal" | "curved";
|
|
145
147
|
type DiagramTransform = {
|
|
146
148
|
zoom: number;
|
|
147
149
|
focused: boolean;
|
|
@@ -163,6 +165,8 @@ type Relationship = {
|
|
|
163
165
|
flowId?: Id;
|
|
164
166
|
};
|
|
165
167
|
|
|
168
|
+
const ROUTING_LOADING_DELAY_MS = 1000;
|
|
169
|
+
|
|
166
170
|
const modeLabels: Record<Mode, string> = {
|
|
167
171
|
flows: "Flows",
|
|
168
172
|
sequence: "Sequence",
|
|
@@ -208,6 +212,14 @@ function relationshipLabel(from: ArchNode | undefined, to: ArchNode | undefined)
|
|
|
208
212
|
return "depends on";
|
|
209
213
|
}
|
|
210
214
|
|
|
215
|
+
function initialRoutingStyle(): RoutingStyle {
|
|
216
|
+
return localStorage.getItem("architext-routing-style") === "curved" ? "curved" : "orthogonal";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function initialDebugRouting(): boolean {
|
|
220
|
+
return new URLSearchParams(window.location.search).get("debugRouting") === "1";
|
|
221
|
+
}
|
|
222
|
+
|
|
211
223
|
async function fetchJson<T>(path: string): Promise<T> {
|
|
212
224
|
const response = await fetch(path);
|
|
213
225
|
if (!response.ok) {
|
|
@@ -309,6 +321,170 @@ function useElementSize<T extends HTMLElement>() {
|
|
|
309
321
|
return [ref, size] as const;
|
|
310
322
|
}
|
|
311
323
|
|
|
324
|
+
function planInputKey(input: any): string {
|
|
325
|
+
return JSON.stringify({
|
|
326
|
+
view: {
|
|
327
|
+
id: input.view.id,
|
|
328
|
+
type: input.view.type,
|
|
329
|
+
lanes: input.view.lanes.map((lane: any) => [lane.id, lane.nodeIds])
|
|
330
|
+
},
|
|
331
|
+
relationships: input.relationships.map((relationship: Relationship) => ({
|
|
332
|
+
id: relationship.id,
|
|
333
|
+
from: relationship.from,
|
|
334
|
+
to: relationship.to,
|
|
335
|
+
label: relationship.label,
|
|
336
|
+
relationshipType: relationship.relationshipType,
|
|
337
|
+
stepId: relationship.stepId,
|
|
338
|
+
flowId: relationship.flowId
|
|
339
|
+
})),
|
|
340
|
+
visibleNodeIds: Array.from(input.visibleNodeIds).sort(),
|
|
341
|
+
nodeWidth: input.nodeWidth,
|
|
342
|
+
nodeHeight: input.nodeHeight,
|
|
343
|
+
laneWidth: input.laneWidth,
|
|
344
|
+
rowGap: input.rowGap,
|
|
345
|
+
marginX: input.marginX,
|
|
346
|
+
marginY: input.marginY,
|
|
347
|
+
minCanvasWidth: input.minCanvasWidth,
|
|
348
|
+
minCanvasHeight: input.minCanvasHeight,
|
|
349
|
+
canvasExtraWidth: input.canvasExtraWidth,
|
|
350
|
+
canvasExtraHeight: input.canvasExtraHeight,
|
|
351
|
+
style: input.style
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function attachPlanHelpers(plan: any) {
|
|
356
|
+
return {
|
|
357
|
+
...plan,
|
|
358
|
+
positionFor: (nodeId: Id) => {
|
|
359
|
+
const rect = plan.nodeRects.get(nodeId);
|
|
360
|
+
return {
|
|
361
|
+
x: rect?.x ?? 0,
|
|
362
|
+
y: rect?.y ?? 0
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function usePlannedDiagram(input: any) {
|
|
369
|
+
const key = planInputKey(input);
|
|
370
|
+
const [state, setState] = useState<{
|
|
371
|
+
key: string;
|
|
372
|
+
plan: any | null;
|
|
373
|
+
planning: boolean;
|
|
374
|
+
error: string | null;
|
|
375
|
+
}>({
|
|
376
|
+
key: "",
|
|
377
|
+
plan: null,
|
|
378
|
+
planning: false,
|
|
379
|
+
error: null
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
let cancelled = false;
|
|
384
|
+
let worker: Worker | null = null;
|
|
385
|
+
|
|
386
|
+
setState((previous) => ({
|
|
387
|
+
key,
|
|
388
|
+
plan: previous.key === key ? previous.plan : null,
|
|
389
|
+
planning: false,
|
|
390
|
+
error: null
|
|
391
|
+
}));
|
|
392
|
+
|
|
393
|
+
const slowTimer = window.setTimeout(() => {
|
|
394
|
+
if (cancelled) return;
|
|
395
|
+
setState((previous) => previous.key === key ? { ...previous, planning: true } : previous);
|
|
396
|
+
}, ROUTING_LOADING_DELAY_MS);
|
|
397
|
+
|
|
398
|
+
const finishWithPlan = (plan: any) => {
|
|
399
|
+
if (cancelled) return;
|
|
400
|
+
window.clearTimeout(slowTimer);
|
|
401
|
+
setState({
|
|
402
|
+
key,
|
|
403
|
+
plan: attachPlanHelpers(plan),
|
|
404
|
+
planning: false,
|
|
405
|
+
error: null
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const finishWithError = (message: string) => {
|
|
410
|
+
if (cancelled) return;
|
|
411
|
+
window.clearTimeout(slowTimer);
|
|
412
|
+
setState({
|
|
413
|
+
key,
|
|
414
|
+
plan: null,
|
|
415
|
+
planning: false,
|
|
416
|
+
error: message
|
|
417
|
+
});
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
if (typeof Worker === "undefined") {
|
|
421
|
+
const timer = window.setTimeout(() => {
|
|
422
|
+
try {
|
|
423
|
+
const plan = planDiagram(input);
|
|
424
|
+
const { positionFor, ...cloneablePlan } = plan;
|
|
425
|
+
finishWithPlan(cloneablePlan);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
finishWithError(error instanceof Error ? error.message : String(error));
|
|
428
|
+
}
|
|
429
|
+
}, 0);
|
|
430
|
+
return () => {
|
|
431
|
+
cancelled = true;
|
|
432
|
+
window.clearTimeout(timer);
|
|
433
|
+
window.clearTimeout(slowTimer);
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
worker = new Worker(new URL("./routing/planningWorker.js", import.meta.url), { type: "module" });
|
|
438
|
+
worker.onmessage = (event) => {
|
|
439
|
+
if (event.data.key !== key) return;
|
|
440
|
+
if (event.data.error) {
|
|
441
|
+
finishWithError(event.data.error);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
finishWithPlan(event.data.plan);
|
|
445
|
+
};
|
|
446
|
+
worker.onerror = (event) => {
|
|
447
|
+
finishWithError(event.message || "Route planning failed.");
|
|
448
|
+
};
|
|
449
|
+
worker.postMessage({ key, input });
|
|
450
|
+
|
|
451
|
+
return () => {
|
|
452
|
+
cancelled = true;
|
|
453
|
+
window.clearTimeout(slowTimer);
|
|
454
|
+
worker?.terminate();
|
|
455
|
+
};
|
|
456
|
+
}, [key]);
|
|
457
|
+
|
|
458
|
+
return state;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function plannedCanvasFallback(input: any) {
|
|
462
|
+
const maxRows = Math.max(...input.view.lanes.map((lane: any) => lane.nodeIds.filter((nodeId: Id) => input.visibleNodeIds.has(nodeId)).length), 1);
|
|
463
|
+
return {
|
|
464
|
+
width: Math.max(input.minCanvasWidth, input.marginX * 2 + input.view.lanes.length * input.laneWidth + input.canvasExtraWidth),
|
|
465
|
+
height: Math.max(input.minCanvasHeight, input.marginY + maxRows * input.rowGap + input.canvasExtraHeight)
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function RoutingLoadingOverlay({ active }: { active: boolean }) {
|
|
470
|
+
if (!active) return null;
|
|
471
|
+
return (
|
|
472
|
+
<div className="routing-loading-overlay" role="status" aria-live="polite">
|
|
473
|
+
<span className="routing-spinner" aria-hidden="true" />
|
|
474
|
+
<span>Planning routes</span>
|
|
475
|
+
</div>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function RoutingPlanningError({ message }: { message: string }) {
|
|
480
|
+
return (
|
|
481
|
+
<div className="routing-planning-error" role="alert">
|
|
482
|
+
<strong>Route planning failed</strong>
|
|
483
|
+
<span>{message}</span>
|
|
484
|
+
</div>
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
312
488
|
function sectionId(title: string): string {
|
|
313
489
|
const normalized = title.toLowerCase();
|
|
314
490
|
if (normalized.includes("runtime")) return "runtime";
|
|
@@ -351,6 +527,8 @@ function App() {
|
|
|
351
527
|
const [activeFlowId, setActiveFlowId] = useState<Id>("");
|
|
352
528
|
const [selection, setSelection] = useState<Selection | null>(null);
|
|
353
529
|
const [diagramTransform, setDiagramTransform] = useState<DiagramTransform>({ zoom: 1, focused: false });
|
|
530
|
+
const [routingStyle, setRoutingStyle] = useState<RoutingStyle>(initialRoutingStyle);
|
|
531
|
+
const [debugRouting] = useState(initialDebugRouting);
|
|
354
532
|
const [riskFilter, setRiskFilter] = useState("all");
|
|
355
533
|
const [stepsCollapsed, setStepsCollapsed] = useState(false);
|
|
356
534
|
const [diagramViewportRef, diagramViewportSize] = useElementSize<HTMLElement>();
|
|
@@ -377,6 +555,10 @@ function App() {
|
|
|
377
555
|
localStorage.setItem("architext-right-collapsed", String(rightCollapsed));
|
|
378
556
|
}, [rightCollapsed]);
|
|
379
557
|
|
|
558
|
+
useEffect(() => {
|
|
559
|
+
localStorage.setItem("architext-routing-style", routingStyle);
|
|
560
|
+
}, [routingStyle]);
|
|
561
|
+
|
|
380
562
|
useEffect(() => {
|
|
381
563
|
const narrowWidth = window.matchMedia("(max-width: 760px)");
|
|
382
564
|
const laptopWidth = window.matchMedia("(max-width: 1180px)");
|
|
@@ -590,6 +772,8 @@ function App() {
|
|
|
590
772
|
</div>
|
|
591
773
|
<DiagramControls
|
|
592
774
|
transform={diagramTransform}
|
|
775
|
+
routingStyle={routingStyle}
|
|
776
|
+
onRoutingStyleChange={setRoutingStyle}
|
|
593
777
|
onZoomIn={() => setDiagramTransform((value) => ({ ...value, zoom: Math.min(1.6, Number((value.zoom + 0.1).toFixed(2))) }))}
|
|
594
778
|
onZoomOut={() => setDiagramTransform((value) => ({ ...value, zoom: Math.max(0.7, Number((value.zoom - 0.1).toFixed(2))) }))}
|
|
595
779
|
onFit={() => setDiagramTransform((value) => ({ ...value, zoom: fitZoomFor(activeMode, activeView, activeFlow) }))}
|
|
@@ -627,6 +811,8 @@ function App() {
|
|
|
627
811
|
selectedNodeId={selectedNodeId}
|
|
628
812
|
selectedRelationship={selection?.kind === "relationship" ? selection : null}
|
|
629
813
|
transform={diagramTransform}
|
|
814
|
+
routingStyle={routingStyle}
|
|
815
|
+
debugRouting={debugRouting}
|
|
630
816
|
onSelectNode={(id) => setSelection({ kind: "node", id })}
|
|
631
817
|
onSelectRelationship={selectRelationship}
|
|
632
818
|
/>
|
|
@@ -640,6 +826,8 @@ function App() {
|
|
|
640
826
|
selectedRelationship={selection?.kind === "relationship" ? selection : null}
|
|
641
827
|
selectedNodeId={selectedNodeId}
|
|
642
828
|
transform={diagramTransform}
|
|
829
|
+
routingStyle={routingStyle}
|
|
830
|
+
debugRouting={debugRouting}
|
|
643
831
|
onSelectNode={(id) => setSelection({ kind: "node", id })}
|
|
644
832
|
onSelectRelationship={selectRelationship}
|
|
645
833
|
/>
|
|
@@ -891,6 +1079,8 @@ function LeftPanel({
|
|
|
891
1079
|
|
|
892
1080
|
function DiagramControls({
|
|
893
1081
|
transform,
|
|
1082
|
+
routingStyle,
|
|
1083
|
+
onRoutingStyleChange,
|
|
894
1084
|
onZoomIn,
|
|
895
1085
|
onZoomOut,
|
|
896
1086
|
onFit,
|
|
@@ -898,6 +1088,8 @@ function DiagramControls({
|
|
|
898
1088
|
onToggleFocus
|
|
899
1089
|
}: {
|
|
900
1090
|
transform: DiagramTransform;
|
|
1091
|
+
routingStyle: RoutingStyle;
|
|
1092
|
+
onRoutingStyleChange: (style: RoutingStyle) => void;
|
|
901
1093
|
onZoomIn: () => void;
|
|
902
1094
|
onZoomOut: () => void;
|
|
903
1095
|
onFit: () => void;
|
|
@@ -906,6 +1098,19 @@ function DiagramControls({
|
|
|
906
1098
|
}) {
|
|
907
1099
|
return (
|
|
908
1100
|
<div className="diagram-controls" aria-label="Diagram controls">
|
|
1101
|
+
<div className="routing-style-control" role="group" aria-label="Route style">
|
|
1102
|
+
{(["orthogonal", "curved"] as RoutingStyle[]).map((style) => (
|
|
1103
|
+
<button
|
|
1104
|
+
key={style}
|
|
1105
|
+
type="button"
|
|
1106
|
+
className={routingStyle === style ? "active" : ""}
|
|
1107
|
+
aria-pressed={routingStyle === style}
|
|
1108
|
+
onClick={() => onRoutingStyleChange(style)}
|
|
1109
|
+
>
|
|
1110
|
+
{style === "orthogonal" ? "Orthogonal" : "Curved"}
|
|
1111
|
+
</button>
|
|
1112
|
+
))}
|
|
1113
|
+
</div>
|
|
909
1114
|
<button type="button" onClick={onZoomOut} aria-label="Zoom out">-</button>
|
|
910
1115
|
<span>{Math.round(transform.zoom * 100)}%</span>
|
|
911
1116
|
<button type="button" onClick={onZoomIn} aria-label="Zoom in">+</button>
|
|
@@ -925,6 +1130,8 @@ function SystemMap({
|
|
|
925
1130
|
selectedRelationship,
|
|
926
1131
|
selectedNodeId,
|
|
927
1132
|
transform,
|
|
1133
|
+
routingStyle,
|
|
1134
|
+
debugRouting,
|
|
928
1135
|
onSelectRelationship,
|
|
929
1136
|
onSelectNode
|
|
930
1137
|
}: {
|
|
@@ -936,6 +1143,8 @@ function SystemMap({
|
|
|
936
1143
|
selectedRelationship: Extract<Selection, { kind: "relationship" }> | null;
|
|
937
1144
|
selectedNodeId: Id | null;
|
|
938
1145
|
transform: DiagramTransform;
|
|
1146
|
+
routingStyle: RoutingStyle;
|
|
1147
|
+
debugRouting: boolean;
|
|
939
1148
|
onSelectRelationship: (relationship: Relationship) => void;
|
|
940
1149
|
onSelectNode: (id: Id) => void;
|
|
941
1150
|
}) {
|
|
@@ -944,494 +1153,10 @@ function SystemMap({
|
|
|
944
1153
|
const nodeWidth = 136;
|
|
945
1154
|
const nodeHeight = 54;
|
|
946
1155
|
const laneWidth = 210;
|
|
947
|
-
const rowGap =
|
|
948
|
-
const routeGutter =
|
|
1156
|
+
const rowGap = 102;
|
|
1157
|
+
const routeGutter = 132;
|
|
949
1158
|
const marginX = routeGutter + 48;
|
|
950
1159
|
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
1160
|
|
|
1436
1161
|
const structuralRelationships = Array.from(visibleNodeIds).flatMap((nodeId) => {
|
|
1437
1162
|
const node = nodesById.get(nodeId);
|
|
@@ -1462,37 +1187,77 @@ function SystemMap({
|
|
|
1462
1187
|
summary: step.summary,
|
|
1463
1188
|
relationshipType: "flow" as const,
|
|
1464
1189
|
stepId: step.id,
|
|
1465
|
-
flowId: activeFlow.id
|
|
1190
|
+
flowId: activeFlow.id,
|
|
1191
|
+
displayIndex: index + 1
|
|
1466
1192
|
};
|
|
1467
1193
|
}) ?? [];
|
|
1468
1194
|
|
|
1469
|
-
const
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1195
|
+
const planInput = {
|
|
1196
|
+
view,
|
|
1197
|
+
relationships: showStructuralConnections ? structuralRelationships : flowRelationships,
|
|
1198
|
+
visibleNodeIds,
|
|
1199
|
+
nodeWidth,
|
|
1200
|
+
nodeHeight,
|
|
1201
|
+
laneWidth,
|
|
1202
|
+
rowGap,
|
|
1203
|
+
marginX,
|
|
1204
|
+
marginY,
|
|
1205
|
+
minCanvasWidth: 0,
|
|
1206
|
+
minCanvasHeight: 340,
|
|
1207
|
+
canvasExtraWidth: routeGutter,
|
|
1208
|
+
canvasExtraHeight: 88,
|
|
1209
|
+
style: routingStyle
|
|
1210
|
+
};
|
|
1211
|
+
const planningState = usePlannedDiagram(planInput);
|
|
1212
|
+
const fallbackCanvas = plannedCanvasFallback(planInput);
|
|
1213
|
+
const plan = planningState.plan;
|
|
1482
1214
|
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1215
|
+
if (planningState.error) {
|
|
1216
|
+
return (
|
|
1217
|
+
<section className="map-shell">
|
|
1218
|
+
<div
|
|
1219
|
+
className="diagram-canvas"
|
|
1220
|
+
style={{ width: fallbackCanvas.width, height: fallbackCanvas.height, transform: `scale(${transform.zoom})`, transformOrigin: "0 0" }}
|
|
1221
|
+
>
|
|
1222
|
+
<RoutingPlanningError message={planningState.error} />
|
|
1223
|
+
</div>
|
|
1224
|
+
</section>
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1487
1227
|
|
|
1488
|
-
|
|
1489
|
-
|
|
1228
|
+
if (!plan) {
|
|
1229
|
+
return (
|
|
1230
|
+
<section className="map-shell" aria-busy={planningState.planning ? "true" : "false"}>
|
|
1231
|
+
<div
|
|
1232
|
+
className="diagram-canvas"
|
|
1233
|
+
style={{ width: fallbackCanvas.width, height: fallbackCanvas.height, transform: `scale(${transform.zoom})`, transformOrigin: "0 0" }}
|
|
1234
|
+
>
|
|
1235
|
+
<RoutingLoadingOverlay active={planningState.planning} />
|
|
1236
|
+
</div>
|
|
1237
|
+
</section>
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1490
1240
|
|
|
1491
|
-
const
|
|
1492
|
-
const
|
|
1241
|
+
const canvasWidth = plan.canvasWidth;
|
|
1242
|
+
const canvasHeight = plan.canvasHeight;
|
|
1243
|
+
const structuralRoutes = showStructuralConnections ? plan.routes : new Map();
|
|
1244
|
+
const flowRoutes = showStructuralConnections ? new Map() : plan.routes;
|
|
1245
|
+
const nodePosition = plan.positionFor;
|
|
1246
|
+
const isStructuralSelected = (relationship: Relationship) => (
|
|
1247
|
+
selectedRelationship?.from === relationship.from && selectedRelationship.to === relationship.to
|
|
1248
|
+
);
|
|
1249
|
+
const isFlowSelected = (relationship: Relationship) => (
|
|
1250
|
+
selectedStepId === relationship.stepId || (
|
|
1251
|
+
selectedRelationship?.from === relationship.from &&
|
|
1252
|
+
selectedRelationship.to === relationship.to &&
|
|
1253
|
+
selectedRelationship.stepId === relationship.stepId
|
|
1254
|
+
)
|
|
1255
|
+
);
|
|
1256
|
+
const orderedStructuralRelationships = [...structuralRelationships].sort((a, b) => Number(isStructuralSelected(a)) - Number(isStructuralSelected(b)));
|
|
1257
|
+
const orderedFlowRelationships = [...flowRelationships].sort((a, b) => Number(isFlowSelected(a)) - Number(isFlowSelected(b)));
|
|
1493
1258
|
|
|
1494
1259
|
return (
|
|
1495
|
-
<section className="map-shell">
|
|
1260
|
+
<section className="map-shell" aria-busy={planningState.planning ? "true" : "false"}>
|
|
1496
1261
|
<div
|
|
1497
1262
|
className="diagram-canvas"
|
|
1498
1263
|
style={{ width: canvasWidth, height: canvasHeight, transform: `scale(${transform.zoom})`, transformOrigin: "0 0" }}
|
|
@@ -1506,10 +1271,10 @@ function SystemMap({
|
|
|
1506
1271
|
<path d="M 0 0 L 8 4 L 0 8 z" />
|
|
1507
1272
|
</marker>
|
|
1508
1273
|
</defs>
|
|
1509
|
-
{showStructuralConnections &&
|
|
1274
|
+
{showStructuralConnections && orderedStructuralRelationships.map((connection, index) => {
|
|
1510
1275
|
const route = structuralRoutes.get(connection.id);
|
|
1511
1276
|
if (!route) return null;
|
|
1512
|
-
const selected =
|
|
1277
|
+
const selected = isStructuralSelected(connection);
|
|
1513
1278
|
return (
|
|
1514
1279
|
<g
|
|
1515
1280
|
key={`${connection.from}-${connection.to}`}
|
|
@@ -1531,17 +1296,13 @@ function SystemMap({
|
|
|
1531
1296
|
</g>
|
|
1532
1297
|
);
|
|
1533
1298
|
})}
|
|
1534
|
-
{!showStructuralConnections &&
|
|
1535
|
-
if (!laneIndexByNode.has(relationship.from) || !laneIndexByNode.has(relationship.to)) {
|
|
1299
|
+
{!showStructuralConnections && orderedFlowRelationships.map((relationship, index) => {
|
|
1300
|
+
if (!plan.laneIndexByNode.has(relationship.from) || !plan.laneIndexByNode.has(relationship.to)) {
|
|
1536
1301
|
return null;
|
|
1537
1302
|
}
|
|
1538
1303
|
const route = flowRoutes.get(relationship.id);
|
|
1539
1304
|
if (!route) return null;
|
|
1540
|
-
const isSelected =
|
|
1541
|
-
selectedRelationship?.from === relationship.from &&
|
|
1542
|
-
selectedRelationship.to === relationship.to &&
|
|
1543
|
-
selectedRelationship.stepId === relationship.stepId
|
|
1544
|
-
);
|
|
1305
|
+
const isSelected = isFlowSelected(relationship);
|
|
1545
1306
|
return (
|
|
1546
1307
|
<g
|
|
1547
1308
|
key={relationship.id}
|
|
@@ -1560,10 +1321,11 @@ function SystemMap({
|
|
|
1560
1321
|
markerEnd={isSelected ? "url(#arrowhead-selected)" : "url(#arrowhead)"}
|
|
1561
1322
|
/>
|
|
1562
1323
|
<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}>{
|
|
1324
|
+
<text className="flow-step-label" x={route.labelX} y={route.labelY + 4}>{relationship.displayIndex}</text>
|
|
1564
1325
|
</g>
|
|
1565
1326
|
);
|
|
1566
1327
|
})}
|
|
1328
|
+
{debugRouting ? <RoutingDebugGeometry plan={plan} relationships={showStructuralConnections ? structuralRelationships : flowRelationships} /> : null}
|
|
1567
1329
|
</svg>
|
|
1568
1330
|
{view.lanes.map((lane, laneIndex) => (
|
|
1569
1331
|
<div
|
|
@@ -1593,6 +1355,7 @@ function SystemMap({
|
|
|
1593
1355
|
</button>
|
|
1594
1356
|
);
|
|
1595
1357
|
})}
|
|
1358
|
+
<RoutingLoadingOverlay active={planningState.planning} />
|
|
1596
1359
|
</div>
|
|
1597
1360
|
{activeFlow ? (
|
|
1598
1361
|
<div className="edge-strip">
|
|
@@ -1614,16 +1377,119 @@ function SystemMap({
|
|
|
1614
1377
|
<span className="edge-count">Structural connections only</span>
|
|
1615
1378
|
</div>
|
|
1616
1379
|
)}
|
|
1380
|
+
{debugRouting ? (
|
|
1381
|
+
<RoutingDebugPanel
|
|
1382
|
+
plan={plan}
|
|
1383
|
+
relationships={showStructuralConnections ? structuralRelationships : flowRelationships}
|
|
1384
|
+
/>
|
|
1385
|
+
) : null}
|
|
1617
1386
|
</section>
|
|
1618
1387
|
);
|
|
1619
1388
|
}
|
|
1620
1389
|
|
|
1390
|
+
function topQualityCosts(route: any) {
|
|
1391
|
+
return Object.entries(route.qualityCosts ?? {})
|
|
1392
|
+
.filter(([, value]) => typeof value === "number" && value !== 0)
|
|
1393
|
+
.sort(([, left], [, right]) => Math.abs(right as number) - Math.abs(left as number))
|
|
1394
|
+
.slice(0, 5);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function RoutingDebugPanel({ plan, relationships }: { plan: any; relationships: Relationship[] }) {
|
|
1398
|
+
const warnings = plan.warnings ?? [];
|
|
1399
|
+
return (
|
|
1400
|
+
<div className="routing-debug-panel" aria-label="Routing debug data">
|
|
1401
|
+
<div className="routing-debug-summary">
|
|
1402
|
+
<strong>Routing debug</strong>
|
|
1403
|
+
<span>{plan.routes.size} routes</span>
|
|
1404
|
+
<span>{warnings.length} warnings</span>
|
|
1405
|
+
</div>
|
|
1406
|
+
{warnings.length > 0 ? (
|
|
1407
|
+
<div className="routing-debug-warnings">
|
|
1408
|
+
{warnings.slice(0, 8).map((warning: any, index: number) => (
|
|
1409
|
+
<span key={`${warning.relationshipId}-${warning.code}-${index}`}>
|
|
1410
|
+
{warning.relationshipId}: {warning.code}
|
|
1411
|
+
</span>
|
|
1412
|
+
))}
|
|
1413
|
+
</div>
|
|
1414
|
+
) : null}
|
|
1415
|
+
<div className="routing-debug-routes">
|
|
1416
|
+
{relationships.map((relationship) => {
|
|
1417
|
+
const route = plan.routes.get(relationship.id);
|
|
1418
|
+
if (!route) return null;
|
|
1419
|
+
return (
|
|
1420
|
+
<details key={relationship.id}>
|
|
1421
|
+
<summary>
|
|
1422
|
+
<span>{relationship.id}</span>
|
|
1423
|
+
<span>{Math.round(route.cost)}</span>
|
|
1424
|
+
</summary>
|
|
1425
|
+
<dl>
|
|
1426
|
+
{topQualityCosts(route).map(([name, value]) => (
|
|
1427
|
+
<React.Fragment key={name}>
|
|
1428
|
+
<dt>{name}</dt>
|
|
1429
|
+
<dd>{Math.round(value as number)}</dd>
|
|
1430
|
+
</React.Fragment>
|
|
1431
|
+
))}
|
|
1432
|
+
</dl>
|
|
1433
|
+
</details>
|
|
1434
|
+
);
|
|
1435
|
+
})}
|
|
1436
|
+
</div>
|
|
1437
|
+
</div>
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function RoutingDebugGeometry({ plan, relationships }: { plan: any; relationships: Relationship[] }) {
|
|
1442
|
+
return (
|
|
1443
|
+
<g className="routing-debug-geometry" aria-hidden="true">
|
|
1444
|
+
{[...plan.nodeRects.entries()].map(([nodeId, rect]: [string, any]) => (
|
|
1445
|
+
<rect
|
|
1446
|
+
key={`node-${nodeId}`}
|
|
1447
|
+
className="routing-debug-node"
|
|
1448
|
+
x={rect.x}
|
|
1449
|
+
y={rect.y}
|
|
1450
|
+
width={rect.width}
|
|
1451
|
+
height={rect.height}
|
|
1452
|
+
/>
|
|
1453
|
+
))}
|
|
1454
|
+
{relationships.map((relationship) => {
|
|
1455
|
+
const route = plan.routes.get(relationship.id);
|
|
1456
|
+
const labelBox = plan.labelBoxes.get(relationship.id);
|
|
1457
|
+
if (!route) return null;
|
|
1458
|
+
return (
|
|
1459
|
+
<g key={`debug-${relationship.id}`} className={route.warnings?.length ? "routing-debug-route warned" : "routing-debug-route"}>
|
|
1460
|
+
{labelBox ? (
|
|
1461
|
+
<rect
|
|
1462
|
+
className="routing-debug-label"
|
|
1463
|
+
x={labelBox.x}
|
|
1464
|
+
y={labelBox.y}
|
|
1465
|
+
width={labelBox.width}
|
|
1466
|
+
height={labelBox.height}
|
|
1467
|
+
/>
|
|
1468
|
+
) : null}
|
|
1469
|
+
{route.points?.map((point: any, index: number) => (
|
|
1470
|
+
<circle
|
|
1471
|
+
key={`point-${relationship.id}-${index}`}
|
|
1472
|
+
className="routing-debug-point"
|
|
1473
|
+
cx={point.x}
|
|
1474
|
+
cy={point.y}
|
|
1475
|
+
r={index === 0 || index === route.points.length - 1 ? 4 : 2.5}
|
|
1476
|
+
/>
|
|
1477
|
+
))}
|
|
1478
|
+
</g>
|
|
1479
|
+
);
|
|
1480
|
+
})}
|
|
1481
|
+
</g>
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1621
1485
|
function C4Diagram({
|
|
1622
1486
|
view,
|
|
1623
1487
|
nodesById,
|
|
1624
1488
|
selectedNodeId,
|
|
1625
1489
|
selectedRelationship,
|
|
1626
1490
|
transform,
|
|
1491
|
+
routingStyle,
|
|
1492
|
+
debugRouting,
|
|
1627
1493
|
onSelectNode,
|
|
1628
1494
|
onSelectRelationship
|
|
1629
1495
|
}: {
|
|
@@ -1632,31 +1498,19 @@ function C4Diagram({
|
|
|
1632
1498
|
selectedNodeId: Id | null;
|
|
1633
1499
|
selectedRelationship: Extract<Selection, { kind: "relationship" }> | null;
|
|
1634
1500
|
transform: DiagramTransform;
|
|
1501
|
+
routingStyle: RoutingStyle;
|
|
1502
|
+
debugRouting: boolean;
|
|
1635
1503
|
onSelectNode: (id: Id) => void;
|
|
1636
1504
|
onSelectRelationship: (relationship: Relationship) => void;
|
|
1637
1505
|
}) {
|
|
1638
1506
|
const nodeWidth = 156;
|
|
1639
|
-
const nodeHeight =
|
|
1507
|
+
const nodeHeight = 92;
|
|
1640
1508
|
const laneWidth = 210;
|
|
1641
1509
|
const marginX = 56;
|
|
1642
1510
|
const marginY = 76;
|
|
1643
|
-
const rowGap =
|
|
1511
|
+
const rowGap = 116;
|
|
1644
1512
|
const allNodeIds = view.lanes.flatMap((lane) => lane.nodeIds);
|
|
1645
1513
|
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
1514
|
const relationships = allNodeIds.flatMap((nodeId) => {
|
|
1661
1515
|
const node = nodesById.get(nodeId);
|
|
1662
1516
|
return (node?.dependencies ?? [])
|
|
@@ -1674,26 +1528,62 @@ function C4Diagram({
|
|
|
1674
1528
|
};
|
|
1675
1529
|
});
|
|
1676
1530
|
});
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
};
|
|
1531
|
+
const planInput = {
|
|
1532
|
+
view,
|
|
1533
|
+
relationships,
|
|
1534
|
+
visibleNodeIds,
|
|
1535
|
+
nodeWidth,
|
|
1536
|
+
nodeHeight,
|
|
1537
|
+
laneWidth,
|
|
1538
|
+
rowGap,
|
|
1539
|
+
marginX,
|
|
1540
|
+
marginY,
|
|
1541
|
+
minCanvasWidth: 760,
|
|
1542
|
+
minCanvasHeight: 440,
|
|
1543
|
+
canvasExtraWidth: 40,
|
|
1544
|
+
canvasExtraHeight: 88,
|
|
1545
|
+
style: routingStyle
|
|
1693
1546
|
};
|
|
1547
|
+
const planningState = usePlannedDiagram(planInput);
|
|
1548
|
+
const fallbackCanvas = plannedCanvasFallback(planInput);
|
|
1549
|
+
const plan = planningState.plan;
|
|
1550
|
+
|
|
1551
|
+
if (planningState.error) {
|
|
1552
|
+
return (
|
|
1553
|
+
<section className="map-shell c4-shell">
|
|
1554
|
+
<div
|
|
1555
|
+
className={`c4-canvas ${view.type}`}
|
|
1556
|
+
style={{ width: fallbackCanvas.width, height: fallbackCanvas.height, transform: `scale(${transform.zoom})`, transformOrigin: "0 0" }}
|
|
1557
|
+
>
|
|
1558
|
+
<RoutingPlanningError message={planningState.error} />
|
|
1559
|
+
</div>
|
|
1560
|
+
</section>
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
if (!plan) {
|
|
1565
|
+
return (
|
|
1566
|
+
<section className="map-shell c4-shell" aria-busy={planningState.planning ? "true" : "false"}>
|
|
1567
|
+
<div
|
|
1568
|
+
className={`c4-canvas ${view.type}`}
|
|
1569
|
+
style={{ width: fallbackCanvas.width, height: fallbackCanvas.height, transform: `scale(${transform.zoom})`, transformOrigin: "0 0" }}
|
|
1570
|
+
>
|
|
1571
|
+
<RoutingLoadingOverlay active={planningState.planning} />
|
|
1572
|
+
</div>
|
|
1573
|
+
</section>
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
const canvasWidth = plan.canvasWidth;
|
|
1578
|
+
const canvasHeight = plan.canvasHeight;
|
|
1579
|
+
const positionFor = plan.positionFor;
|
|
1580
|
+
const isC4RelationshipSelected = (relationship: Relationship) => (
|
|
1581
|
+
selectedRelationship?.from === relationship.from && selectedRelationship.to === relationship.to
|
|
1582
|
+
);
|
|
1583
|
+
const orderedRelationships = [...relationships].sort((a, b) => Number(isC4RelationshipSelected(a)) - Number(isC4RelationshipSelected(b)));
|
|
1694
1584
|
|
|
1695
1585
|
return (
|
|
1696
|
-
<section className="map-shell c4-shell">
|
|
1586
|
+
<section className="map-shell c4-shell" aria-busy={planningState.planning ? "true" : "false"}>
|
|
1697
1587
|
<div
|
|
1698
1588
|
className={`c4-canvas ${view.type}`}
|
|
1699
1589
|
style={{ width: canvasWidth, height: canvasHeight, transform: `scale(${transform.zoom})`, transformOrigin: "0 0" }}
|
|
@@ -1707,9 +1597,10 @@ function C4Diagram({
|
|
|
1707
1597
|
<path d="M 0 0 L 8 4 L 0 8 z" />
|
|
1708
1598
|
</marker>
|
|
1709
1599
|
</defs>
|
|
1710
|
-
{
|
|
1711
|
-
const route =
|
|
1712
|
-
|
|
1600
|
+
{orderedRelationships.map((relationship) => {
|
|
1601
|
+
const route = plan.routes.get(relationship.id);
|
|
1602
|
+
if (!route) return null;
|
|
1603
|
+
const selected = isC4RelationshipSelected(relationship);
|
|
1713
1604
|
return (
|
|
1714
1605
|
<g
|
|
1715
1606
|
key={relationship.id}
|
|
@@ -1731,6 +1622,7 @@ function C4Diagram({
|
|
|
1731
1622
|
</g>
|
|
1732
1623
|
);
|
|
1733
1624
|
})}
|
|
1625
|
+
{debugRouting ? <RoutingDebugGeometry plan={plan} relationships={relationships} /> : null}
|
|
1734
1626
|
</svg>
|
|
1735
1627
|
<div className="c4-boundary">
|
|
1736
1628
|
<span>{view.type === "c4-context" ? "System boundary" : view.type === "c4-container" ? "Container boundary" : "Component scope"}</span>
|
|
@@ -1753,7 +1645,7 @@ function C4Diagram({
|
|
|
1753
1645
|
key={node.id}
|
|
1754
1646
|
type="button"
|
|
1755
1647
|
className={`c4-node ${node.type} ${selectedNodeId === node.id ? "selected" : ""}`}
|
|
1756
|
-
style={{ left: position.x, top: position.y, width: nodeWidth,
|
|
1648
|
+
style={{ left: position.x, top: position.y, width: nodeWidth, height: nodeHeight }}
|
|
1757
1649
|
onClick={() => onSelectNode(node.id)}
|
|
1758
1650
|
aria-label={`${node.name}, ${node.type}. ${node.summary}`}
|
|
1759
1651
|
>
|
|
@@ -1763,10 +1655,12 @@ function C4Diagram({
|
|
|
1763
1655
|
</button>
|
|
1764
1656
|
);
|
|
1765
1657
|
})}
|
|
1658
|
+
<RoutingLoadingOverlay active={planningState.planning} />
|
|
1766
1659
|
</div>
|
|
1767
1660
|
<div className="edge-strip">
|
|
1768
1661
|
<span className="edge-count">{relationships.length} labeled structural relationships</span>
|
|
1769
1662
|
</div>
|
|
1663
|
+
{debugRouting ? <RoutingDebugPanel plan={plan} relationships={relationships} /> : null}
|
|
1770
1664
|
</section>
|
|
1771
1665
|
);
|
|
1772
1666
|
}
|