@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.
@@ -0,0 +1,208 @@
1
+ import { routeEdges } from "./routeEdges.js";
2
+
3
+ function estimatedLabelBox(route, relationship) {
4
+ return labelBoxAt(route.labelX, route.labelY, relationship);
5
+ }
6
+
7
+ function labelBoxAt(x, y, relationship) {
8
+ const text = relationship.label ?? relationship.id ?? "";
9
+ const width = Math.max(24, Math.min(180, text.length * 6 + 12));
10
+ const height = relationship.relationshipType === "flow" || relationship.stepId ? 24 : 18;
11
+ return {
12
+ x: x - width / 2,
13
+ y: y - height / 2,
14
+ width,
15
+ height
16
+ };
17
+ }
18
+
19
+ function rectsOverlap(a, b, padding = 0) {
20
+ return (
21
+ a.x < b.x + b.width + padding &&
22
+ a.x + a.width > b.x - padding &&
23
+ a.y < b.y + b.height + padding &&
24
+ a.y + a.height > b.y - padding
25
+ );
26
+ }
27
+
28
+ function labelPlacementCandidates(route) {
29
+ const offsets = [];
30
+ for (const y of [0, -24, 24, -48, 48, -72, 72, -96, 96, -120, 120, -144, 144, -168, 168, -192, 192]) {
31
+ for (const x of [0, 36, -36, 64, -64, 96, -96, 128, -128]) {
32
+ offsets.push([x, y]);
33
+ }
34
+ }
35
+ return offsets
36
+ .sort((a, b) => Math.hypot(a[0], a[1]) - Math.hypot(b[0], b[1]))
37
+ .map(([x, y]) => ({ x: route.labelX + x, y: route.labelY + y }));
38
+ }
39
+
40
+ function placeLabel(route, relationship, nodeRects, placedLabels, canvasWidth, canvasHeight) {
41
+ const candidates = labelPlacementCandidates(route);
42
+ const scored = candidates.map((candidate, index) => {
43
+ const box = labelBoxAt(candidate.x, candidate.y, relationship);
44
+ const qualityCosts = {
45
+ labelMovementCost: Math.hypot(candidate.x - route.labelX, candidate.y - route.labelY),
46
+ labelSearchOrderCost: index * 4,
47
+ labelBoundaryCost: 0,
48
+ labelNodeConflictCost: 0,
49
+ labelConflictCost: 0
50
+ };
51
+ if (box.x < 8 || box.y < 8 || box.x + box.width > canvasWidth - 8 || box.y + box.height > canvasHeight - 8) {
52
+ qualityCosts.labelBoundaryCost += 100000;
53
+ }
54
+ for (const [nodeId, rect] of nodeRects) {
55
+ if (nodeId === relationship.from || nodeId === relationship.to) continue;
56
+ if (rectsOverlap(box, rect, 4)) qualityCosts.labelNodeConflictCost += 80000;
57
+ }
58
+ for (const placed of placedLabels) {
59
+ if (rectsOverlap(box, placed, 2)) qualityCosts.labelConflictCost += 20000;
60
+ }
61
+ return {
62
+ candidate,
63
+ box,
64
+ cost: Object.values(qualityCosts).reduce((sum, value) => sum + value, 0),
65
+ qualityCosts
66
+ };
67
+ });
68
+ return scored.sort((a, b) => a.cost - b.cost)[0];
69
+ }
70
+
71
+ export function planDiagram(input) {
72
+ const nodeWidth = input.nodeWidth;
73
+ const nodeHeight = input.nodeHeight;
74
+ const laneWidth = input.laneWidth;
75
+ const rowGap = input.rowGap;
76
+ const marginX = input.marginX;
77
+ const marginY = input.marginY;
78
+ const visibleNodeIds = new Set(input.visibleNodeIds);
79
+ const laneIndexByNode = new Map();
80
+ const rowIndexByNode = new Map();
81
+
82
+ input.view.lanes.forEach((lane, laneIndex) => {
83
+ lane.nodeIds.forEach((nodeId, rowIndex) => {
84
+ if (!visibleNodeIds.has(nodeId)) return;
85
+ laneIndexByNode.set(nodeId, laneIndex);
86
+ rowIndexByNode.set(nodeId, rowIndex);
87
+ });
88
+ });
89
+
90
+ const maxRows = Math.max(...input.view.lanes.map((lane) => lane.nodeIds.filter((nodeId) => visibleNodeIds.has(nodeId)).length), 1);
91
+ const canvasWidth = Math.max(input.minCanvasWidth, marginX * 2 + input.view.lanes.length * laneWidth + input.canvasExtraWidth);
92
+ const canvasHeight = Math.max(input.minCanvasHeight, marginY + maxRows * rowGap + input.canvasExtraHeight);
93
+ const positionFor = (nodeId) => ({
94
+ x: marginX + (laneIndexByNode.get(nodeId) ?? 0) * laneWidth,
95
+ y: marginY + (rowIndexByNode.get(nodeId) ?? 0) * rowGap
96
+ });
97
+ const nodeRects = new Map(Array.from(visibleNodeIds).map((nodeId) => {
98
+ const position = positionFor(nodeId);
99
+ return [
100
+ nodeId,
101
+ {
102
+ x: position.x,
103
+ y: position.y,
104
+ width: nodeWidth,
105
+ height: nodeHeight
106
+ }
107
+ ];
108
+ }));
109
+ const routes = routeEdges({
110
+ relationships: input.relationships,
111
+ visibleNodeIds,
112
+ nodeRects,
113
+ laneIndexByNode,
114
+ rowIndexByNode,
115
+ canvasWidth,
116
+ canvasHeight,
117
+ marginY,
118
+ style: input.style,
119
+ stats: input.stats
120
+ });
121
+ const relationshipsById = new Map(input.relationships.map((relationship) => [relationship.id, relationship]));
122
+ const plannedRoutes = new Map();
123
+ const labelBoxes = new Map();
124
+ const placedLabels = [];
125
+
126
+ for (const [relationshipId, route] of routes) {
127
+ const relationship = relationshipsById.get(relationshipId);
128
+ if (relationship) {
129
+ const labelPlacement = placeLabel(route, relationship, nodeRects, placedLabels, canvasWidth, canvasHeight);
130
+ const labelQualityCosts = {
131
+ ...route.qualityCosts,
132
+ labelMovementCost: 0,
133
+ labelSearchOrderCost: 0,
134
+ labelBoundaryCost: 0,
135
+ labelNodeConflictCost: 0,
136
+ labelConflictCost: 0,
137
+ ...labelPlacement.qualityCosts
138
+ };
139
+ const plannedRoute = {
140
+ ...route,
141
+ labelX: labelPlacement.candidate.x,
142
+ labelY: labelPlacement.candidate.y,
143
+ qualityCosts: labelQualityCosts,
144
+ cost: Object.values(labelQualityCosts).reduce((sum, value) => sum + value, 0)
145
+ };
146
+ plannedRoutes.set(relationshipId, plannedRoute);
147
+ labelBoxes.set(relationshipId, labelPlacement.box);
148
+ placedLabels.push(labelPlacement.box);
149
+ } else {
150
+ plannedRoutes.set(relationshipId, route);
151
+ }
152
+ }
153
+
154
+ const warnings = [];
155
+ for (const [relationshipId, route] of plannedRoutes) {
156
+ for (const warning of route.warnings ?? []) {
157
+ warnings.push({ ...warning, relationshipId });
158
+ }
159
+ }
160
+ for (const [relationshipId, labelBox] of labelBoxes) {
161
+ const relationship = relationshipsById.get(relationshipId);
162
+ for (const [nodeId, rect] of nodeRects) {
163
+ if (nodeId === relationship?.from || nodeId === relationship?.to) continue;
164
+ if (rectsOverlap(labelBox, rect, 4)) {
165
+ warnings.push({
166
+ code: "label-over-node",
167
+ message: "Route label overlaps a non-endpoint node.",
168
+ relationshipId,
169
+ nodeId
170
+ });
171
+ }
172
+ }
173
+ }
174
+ const labelEntries = [...labelBoxes];
175
+ for (let index = 0; index < labelEntries.length; index += 1) {
176
+ const [relationshipId, labelBox] = labelEntries[index];
177
+ for (let otherIndex = index + 1; otherIndex < labelEntries.length; otherIndex += 1) {
178
+ const [otherRelationshipId, otherLabelBox] = labelEntries[otherIndex];
179
+ if (rectsOverlap(labelBox, otherLabelBox, 2)) {
180
+ warnings.push({
181
+ code: "label-over-label",
182
+ message: "Route label overlaps another route label.",
183
+ relationshipId,
184
+ otherRelationshipId
185
+ });
186
+ }
187
+ }
188
+ }
189
+
190
+ return {
191
+ canvasWidth,
192
+ canvasHeight,
193
+ nodeWidth,
194
+ nodeHeight,
195
+ laneWidth,
196
+ rowGap,
197
+ marginX,
198
+ marginY,
199
+ visibleNodeIds,
200
+ laneIndexByNode,
201
+ rowIndexByNode,
202
+ nodeRects,
203
+ routes: plannedRoutes,
204
+ labelBoxes,
205
+ warnings,
206
+ positionFor
207
+ };
208
+ }
@@ -0,0 +1,15 @@
1
+ import { planDiagram } from "./planDiagram.js";
2
+
3
+ self.onmessage = (event) => {
4
+ const { key, input } = event.data;
5
+ try {
6
+ const plan = planDiagram(input);
7
+ const { positionFor, ...cloneablePlan } = plan;
8
+ self.postMessage({ key, plan: cloneablePlan });
9
+ } catch (error) {
10
+ self.postMessage({
11
+ key,
12
+ error: error instanceof Error ? error.message : String(error)
13
+ });
14
+ }
15
+ };