@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.
@@ -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 = 84;
948
- const routeGutter = 96;
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 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);
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
- const route = edgePath(relationship.from, relationship.to, index, pairIndex, usedRoutes);
1484
- routes.set(relationship.id, route);
1485
- usedRoutes.push(route.samples);
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
- return routes;
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 structuralRoutes = planRoutes(structuralRelationships);
1492
- const flowRoutes = planRoutes(flowRelationships);
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 && structuralRelationships.map((connection, index) => {
1274
+ {showStructuralConnections && orderedStructuralRelationships.map((connection, index) => {
1510
1275
  const route = structuralRoutes.get(connection.id);
1511
1276
  if (!route) return null;
1512
- const selected = selectedRelationship?.from === connection.from && selectedRelationship.to === connection.to;
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 && flowRelationships.map((relationship, index) => {
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 = selectedStepId === relationship.stepId || (
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}>{index + 1}</text>
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 = 62;
1507
+ const nodeHeight = 92;
1640
1508
  const laneWidth = 210;
1641
1509
  const marginX = 56;
1642
1510
  const marginY = 76;
1643
- const rowGap = 86;
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
- 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
- };
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
- {relationships.map((relationship, index) => {
1711
- const route = pathFor(relationship, index);
1712
- const selected = selectedRelationship?.from === relationship.from && selectedRelationship.to === relationship.to;
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, minHeight: nodeHeight }}
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
  }