@squinch/core 0.1.1 → 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/api.d.ts +11 -0
- package/dist/api.js +44 -8
- package/dist/layout/layout.js +72 -46
- package/package.json +6 -6
package/dist/api.d.ts
CHANGED
|
@@ -36,6 +36,17 @@ export declare function iconsUsedBy(files: ProjectFile[] | string): {
|
|
|
36
36
|
* them and a result of only `azure/key-vaults` reads as proof that
|
|
37
37
|
* `azure/key-vault` doesn't exist) can look them up via `packInfo`. */
|
|
38
38
|
export declare function searchIcons(query: string, pack?: string): string[];
|
|
39
|
+
/** The same search, saying how it matched. `relaxed: true` means no icon
|
|
40
|
+
* matched every word, so the hits are the closest partial matches instead —
|
|
41
|
+
* callers that print for an agent loop label them (`no exact match —
|
|
42
|
+
* closest:`), because near-misses answer the question and an empty list
|
|
43
|
+
* forces a second pass with different words. Measured before ranking
|
|
44
|
+
* existed: `rag` returned eighteen sto*rag*e rows with `sys/rag` dead last,
|
|
45
|
+
* and "message queue" / "object storage" returned nothing at all. */
|
|
46
|
+
export declare function searchIconsDetailed(query: string, pack?: string): {
|
|
47
|
+
hits: string[];
|
|
48
|
+
relaxed: boolean;
|
|
49
|
+
};
|
|
39
50
|
export type { ProjectFile };
|
|
40
51
|
export type * from "./model/types.js";
|
|
41
52
|
export { HUES } from "./model/types.js";
|
package/dist/api.js
CHANGED
|
@@ -69,6 +69,16 @@ export function iconsUsedBy(files) {
|
|
|
69
69
|
* them and a result of only `azure/key-vaults` reads as proof that
|
|
70
70
|
* `azure/key-vault` doesn't exist) can look them up via `packInfo`. */
|
|
71
71
|
export function searchIcons(query, pack) {
|
|
72
|
+
return searchIconsDetailed(query, pack).hits;
|
|
73
|
+
}
|
|
74
|
+
/** The same search, saying how it matched. `relaxed: true` means no icon
|
|
75
|
+
* matched every word, so the hits are the closest partial matches instead —
|
|
76
|
+
* callers that print for an agent loop label them (`no exact match —
|
|
77
|
+
* closest:`), because near-misses answer the question and an empty list
|
|
78
|
+
* forces a second pass with different words. Measured before ranking
|
|
79
|
+
* existed: `rag` returned eighteen sto*rag*e rows with `sys/rag` dead last,
|
|
80
|
+
* and "message queue" / "object storage" returned nothing at all. */
|
|
81
|
+
export function searchIconsDetailed(query, pack) {
|
|
72
82
|
// Vendors are inconsistent about number — Azure has "Container Registries"
|
|
73
83
|
// and "Data Factories" where everyone searches "container registry" and
|
|
74
84
|
// "data factory" — so both sides are singularized before comparing.
|
|
@@ -97,20 +107,46 @@ export function searchIcons(query, pack) {
|
|
|
97
107
|
}
|
|
98
108
|
}
|
|
99
109
|
const words = rawWords.map(stem);
|
|
100
|
-
const
|
|
110
|
+
const joined = words.join(" ");
|
|
111
|
+
const scored = [];
|
|
101
112
|
for (const name of allPackNames()) {
|
|
102
113
|
if (packFilter && name !== packFilter)
|
|
103
114
|
continue;
|
|
104
|
-
const aliases = packInfo(name)?.aliases ?? {};
|
|
105
115
|
const haystack = (id) => norm(`${id} ${iconTitle(name, id) ?? ""}`);
|
|
106
|
-
const
|
|
107
|
-
for (const id of
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
const matchedIds = new Set(iconIds(name).filter((id) => words.some((w) => haystack(id).includes(w)) || !words.length));
|
|
117
|
+
for (const id of matchedIds) {
|
|
118
|
+
const hay = haystack(id);
|
|
119
|
+
const hayWords = new Set(hay.split(" "));
|
|
120
|
+
scored.push({
|
|
121
|
+
hit: `${name}/${id}`,
|
|
122
|
+
exact: joined && norm(id) === joined ? 1 : 0,
|
|
123
|
+
whole: words.length && words.every((w) => hayWords.has(w)) ? 1 : 0,
|
|
124
|
+
matched: words.filter((w) => hay.includes(w)).length,
|
|
125
|
+
});
|
|
111
126
|
}
|
|
112
127
|
}
|
|
113
|
-
|
|
128
|
+
// One row per icon: an alias row is dropped only when its canonical is in
|
|
129
|
+
// the SAME result list. Checking the broader matched set instead deduped
|
|
130
|
+
// `sys/vector-search` (a full match) against a canonical that had only
|
|
131
|
+
// matched one word and was not being returned at all.
|
|
132
|
+
const dedupe = (list) => {
|
|
133
|
+
const present = new Set(list.map((s) => s.hit));
|
|
134
|
+
return list.filter((s) => {
|
|
135
|
+
const name = s.hit.slice(0, s.hit.indexOf("/"));
|
|
136
|
+
const id = s.hit.slice(s.hit.indexOf("/") + 1);
|
|
137
|
+
const canonical = (packInfo(name)?.aliases ?? {})[id];
|
|
138
|
+
return !(canonical && present.has(`${name}/${canonical}`)); // canonical covers it
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
const rank = (list) => dedupe(list)
|
|
142
|
+
.sort((a, b) => b.exact - a.exact || b.whole - a.whole || b.matched - a.matched || (a.hit < b.hit ? -1 : 1))
|
|
143
|
+
.map((s) => s.hit);
|
|
144
|
+
const full = scored.filter((s) => s.matched === words.length);
|
|
145
|
+
if (full.length || !words.length)
|
|
146
|
+
return { hits: rank(full), relaxed: false };
|
|
147
|
+
// Nothing matched every word: fall back to partial matches, best first.
|
|
148
|
+
// Capped — the point is a next move, not a directory listing.
|
|
149
|
+
return { hits: rank(scored).slice(0, 12), relaxed: true };
|
|
114
150
|
}
|
|
115
151
|
// the one colour vocabulary, for editors that complete it and tests that sweep it
|
|
116
152
|
export { HUES } from "./model/types.js";
|
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-sys": "0.
|
|
39
|
-
"@squinch/pack-
|
|
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",
|