sqlseed-web 0.2.4__py3-none-any.whl
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.
- sqlseed_web/AGENTS.md +106 -0
- sqlseed_web/__init__.py +24 -0
- sqlseed_web/__main__.py +8 -0
- sqlseed_web/_application.py +205 -0
- sqlseed_web/ai_settings.py +290 -0
- sqlseed_web/api.py +1102 -0
- sqlseed_web/app.py +26 -0
- sqlseed_web/managed_worker.py +184 -0
- sqlseed_web/operation_errors.py +42 -0
- sqlseed_web/plugin_environment.py +231 -0
- sqlseed_web/plugin_management.py +322 -0
- sqlseed_web/plugin_process.py +131 -0
- sqlseed_web/runtime_lifecycle.py +130 -0
- sqlseed_web/runtime_session.py +97 -0
- sqlseed_web/settings_environment.py +368 -0
- sqlseed_web/sqlite_target.py +86 -0
- sqlseed_web/state.py +348 -0
- sqlseed_web/static/AGENTS.md +180 -0
- sqlseed_web/static/ai.css +57 -0
- sqlseed_web/static/configs.css +50 -0
- sqlseed_web/static/date-picker.css +230 -0
- sqlseed_web/static/disclosure.css +149 -0
- sqlseed_web/static/graph-clarity.css +103 -0
- sqlseed_web/static/index.html +29 -0
- sqlseed_web/static/js/api.js +228 -0
- sqlseed_web/static/js/app.js +97 -0
- sqlseed_web/static/js/dropdown.js +432 -0
- sqlseed_web/static/js/filepicker.js +203 -0
- sqlseed_web/static/js/genform.js +1066 -0
- sqlseed_web/static/js/labels.js +195 -0
- sqlseed_web/static/js/pages/browse.js +209 -0
- sqlseed_web/static/js/pages/configs.js +424 -0
- sqlseed_web/static/js/pages/connect.js +332 -0
- sqlseed_web/static/js/pages/heal.js +395 -0
- sqlseed_web/static/js/pages/meta.js +110 -0
- sqlseed_web/static/js/pages/runs.js +293 -0
- sqlseed_web/static/js/pages/settings.js +942 -0
- sqlseed_web/static/js/pages/wizard.js +751 -0
- sqlseed_web/static/js/pages/workbench.js +3123 -0
- sqlseed_web/static/js/tree.js +126 -0
- sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
- sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
- sqlseed_web/static/js/workbench/ai-stream.js +116 -0
- sqlseed_web/static/js/workbench/ai.js +888 -0
- sqlseed_web/static/js/workbench/connection.js +508 -0
- sqlseed_web/static/js/workbench/date-picker.js +445 -0
- sqlseed_web/static/js/workbench/dependency-view.js +119 -0
- sqlseed_web/static/js/workbench/editor.js +1236 -0
- sqlseed_web/static/js/workbench/focus.js +11 -0
- sqlseed_web/static/js/workbench/graph-layout.js +332 -0
- sqlseed_web/static/js/workbench/graph.js +970 -0
- sqlseed_web/static/js/workbench/guidance.js +29 -0
- sqlseed_web/static/js/workbench/model.js +124 -0
- sqlseed_web/static/js/workbench/plugin-management.js +512 -0
- sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
- sqlseed_web/static/js/workbench/preview.js +572 -0
- sqlseed_web/static/js/workbench/provider-guide.js +33 -0
- sqlseed_web/static/js/workbench/recovery.js +28 -0
- sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
- sqlseed_web/static/js/workbench/session.js +174 -0
- sqlseed_web/static/js/workbench/table-data.js +186 -0
- sqlseed_web/static/js/workbench/ui.js +262 -0
- sqlseed_web/static/navigation.css +92 -0
- sqlseed_web/static/preview.css +29 -0
- sqlseed_web/static/runs.css +53 -0
- sqlseed_web/static/scrollbars.css +42 -0
- sqlseed_web/static/settings.css +108 -0
- sqlseed_web/static/style.css +3382 -0
- sqlseed_web/static/table-data.css +27 -0
- sqlseed_web/static/workbench.css +509 -0
- sqlseed_web/supervised_plugins.py +173 -0
- sqlseed_web/supervisor.py +238 -0
- sqlseed_web/workbench.py +381 -0
- sqlseed_web/workbench_ai.py +887 -0
- sqlseed_web/workbench_ai_relations.py +285 -0
- sqlseed_web/workbench_ai_stream.py +172 -0
- sqlseed_web/workbench_data.py +163 -0
- sqlseed_web/workbench_execution.py +199 -0
- sqlseed_web/workbench_runtime.py +1218 -0
- sqlseed_web/workbench_schema.py +277 -0
- sqlseed_web/workbench_store.py +458 -0
- sqlseed_web/worker_control.py +192 -0
- sqlseed_web-0.2.4.dist-info/METADATA +105 -0
- sqlseed_web-0.2.4.dist-info/RECORD +87 -0
- sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
- sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
- sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Keep Tab navigation inside the caller's already-filtered dialog controls. */
|
|
2
|
+
export function cycleFocus(event, controls) {
|
|
3
|
+
const first = controls[0], last = controls.at(-1);
|
|
4
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
5
|
+
event.preventDefault();
|
|
6
|
+
last?.focus();
|
|
7
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
8
|
+
event.preventDefault();
|
|
9
|
+
first?.focus();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/* Standalone prototype layout: no DOM, dependencies, or database access. */
|
|
2
|
+
(function (root, factory) {
|
|
3
|
+
'use strict';
|
|
4
|
+
const api = factory();
|
|
5
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
6
|
+
else root.SqlseedGraphLayout = api;
|
|
7
|
+
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const NODE_WIDTH = 210;
|
|
11
|
+
const NODE_HEIGHT = 74;
|
|
12
|
+
const PADDING = 36;
|
|
13
|
+
const LANE = 7;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parents point to children. Components are strongly connected components,
|
|
17
|
+
* not disconnected subgraphs; a component is cyclic for a self-reference too.
|
|
18
|
+
* Node and edge identities must be unique strings. Invalid references throw.
|
|
19
|
+
* Returned paths contain orthogonal SVG M/L segments, ready for arrow markers.
|
|
20
|
+
* Large graphs deliberately grow the canvas instead of overlapping nodes.
|
|
21
|
+
*/
|
|
22
|
+
function layoutGraph(nodes, edges) {
|
|
23
|
+
if (!Array.isArray(nodes) || !Array.isArray(edges)) throw new TypeError('Expected node and edge arrays');
|
|
24
|
+
const original = new Map();
|
|
25
|
+
const adjacency = new Map();
|
|
26
|
+
const reverse = new Map();
|
|
27
|
+
nodes.forEach((node, index) => {
|
|
28
|
+
if (!node || typeof node.id !== 'string' || !node.id) throw new TypeError('Node id must be a nonempty string');
|
|
29
|
+
if (original.has(node.id)) throw new Error(`Duplicate node id: ${node.id}`);
|
|
30
|
+
original.set(node.id, index);
|
|
31
|
+
adjacency.set(node.id, []);
|
|
32
|
+
reverse.set(node.id, []);
|
|
33
|
+
});
|
|
34
|
+
const edgeIds = new Set();
|
|
35
|
+
for (const edge of edges) {
|
|
36
|
+
if (!edge || typeof edge.id !== 'string' || !edge.id) throw new TypeError('Edge id must be a nonempty string');
|
|
37
|
+
if (edgeIds.has(edge.id)) throw new Error(`Duplicate edge id: ${edge.id}`);
|
|
38
|
+
if (!original.has(edge.source) || !original.has(edge.target)) throw new Error(`Unknown endpoint on edge: ${edge.id}`);
|
|
39
|
+
edgeIds.add(edge.id);
|
|
40
|
+
adjacency.get(edge.source).push(edge.target);
|
|
41
|
+
reverse.get(edge.target).push(edge.source);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Iterative Kosaraju avoids call-stack limits on long dependency chains.
|
|
45
|
+
const visited = new Set();
|
|
46
|
+
const finishingOrder = [];
|
|
47
|
+
for (const node of nodes) {
|
|
48
|
+
if (visited.has(node.id)) continue;
|
|
49
|
+
visited.add(node.id);
|
|
50
|
+
const stack = [{ id: node.id, next: 0 }];
|
|
51
|
+
while (stack.length) {
|
|
52
|
+
const frame = stack[stack.length - 1];
|
|
53
|
+
const neighbors = adjacency.get(frame.id);
|
|
54
|
+
if (frame.next < neighbors.length) {
|
|
55
|
+
const child = neighbors[frame.next++];
|
|
56
|
+
if (!visited.has(child)) { visited.add(child); stack.push({ id: child, next: 0 }); }
|
|
57
|
+
} else { finishingOrder.push(frame.id); stack.pop(); }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const componentOf = new Map();
|
|
61
|
+
const components = [];
|
|
62
|
+
for (const start of finishingOrder.reverse()) {
|
|
63
|
+
if (componentOf.has(start)) continue;
|
|
64
|
+
const id = components.length;
|
|
65
|
+
const nodeIds = [];
|
|
66
|
+
const stack = [start];
|
|
67
|
+
componentOf.set(start, id);
|
|
68
|
+
while (stack.length) {
|
|
69
|
+
const current = stack.pop();
|
|
70
|
+
nodeIds.push(current);
|
|
71
|
+
for (const parent of reverse.get(current)) {
|
|
72
|
+
if (!componentOf.has(parent)) { componentOf.set(parent, id); stack.push(parent); }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
nodeIds.sort((a, b) => original.get(a) - original.get(b));
|
|
76
|
+
components.push({ id, nodeIds, rank: 0, cyclic: nodeIds.length > 1 });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const children = components.map(() => new Set());
|
|
80
|
+
const parents = components.map(() => new Set());
|
|
81
|
+
for (const edge of edges) {
|
|
82
|
+
const source = componentOf.get(edge.source), target = componentOf.get(edge.target);
|
|
83
|
+
if (source === target) { components[source].cyclic = true; continue; }
|
|
84
|
+
children[source].add(target);
|
|
85
|
+
parents[target].add(source);
|
|
86
|
+
}
|
|
87
|
+
const indegree = parents.map(set => set.size);
|
|
88
|
+
const queue = components.filter(c => !indegree[c.id]).map(c => c.id);
|
|
89
|
+
for (let i = 0; i < queue.length; i++) {
|
|
90
|
+
const component = components[queue[i]];
|
|
91
|
+
for (const child of children[component.id]) {
|
|
92
|
+
components[child].rank = Math.max(components[child].rank, component.rank + 1);
|
|
93
|
+
if (--indegree[child] === 0) queue.push(child);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const maxRank = components.reduce((max, c) => Math.max(max, c.rank), 0);
|
|
98
|
+
const ranks = Array.from({ length: maxRank + 1 }, () => []);
|
|
99
|
+
for (const component of components) ranks[component.rank].push(component);
|
|
100
|
+
// Keep SCCs together and move children toward the average of their parents.
|
|
101
|
+
const verticalCenter = new Map();
|
|
102
|
+
const parentCenter = c => {
|
|
103
|
+
const values = [...parents[c.id]].map(id => verticalCenter.get(id));
|
|
104
|
+
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : original.get(c.nodeIds[0]);
|
|
105
|
+
};
|
|
106
|
+
for (const rank of ranks) {
|
|
107
|
+
rank.sort((a, b) => parentCenter(a) - parentCenter(b) || original.get(a.nodeIds[0]) - original.get(b.nodeIds[0]));
|
|
108
|
+
let offset = 0;
|
|
109
|
+
for (const component of rank) { verticalCenter.set(component.id, offset + component.nodeIds.length / 2); offset += component.nodeIds.length; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const rightLanes = ranks.map(() => 0), leftLanes = ranks.map(() => 0);
|
|
113
|
+
const outgoing = new Map(nodes.map(n => [n.id, 0])), incoming = new Map(nodes.map(n => [n.id, 0]));
|
|
114
|
+
const selfCounts = new Map(nodes.map(n => [n.id, 0]));
|
|
115
|
+
let longCount = 0;
|
|
116
|
+
const routes = edges.map(edge => {
|
|
117
|
+
const sourceRank = components[componentOf.get(edge.source)].rank;
|
|
118
|
+
const targetRank = components[componentOf.get(edge.target)].rank;
|
|
119
|
+
const kind = edge.source === edge.target ? 'self' : componentOf.get(edge.source) === componentOf.get(edge.target) ? 'cycle' : 'normal';
|
|
120
|
+
const route = {
|
|
121
|
+
edge, sourceRank, targetRank, kind, rightLane: rightLanes[sourceRank]++,
|
|
122
|
+
leftLane: kind === 'normal' ? leftLanes[targetRank]++ : 0,
|
|
123
|
+
sourcePort: outgoing.get(edge.source), targetPort: incoming.get(edge.target),
|
|
124
|
+
topLane: targetRank - sourceRank > 1 ? longCount++ : -1,
|
|
125
|
+
selfLane: selfCounts.get(edge.source),
|
|
126
|
+
};
|
|
127
|
+
outgoing.set(edge.source, route.sourcePort + 1);
|
|
128
|
+
incoming.set(edge.target, route.targetPort + 1);
|
|
129
|
+
if (kind === 'self') selfCounts.set(edge.source, route.selfLane + 1);
|
|
130
|
+
return route;
|
|
131
|
+
});
|
|
132
|
+
const maxSelf = Math.max(0, ...selfCounts.values());
|
|
133
|
+
const rowGap = Math.max(62, 28 + maxSelf * 8);
|
|
134
|
+
const startY = PADDING + longCount * 10 + 30 + maxSelf * 8;
|
|
135
|
+
const rankX = [PADDING];
|
|
136
|
+
for (let i = 1; i <= maxRank; i++) rankX[i] = rankX[i - 1] + NODE_WIDTH + 56 + (rightLanes[i - 1] + leftLanes[i]) * LANE;
|
|
137
|
+
const positioned = new Map();
|
|
138
|
+
for (let rank = 0; rank < ranks.length; rank++) {
|
|
139
|
+
let row = 0;
|
|
140
|
+
for (const component of ranks[rank]) for (const id of component.nodeIds) {
|
|
141
|
+
positioned.set(id, { ...nodes[original.get(id)], x: rankX[rank], y: startY + row++ * (NODE_HEIGHT + rowGap), width: NODE_WIDTH, height: NODE_HEIGHT, component: component.id, rank });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const number = value => Math.round(value * 100) / 100;
|
|
146
|
+
const pathFor = points => points.map(([x, y], i) => `${i ? 'L' : 'M'} ${number(x)} ${number(y)}`).join(' ');
|
|
147
|
+
const routed = routes.map(route => {
|
|
148
|
+
const { edge, kind } = route;
|
|
149
|
+
const source = positioned.get(edge.source), target = positioned.get(edge.target);
|
|
150
|
+
const sourceRight = source.x + NODE_WIDTH;
|
|
151
|
+
const sourceY = number(source.y + NODE_HEIGHT * (route.sourcePort + 1) / (outgoing.get(edge.source) + 1));
|
|
152
|
+
const targetY = number(target.y + NODE_HEIGHT * (route.targetPort + 1) / (incoming.get(edge.target) + 1));
|
|
153
|
+
const rightX = sourceRight + 22 + route.rightLane * LANE;
|
|
154
|
+
const leftX = target.x - 22 - route.leftLane * LANE;
|
|
155
|
+
let points, labelX, labelY;
|
|
156
|
+
if (kind === 'self') {
|
|
157
|
+
const top = source.y - 18 - route.selfLane * 8;
|
|
158
|
+
const endX = number(source.x + NODE_WIDTH * (0.3 + 0.4 * (route.selfLane + 1) / (selfCounts.get(edge.source) + 1)));
|
|
159
|
+
points = [[sourceRight, sourceY], [rightX, sourceY], [rightX, top], [endX, top], [endX, source.y]];
|
|
160
|
+
labelX = (rightX + endX) / 2; labelY = top - 5;
|
|
161
|
+
} else if (kind === 'cycle') {
|
|
162
|
+
points = [[sourceRight, sourceY], [rightX, sourceY], [rightX, targetY], [target.x + NODE_WIDTH, targetY]];
|
|
163
|
+
labelX = rightX + 5; labelY = (sourceY + targetY) / 2;
|
|
164
|
+
} else if (route.topLane >= 0) {
|
|
165
|
+
const top = PADDING + route.topLane * 10;
|
|
166
|
+
points = [[sourceRight, sourceY], [rightX, sourceY], [rightX, top], [leftX, top], [leftX, targetY], [target.x, targetY]];
|
|
167
|
+
labelX = (rightX + leftX) / 2; labelY = top - 5;
|
|
168
|
+
} else {
|
|
169
|
+
points = [[sourceRight, sourceY], [rightX, sourceY], [rightX, targetY], [target.x, targetY]];
|
|
170
|
+
labelX = (rightX + target.x) / 2; labelY = targetY - 7;
|
|
171
|
+
}
|
|
172
|
+
return { ...edge, path: pathFor(points), kind, labelX: number(labelX), labelY: number(labelY) };
|
|
173
|
+
});
|
|
174
|
+
const resultNodes = nodes.map(node => positioned.get(node.id));
|
|
175
|
+
const width = nodes.length ? rankX[maxRank] + NODE_WIDTH + 56 + rightLanes[maxRank] * LANE + PADDING : PADDING * 2;
|
|
176
|
+
const height = resultNodes.reduce((max, node) => Math.max(max, node.y + NODE_HEIGHT + PADDING), PADDING * 2);
|
|
177
|
+
return { nodes: resultNodes, edges: routed, width, height, components };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Place readable, unabridged edge captions after routing the graph. Labels are
|
|
182
|
+
* rendered in a separate SVG layer: rect at x/y, then lines as tspans beginning
|
|
183
|
+
* at textX/textY with lineHeight spacing. Geometry assumes 11px monospace text;
|
|
184
|
+
* the conservative character widths also reserve space for CJK fallback fonts.
|
|
185
|
+
* Every caption avoids nodes, other captions, and all original FK paths. A
|
|
186
|
+
* short leader can join a nearby caption to its route. On crowded graphs an
|
|
187
|
+
* outside caption may have no leader; its edgeId still identifies the relation.
|
|
188
|
+
* Call this on the base layout again when labels change, never on its result.
|
|
189
|
+
*/
|
|
190
|
+
function placeEdgeLabels(layout, captions) {
|
|
191
|
+
if (!layout || !Array.isArray(layout.nodes) || !Array.isArray(layout.edges) || !Array.isArray(captions)) {
|
|
192
|
+
throw new TypeError('Expected a graph layout and caption array');
|
|
193
|
+
}
|
|
194
|
+
const edgeById = new Map(layout.edges.map(edge => [edge.id, edge]));
|
|
195
|
+
const seen = new Set();
|
|
196
|
+
const lineHeight = 16, pad = 8, maxTextWidth = 196, gap = 7;
|
|
197
|
+
const characterWidth = char => char.codePointAt(0) > 255 ? 12 : 7;
|
|
198
|
+
const wrap = text => {
|
|
199
|
+
const lines = [];
|
|
200
|
+
let line = '', width = 0;
|
|
201
|
+
for (const char of text) {
|
|
202
|
+
const nextWidth = characterWidth(char);
|
|
203
|
+
if (line && width + nextWidth > maxTextWidth) { lines.push(line); line = ''; width = 0; }
|
|
204
|
+
line += char; width += nextWidth;
|
|
205
|
+
}
|
|
206
|
+
if (line || !lines.length) lines.push(line);
|
|
207
|
+
return lines;
|
|
208
|
+
};
|
|
209
|
+
const segments = path => {
|
|
210
|
+
const points = [...path.matchAll(/[ML] (-?[\d.]+) (-?[\d.]+)/g)].map(m => [+m[1], +m[2]]);
|
|
211
|
+
return points.slice(1).map((b, i) => [points[i], b]).filter(([a, b]) => a[0] !== b[0] || a[1] !== b[1]);
|
|
212
|
+
};
|
|
213
|
+
const edgeSegments = new Map(layout.edges.map(edge => [edge.id, segments(edge.path)]));
|
|
214
|
+
const occupiedSegments = [...edgeSegments.values()].flat();
|
|
215
|
+
const intersects = (a, b, clearance = gap) => a.x < b.x + b.width + clearance && a.x + a.width + clearance > b.x &&
|
|
216
|
+
a.y < b.y + b.height + clearance && a.y + a.height + clearance > b.y;
|
|
217
|
+
const crosses = ([a, b], box, clearance = 3) => {
|
|
218
|
+
const left = box.x - clearance, right = box.x + box.width + clearance;
|
|
219
|
+
const top = box.y - clearance, bottom = box.y + box.height + clearance;
|
|
220
|
+
return a[0] === b[0]
|
|
221
|
+
? a[0] > left && a[0] < right && Math.max(a[1], b[1]) > top && Math.min(a[1], b[1]) < bottom
|
|
222
|
+
: a[1] > top && a[1] < bottom && Math.max(a[0], b[0]) > left && Math.min(a[0], b[0]) < right;
|
|
223
|
+
};
|
|
224
|
+
const placed = [];
|
|
225
|
+
let width = layout.width, height = layout.height;
|
|
226
|
+
const pathFor = points => points.map(([x, y], i) => `${i ? 'L' : 'M'} ${Math.round(x * 100) / 100} ${Math.round(y * 100) / 100}`).join(' ');
|
|
227
|
+
for (const caption of captions) {
|
|
228
|
+
if (!caption || typeof caption.text !== 'string' || !caption.text || !edgeById.has(caption.edgeId)) throw new TypeError('Each caption needs a known edgeId and nonempty text');
|
|
229
|
+
if (seen.has(caption.edgeId)) throw new Error(`Duplicate caption: ${caption.edgeId}`);
|
|
230
|
+
seen.add(caption.edgeId);
|
|
231
|
+
const edge = edgeById.get(caption.edgeId), lines = wrap(caption.text);
|
|
232
|
+
const boxWidth = Math.max(...lines.map(line => [...line].reduce((sum, char) => sum + characterWidth(char), 0))) + pad * 2;
|
|
233
|
+
const boxHeight = lines.length * lineHeight + 12;
|
|
234
|
+
const candidates = [];
|
|
235
|
+
const addCandidate = (x, y, anchor, endpoint) => {
|
|
236
|
+
const box = { x, y, width: boxWidth, height: boxHeight };
|
|
237
|
+
if (x < 8 || y < 8 || layout.nodes.some(node => intersects(box, node)) || placed.some(label => intersects(box, label))) return;
|
|
238
|
+
if (occupiedSegments.some(segment => crosses(segment, box))) return;
|
|
239
|
+
const leader = [anchor, endpoint];
|
|
240
|
+
if (layout.nodes.some(node => crosses(leader, node, 2)) || placed.some(label => crosses(leader, label, 2))) return;
|
|
241
|
+
const distance = Math.abs(endpoint[0] - anchor[0]) + Math.abs(endpoint[1] - anchor[1]);
|
|
242
|
+
const expansion = Math.max(0, x + boxWidth + pad - width) + Math.max(0, y + boxHeight + pad - height);
|
|
243
|
+
const score = distance + expansion * 2 + (Math.abs(anchor[0] - edge.labelX) + Math.abs(anchor[1] - edge.labelY)) * .2;
|
|
244
|
+
candidates.push({ ...box, anchorX: anchor[0], anchorY: anchor[1], leaderPath: pathFor(leader), score });
|
|
245
|
+
};
|
|
246
|
+
for (const [a, b] of edgeSegments.get(edge.id)) {
|
|
247
|
+
const horizontal = a[1] === b[1];
|
|
248
|
+
for (const fraction of [.5, .25, .75]) {
|
|
249
|
+
const anchor = [a[0] + (b[0] - a[0]) * fraction, a[1] + (b[1] - a[1]) * fraction];
|
|
250
|
+
// Row/column boundaries provide exact clearances where a fixed step
|
|
251
|
+
// could jump over a narrow but usable space between neighboring tables.
|
|
252
|
+
const boundaries = [...layout.nodes, ...placed].flatMap(box => horizontal
|
|
253
|
+
? [box.y - boxHeight - gap, box.y + box.height + gap]
|
|
254
|
+
: [box.x - boxWidth - gap, box.x + box.width + gap]);
|
|
255
|
+
boundaries.sort((a, b) => Math.abs(a - anchor[horizontal ? 1 : 0]) - Math.abs(b - anchor[horizontal ? 1 : 0]));
|
|
256
|
+
for (const position of [...new Set(boundaries)].slice(0, 12)) {
|
|
257
|
+
if (horizontal) {
|
|
258
|
+
if (position + boxHeight < anchor[1]) addCandidate(anchor[0] - boxWidth / 2, position, anchor, [anchor[0], position + boxHeight]);
|
|
259
|
+
else if (position > anchor[1]) addCandidate(anchor[0] - boxWidth / 2, position, anchor, [anchor[0], position]);
|
|
260
|
+
} else {
|
|
261
|
+
if (position + boxWidth < anchor[0]) addCandidate(position, anchor[1] - boxHeight / 2, anchor, [position + boxWidth, anchor[1]]);
|
|
262
|
+
else if (position > anchor[0]) addCandidate(position, anchor[1] - boxHeight / 2, anchor, [position, anchor[1]]);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for (const offset of [8, 24, 44, 72, 104, 152, 224, 320]) {
|
|
266
|
+
if (horizontal) {
|
|
267
|
+
addCandidate(anchor[0] - boxWidth / 2, anchor[1] - offset - boxHeight, anchor, [anchor[0], anchor[1] - offset]);
|
|
268
|
+
addCandidate(anchor[0] - boxWidth / 2, anchor[1] + offset, anchor, [anchor[0], anchor[1] + offset]);
|
|
269
|
+
} else {
|
|
270
|
+
addCandidate(anchor[0] - offset - boxWidth, anchor[1] - boxHeight / 2, anchor, [anchor[0] - offset, anchor[1]]);
|
|
271
|
+
addCandidate(anchor[0] + offset, anchor[1] - boxHeight / 2, anchor, [anchor[0] + offset, anchor[1]]);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
candidates.sort((a, b) => a.score - b.score || a.y - b.y || a.x - b.x);
|
|
277
|
+
let chosen = candidates[0];
|
|
278
|
+
if (!chosen) {
|
|
279
|
+
// Guaranteed unclipped fallback beyond all original routes. Nearby
|
|
280
|
+
// placements above take precedence, so ordinary diagrams stay compact.
|
|
281
|
+
chosen = { x: layout.width + 24, y: Math.max(8, edge.labelY - boxHeight / 2), width: boxWidth, height: boxHeight,
|
|
282
|
+
anchorX: edge.labelX, anchorY: edge.labelY, leaderPath: '' };
|
|
283
|
+
while (placed.some(label => intersects(chosen, label))) {
|
|
284
|
+
chosen.y = Math.max(...placed.filter(label => intersects(chosen, label)).map(label => label.y + label.height + gap));
|
|
285
|
+
}
|
|
286
|
+
// The outside column contains no original FK path. It may contain a
|
|
287
|
+
// leader from a previous caption; move below it instead of erasing it.
|
|
288
|
+
while (occupiedSegments.some(segment => crosses(segment, chosen))) {
|
|
289
|
+
chosen.y = Math.max(...occupiedSegments.filter(segment => crosses(segment, chosen)).flatMap(([a, b]) => [a[1], b[1]])) + gap;
|
|
290
|
+
while (placed.some(label => intersects(chosen, label))) {
|
|
291
|
+
chosen.y = Math.max(...placed.filter(label => intersects(chosen, label)).map(label => label.y + label.height + gap));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// A gutter caption is still connected to its own relation. Try clear
|
|
295
|
+
// horizontal rows via the source route's free vertical corridor; thin
|
|
296
|
+
// leaders may cross FK strokes but never table or caption rectangles.
|
|
297
|
+
const corridorX = chosen.x - gap, targetY = chosen.y + chosen.height / 2;
|
|
298
|
+
const rows = [...new Set([8, height + gap, ...[...layout.nodes, ...placed].flatMap(box => [box.y - gap, box.y + box.height + gap])])].filter(y => y >= 8);
|
|
299
|
+
const leaderCandidates = [];
|
|
300
|
+
for (const [a, b] of edgeSegments.get(edge.id)) for (const fraction of [.5, .25, .75]) {
|
|
301
|
+
const anchor = [a[0] + (b[0] - a[0]) * fraction, a[1] + (b[1] - a[1]) * fraction];
|
|
302
|
+
for (const row of rows) {
|
|
303
|
+
const points = [anchor, [anchor[0], row], [corridorX, row], [corridorX, targetY], [chosen.x, targetY]];
|
|
304
|
+
const parts = points.slice(1).map((b, i) => [points[i], b]);
|
|
305
|
+
if (parts.some(segment => [...layout.nodes, ...placed].some(box => crosses(segment, box, 2)))) continue;
|
|
306
|
+
const distance = parts.reduce((sum, [a, b]) => sum + Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]), 0);
|
|
307
|
+
leaderCandidates.push({ points, anchor, distance });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
leaderCandidates.sort((a, b) => a.distance - b.distance);
|
|
311
|
+
if (leaderCandidates.length) {
|
|
312
|
+
const leader = leaderCandidates[0];
|
|
313
|
+
chosen.leaderPath = pathFor(leader.points);
|
|
314
|
+
chosen.anchorX = leader.anchor[0]; chosen.anchorY = leader.anchor[1];
|
|
315
|
+
width = Math.max(width, ...leader.points.map(point => point[0] + PADDING));
|
|
316
|
+
height = Math.max(height, ...leader.points.map(point => point[1] + PADDING));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const geometry = { ...chosen };
|
|
320
|
+
delete geometry.score;
|
|
321
|
+
const label = { edgeId: caption.edgeId, text: caption.text, lines, lineHeight, ...geometry,
|
|
322
|
+
textX: chosen.x + pad, textY: chosen.y + 17 };
|
|
323
|
+
placed.push(label);
|
|
324
|
+
if (label.leaderPath) occupiedSegments.push(...segments(label.leaderPath));
|
|
325
|
+
width = Math.max(width, label.x + label.width + PADDING);
|
|
326
|
+
height = Math.max(height, label.y + label.height + PADDING);
|
|
327
|
+
}
|
|
328
|
+
return { ...layout, labels: placed, width, height };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { layoutGraph, placeEdgeLabels };
|
|
332
|
+
});
|