@squinch/core 0.2.0 → 0.3.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/dist/layout/layout.js +72 -46
- package/package.json +6 -6
package/dist/layout/layout.js
CHANGED
|
@@ -405,34 +405,24 @@ export async function layoutView(model, view, font = INTER) {
|
|
|
405
405
|
// ── edge classes: inner (same entity) | coplanar (same rank, both bare) |
|
|
406
406
|
// cross-rank (ELK's) ────────────────────────────────────────────────────
|
|
407
407
|
const inner = (e) => entityOf(e.from) === entityOf(e.to) && byPath.get(e.from)?.frame;
|
|
408
|
-
// The coplanar router handles bare leaves
|
|
409
|
-
//
|
|
410
|
-
// between the outermost
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
408
|
+
// The coplanar router handles bare leaves and leaves inside any unit — an
|
|
409
|
+
// expanded frame or a zone — plus frames named as endpoints. A unit endpoint
|
|
410
|
+
// routes between the two *outermost* unit rects, then inward to the leaf
|
|
411
|
+
// when the corridor is clear (docs/notes/coplanar.md, approaches #5 and #6).
|
|
412
|
+
// Zones used to be turned away on the grounds that a dashed boundary is not
|
|
413
|
+
// a wall a wire can enter; ELK's own wires enter zones constantly, and the
|
|
414
|
+
// first real diagram that banded two namespaces on one row (lookbook 27-k8s)
|
|
415
|
+
// fell apart on that rule. Classifying an edge coplanar (hiding it from ELK)
|
|
416
|
+
// IS the entire same-rank mechanism — there is no other way to co-layer its
|
|
417
|
+
// units.
|
|
415
418
|
const framePathSet = new Set(graph.frames.map((f) => f.path));
|
|
416
419
|
const routable = (p) => byPath.has(p) || framePathSet.has(p);
|
|
417
420
|
const coplanar = edges.filter((e) => !inner(e) &&
|
|
418
421
|
routable(e.from) &&
|
|
419
422
|
routable(e.to) &&
|
|
420
|
-
!outerZoneOf(entityOf(e.from)) &&
|
|
421
|
-
!outerZoneOf(entityOf(e.to)) &&
|
|
422
423
|
unitOf(e.from) !== unitOf(e.to) &&
|
|
423
424
|
rank.get(unitOf(e.from)) === rank.get(unitOf(e.to)));
|
|
424
425
|
const coplanarSet = new Set(coplanar.map((e) => e.id));
|
|
425
|
-
for (const e of edges) {
|
|
426
|
-
if (!inner(e) && !coplanarSet.has(e.id) &&
|
|
427
|
-
unitOf(e.from) !== unitOf(e.to) &&
|
|
428
|
-
rank.get(unitOf(e.from)) === rank.get(unitOf(e.to)))
|
|
429
|
-
diagnostics.push({
|
|
430
|
-
severity: "warning",
|
|
431
|
-
message: `same-rank edge ${e.from} → ${e.to} involves a zone — the router cannot cross a zone boundary, so the row may not hold`,
|
|
432
|
-
fix: `give the zone its own band in \`rows\`, or drop one end from the zone`,
|
|
433
|
-
loc: view.loc,
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
426
|
const elkEdges = edges.filter((e) => !coplanarSet.has(e.id));
|
|
437
427
|
const natural = new Map(units.map((p) => [p, 0]));
|
|
438
428
|
for (let i = 0; i < units.length; i++)
|
|
@@ -725,6 +715,12 @@ export async function layoutView(model, view, font = INTER) {
|
|
|
725
715
|
// wrong. DESIGN §2's rule is about numbers chosen from a deliberate
|
|
726
716
|
// scale, not arithmetic for its own sake, and this pair is the scale.
|
|
727
717
|
"elk.padding": "[top=28,left=20,bottom=20,right=20]",
|
|
718
|
+
// a labelled zone-coplanar edge widens the gutter to its pill, exactly
|
|
719
|
+
// as entityElk does for frames — a zone is a unit, and the reservation
|
|
720
|
+
// is keyed by unit
|
|
721
|
+
...(coplanarGutter.get(z.id)
|
|
722
|
+
? { "elk.spacing.individual": `elk.spacing.nodeNode:${coplanarGutter.get(z.id)}` }
|
|
723
|
+
: {}),
|
|
728
724
|
"elk.spacing.nodeNode": String(SP[0]),
|
|
729
725
|
"elk.layered.spacing.nodeNodeBetweenLayers": hasElkLabels ? String(LABEL_GAP) : String(SP[1]),
|
|
730
726
|
// see entityElk: ELK does not inherit edge spacing into a compound
|
|
@@ -962,9 +958,9 @@ export async function layoutView(model, view, font = INTER) {
|
|
|
962
958
|
};
|
|
963
959
|
const frameByPath = new Map(frames.map((f) => [f.path, f]));
|
|
964
960
|
const zoneRectById = new Map(pZones.map((z) => [z.id, { path: z.id, ...z }]));
|
|
965
|
-
// a frame is its own unit, so try
|
|
966
|
-
// only for endpoints that are genuinely bare nodes
|
|
967
|
-
const routeRect = (p) => frameByPath.get(unitOf(p)) ?? nodeById.get(p);
|
|
961
|
+
// a frame or zone is its own unit, so try those rects first — the leaf
|
|
962
|
+
// branch is only for endpoints that are genuinely bare nodes
|
|
963
|
+
const routeRect = (p) => frameByPath.get(unitOf(p)) ?? zoneRectById.get(unitOf(p)) ?? nodeById.get(p);
|
|
968
964
|
/** Every unit's rect on a rank — the obstacle set for blockedness. Unlike
|
|
969
965
|
* the old leaf-only scan this sees frames and zones too, so a coplanar
|
|
970
966
|
* wire no longer threads straight through a boundary it never noticed. */
|
|
@@ -1007,19 +1003,48 @@ export async function layoutView(model, view, font = INTER) {
|
|
|
1007
1003
|
const b = routeRect(e.to);
|
|
1008
1004
|
const blocked = blockedBy(e, a, b);
|
|
1009
1005
|
const midCross = (n) => n[cross] + Math.round(n[crossSize] / 2);
|
|
1010
|
-
/** The cross-coordinate the wire wants at an endpoint
|
|
1011
|
-
*
|
|
1012
|
-
* into the pair's shared
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1015
|
-
|
|
1006
|
+
/** The cross-coordinate the wire wants at an endpoint: the interior leaf's
|
|
1007
|
+
* own height, so the entry sits beside it, clamped into its unit's rect.
|
|
1008
|
+
* Approach #5 clamped into the pair's *shared* band so a straight run could
|
|
1009
|
+
* not miss the other rect; the jog handles any pair of heights, and the
|
|
1010
|
+
* shared clamp is what parked an entry away from a low leaf and stopped
|
|
1011
|
+
* the corridor below from ever reaching it. */
|
|
1012
|
+
const wantCross = (p, own) => {
|
|
1016
1013
|
const leaf = byPath.has(p) ? nodeById.get(p) : undefined;
|
|
1017
1014
|
const raw = leaf ? midCross(leaf) : midCross(own);
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1015
|
+
return Math.min(Math.max(raw, own[cross] + 12), own[cross] + own[crossSize] - 12);
|
|
1016
|
+
};
|
|
1017
|
+
/** Approach #6: walk a unit-wall entry inward to the leaf's own wall when
|
|
1018
|
+
* the straight corridor between them is provably empty — no node but the
|
|
1019
|
+
* leaf, no frame that does not enclose it, ±16 on the cross axis. Zone
|
|
1020
|
+
* boundaries are not obstacles; ELK's wires cross them too. The port then
|
|
1021
|
+
* sits on the leaf's face, so parallel wires spread *there* rather than
|
|
1022
|
+
* converging on one point after the wall. Anything else keeps the wall as
|
|
1023
|
+
* the end, exactly as approach #5 drew it — the wire never jogs inside a
|
|
1024
|
+
* compound, that interior is ELK's. */
|
|
1025
|
+
const inward = (p, unit, side, want) => {
|
|
1026
|
+
const wallOf = (r) => (side === highSide ? r[along] + r[alongSize] : r[along]);
|
|
1027
|
+
const atWall = () => {
|
|
1028
|
+
const c = freePort(unit, side, want);
|
|
1029
|
+
return { c, end: pt(wallOf(unit), c), node: unit.path };
|
|
1030
|
+
};
|
|
1031
|
+
const leaf = byPath.has(p) ? nodeById.get(p) : undefined;
|
|
1032
|
+
if (!leaf || leaf.path === unit.path)
|
|
1033
|
+
return atWall();
|
|
1034
|
+
const lo = Math.min(wallOf(unit), wallOf(leaf)), hi = Math.max(wallOf(unit), wallOf(leaf));
|
|
1035
|
+
const clear = (c) => {
|
|
1036
|
+
if (c < leaf[cross] + 8 || c > leaf[cross] + leaf[crossSize] - 8)
|
|
1037
|
+
return false;
|
|
1038
|
+
const hit = (r) => r[along] < hi && r[along] + r[alongSize] > lo && r[cross] - 16 < c && r[cross] + r[crossSize] + 16 > c;
|
|
1039
|
+
return (!nodes.some((n) => n.path !== leaf.path && hit(n)) &&
|
|
1040
|
+
!frames.some((f) => !leaf.path.startsWith(`${f.path}.`) && hit(f)));
|
|
1041
|
+
};
|
|
1042
|
+
if (!clear(want))
|
|
1043
|
+
return atWall();
|
|
1044
|
+
const c = freePort(leaf, side, want);
|
|
1045
|
+
if (!clear(c))
|
|
1046
|
+
return atWall();
|
|
1047
|
+
return { c, end: pt(wallOf(leaf), c), node: leaf.path };
|
|
1023
1048
|
};
|
|
1024
1049
|
const carry = { label: e.label, async: e.async, animate: e.animate, style: e.style, count: e.count, tags: e.tags, color: e.color, heads: e.heads };
|
|
1025
1050
|
// The router owns coplanar geometry, so it reserves and reports
|
|
@@ -1055,27 +1080,28 @@ export async function layoutView(model, view, font = INTER) {
|
|
|
1055
1080
|
ports.push({ edge: e.id, node: a.path, side: first ? highSide : lowSide, x: pts[0].x, y: pts[0].y }, { edge: e.id, node: b.path, side: first ? lowSide : highSide, x: pts[1].x, y: pts[1].y });
|
|
1056
1081
|
return { id: e.id, from: e.from, to: e.to, ...carry, points: pts, labelRect, coplanar: true };
|
|
1057
1082
|
}
|
|
1058
|
-
|
|
1059
|
-
const bWant = wantCross(e.to, b, a);
|
|
1060
|
-
if (!blocked && !Number.isNaN(aWant) && !Number.isNaN(bWant)) {
|
|
1083
|
+
if (!blocked) {
|
|
1061
1084
|
const first = a[along] <= b[along];
|
|
1062
1085
|
const [aSide, bSide] = first ? [highSide, lowSide] : [lowSide, highSide];
|
|
1063
|
-
// freePort spreads parallel
|
|
1064
|
-
// stay put and agree, the run is straight —
|
|
1065
|
-
// mid-gutter, which also gives every stub
|
|
1066
|
-
|
|
1067
|
-
|
|
1086
|
+
// freePort spreads parallel entries 16 apart on whichever face the wire
|
|
1087
|
+
// ends on; when both entries stay put and agree, the run is straight —
|
|
1088
|
+
// otherwise it jogs at mid-gutter, which also gives every stub
|
|
1089
|
+
// gutter/2 ≥ 24 of clearance. The pill always sits on the gutter run,
|
|
1090
|
+
// between the two unit walls, whether or not the ends reached inward.
|
|
1091
|
+
const ia = inward(e.from, a, aSide, wantCross(e.from, a));
|
|
1092
|
+
const ib = inward(e.to, b, bSide, wantCross(e.to, b));
|
|
1093
|
+
const [aC, bC] = [ia.c, ib.c];
|
|
1068
1094
|
const aWall = first ? a[along] + a[alongSize] : a[along];
|
|
1069
1095
|
const bWall = first ? b[along] : b[along] + b[alongSize];
|
|
1070
1096
|
if (aC === bC) {
|
|
1071
|
-
const pts = [
|
|
1097
|
+
const pts = [ia.end, ib.end];
|
|
1072
1098
|
const labelRect = rectOnRun(Math.min(aWall, bWall), Math.max(aWall, bWall), aC);
|
|
1073
|
-
ports.push({ edge: e.id, node:
|
|
1099
|
+
ports.push({ edge: e.id, node: ia.node, side: aSide, x: pts[0].x, y: pts[0].y }, { edge: e.id, node: ib.node, side: bSide, x: pts[1].x, y: pts[1].y });
|
|
1074
1100
|
return { id: e.id, from: e.from, to: e.to, ...carry, points: pts, labelRect, coplanar: true };
|
|
1075
1101
|
}
|
|
1076
1102
|
// jog: 4-point Z at mid-gutter — the pill sits on the crossing segment
|
|
1077
1103
|
const mid = Math.round((Math.min(aWall, bWall) + Math.max(aWall, bWall)) / 2);
|
|
1078
|
-
const pts = [
|
|
1104
|
+
const pts = [ia.end, pt(mid, aC), pt(mid, bC), ib.end];
|
|
1079
1105
|
const labelRect = e.label
|
|
1080
1106
|
? (() => {
|
|
1081
1107
|
const bw = badgeW(e.id);
|
|
@@ -1085,7 +1111,7 @@ export async function layoutView(model, view, font = INTER) {
|
|
|
1085
1111
|
return { x: r.x, y: r.y, ...(flowsRight ? { w: 18, h: w } : { w, h: 18 }) };
|
|
1086
1112
|
})()
|
|
1087
1113
|
: undefined;
|
|
1088
|
-
ports.push({ edge: e.id, node:
|
|
1114
|
+
ports.push({ edge: e.id, node: ia.node, side: aSide, x: pts[0].x, y: pts[0].y }, { edge: e.id, node: ib.node, side: bSide, x: pts[3].x, y: pts[3].y });
|
|
1089
1115
|
return { id: e.id, from: e.from, to: e.to, ...carry, points: pts, labelRect, coplanar: true };
|
|
1090
1116
|
}
|
|
1091
1117
|
// shelf: past the rank's far edge on the cross axis, measured over every
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@squinch/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The Squinch engine: parse, model, layout and deterministic SVG rendering for architecture diagrams as code.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"architecture",
|
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
"@lezer/lr": "^1.4.10",
|
|
34
34
|
"elkjs": "^0.12.0",
|
|
35
35
|
"fast-xml-parser": "^5.10.1",
|
|
36
|
-
"@squinch/pack-
|
|
37
|
-
"@squinch/pack-
|
|
38
|
-
"@squinch/pack-
|
|
39
|
-
"@squinch/pack-logos": "0.
|
|
40
|
-
"@squinch/pack-
|
|
36
|
+
"@squinch/pack-azure": "0.3.0",
|
|
37
|
+
"@squinch/pack-aws": "0.3.0",
|
|
38
|
+
"@squinch/pack-sys": "0.3.0",
|
|
39
|
+
"@squinch/pack-logos": "0.3.0",
|
|
40
|
+
"@squinch/pack-k8s": "0.3.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@fontsource/ibm-plex-mono": "^5.3.0",
|