@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 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 hits = [];
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 matched = new Set(iconIds(name).filter((id) => words.every((w) => haystack(id).includes(w))));
107
- for (const id of [...matched].sort()) {
108
- if (aliases[id] && matched.has(aliases[id]))
109
- continue; // canonical covers it
110
- hits.push(`${name}/${id}`);
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
- return hits.sort();
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";
@@ -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, leaves inside expanded frames,
409
- // and expanded frames themselvesa framed endpoint routes wall-to-wall
410
- // between the outermost frame rects (docs/notes/coplanar.md, approach #5).
411
- // Zone units stay out: a zone lays out as one block and its dashed boundary
412
- // is not a wall a wire can enter, so those edges go to ELK with the warning
413
- // below. Classifying an edge coplanar (hiding it from ELK) IS the entire
414
- // same-rank mechanism there is no other way to co-layer its units.
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 the frame rect first — the leaf branch is
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. A framed leaf pulls
1011
- * the wire to its own height so the wall entry sits beside it clamped
1012
- * into the pair's shared cross-overlap band, because frames on one layer
1013
- * are top-aligned with unequal sizes and an unclamped anchor could miss
1014
- * the other rect entirely. */
1015
- const wantCross = (p, own, other) => {
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
- const lo = Math.max(own[cross], other[cross]) + 12;
1019
- const hi = Math.min(own[cross] + own[crossSize], other[cross] + other[crossSize]) - 12;
1020
- if (lo > hi)
1021
- return NaN; // no shared bandshelf territory
1022
- return Math.min(Math.max(raw, lo), hi);
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
- const aWant = wantCross(e.from, a, b);
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 wall entries 16 apart; when both entries
1064
- // stay put and agree, the run is straight — otherwise it jogs at
1065
- // mid-gutter, which also gives every stub gutter/2 ≥ 24 of clearance
1066
- const aC = freePort(a, aSide, aWant);
1067
- const bC = freePort(b, bSide, bWant);
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 = [pt(aWall, aC), pt(bWall, aC)];
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: a.path, side: aSide, x: pts[0].x, y: pts[0].y }, { edge: e.id, node: b.path, side: bSide, x: pts[1].x, y: pts[1].y });
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 = [pt(aWall, aC), pt(mid, aC), pt(mid, bC), pt(bWall, bC)];
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: a.path, side: aSide, x: pts[0].x, y: pts[0].y }, { edge: e.id, node: b.path, side: bSide, x: pts[3].x, y: pts[3].y });
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.1.1",
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-aws": "0.1.1",
37
- "@squinch/pack-logos": "0.1.1",
38
- "@squinch/pack-sys": "0.1.1",
39
- "@squinch/pack-k8s": "0.1.1",
40
- "@squinch/pack-azure": "0.1.1"
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",