spiega 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spiega",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A hand-drawn board an AI agent draws on while it explains software architecture.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -146,8 +146,94 @@ function edgeTag(at, step, label, { lit, seed }) {
146
146
  });
147
147
  }
148
148
 
149
- /* `state` is 'idle' | 'lit' | 'dim' — the board lights one path at a time. */
150
- export function drawEdge(r, { step = null, state = 'idle' } = {}) {
149
+ /* Where one tag wants to sit, and how big it is, with no knowledge of the
150
+ others. On a short hop between neighbouring boxes the tag is wider than the
151
+ gap, so centring it on the line buries it under whatever is next door. Lift
152
+ it clear of both boxes instead — nudging it a few pixels is not enough,
153
+ since a box is far taller than the line is thick. `away` records which way
154
+ it was lifted, so that if it later has to move again it keeps going the way
155
+ it already went rather than back across the line it belongs to. */
156
+ /**
157
+ * @param {any} r
158
+ * @param {number|null} step
159
+ */
160
+ export function tagBox(r, step) {
161
+ const { e } = r;
162
+ const label = (step != null ? String(step) + ' ' : '') + (e.label || '');
163
+ const spot = tagSpot(r.pts);
164
+ const w = lettering(label).length * 6.51 + 20;
165
+ let at = spot.at, away = 0;
166
+ if (w > spot.len * 0.95) {
167
+ const { a, b } = r;
168
+ if (!spot.vertical) {
169
+ away = b.x >= a.x ? -1 : 1;
170
+ at = away < 0
171
+ ? [spot.at[0], Math.min(a.y, b.y) - 13]
172
+ : [spot.at[0], Math.max(a.y + a.h, b.y + b.h) + 13];
173
+ } else {
174
+ at = b.y >= a.y
175
+ ? [Math.max(a.x + a.w, b.x + b.w) + w / 2 + 10, spot.at[1]]
176
+ : [Math.min(a.x, b.x) - w / 2 - 10, spot.at[1]];
177
+ }
178
+ }
179
+ return { at, w, h: 19, away };
180
+ }
181
+
182
+ /* Tags are placed one edge at a time, which is exactly why they collide: a
183
+ node that fans out sends several edges through the same gutter, each lifts
184
+ its tag clear of the same box, and they all land on the same spot. Nothing
185
+ downstream can tell, because each pill draws itself happily on top of the
186
+ last. So resolve them together, once, before any of them is drawn.
187
+
188
+ Order matters for stability: left to right, then top to bottom, so adding an
189
+ edge on the right never reshuffles the tags on the left. A tag that was
190
+ lifted keeps being pushed the way it was lifted; one sitting on its own line
191
+ moves to whichever side is nearer, since either reads fine. */
192
+ /**
193
+ * @param {any[]} planned
194
+ * @param {(r: any) => number|null} [stepFor]
195
+ * @returns {Map<string, number[]>}
196
+ */
197
+ export function planTags(planned, stepFor = () => null) {
198
+ const PAD = 4;
199
+ const placed = [];
200
+ const out = new Map();
201
+ const items = planned
202
+ .map(r => ({ r, ...tagBox(r, stepFor(r)) }))
203
+ .filter(t => {
204
+ const { e } = t.r;
205
+ return e.label || stepFor(t.r) != null;
206
+ })
207
+ .sort((p, q) => (p.at[0] - q.at[0]) || (p.at[1] - q.at[1]));
208
+
209
+ const hits = (a, b) =>
210
+ Math.abs(a.at[0] - b.at[0]) < (a.w + b.w) / 2 + PAD &&
211
+ Math.abs(a.at[1] - b.at[1]) < (a.h + b.h) / 2 + PAD;
212
+
213
+ for (const t of items) {
214
+ // bounded: each pass moves clear of one neighbour, and there are finitely
215
+ // many. The cap stops a pathological spec from spinning here.
216
+ for (let guard = 0; guard < 40; guard++) {
217
+ const clash = placed.find(p => hits(p, t));
218
+ if (!clash) break;
219
+ const dir = t.away || (t.at[1] >= clash.at[1] ? 1 : -1);
220
+ t.at = [t.at[0], clash.at[1] + dir * ((t.h + clash.h) / 2 + PAD)];
221
+ t.away = dir;
222
+ }
223
+ placed.push(t);
224
+ out.set(t.r.key, t.at);
225
+ }
226
+ return out;
227
+ }
228
+
229
+ /* `state` is 'idle' | 'lit' | 'dim' — the board lights one path at a time.
230
+ `place` is the anchor planTags settled on; without one the tag places itself,
231
+ which is right for a lone edge and wrong for a fan. */
232
+ /**
233
+ * @param {any} r
234
+ * @param {{step?: number|null, state?: string, place?: number[]|null}} [opts]
235
+ */
236
+ export function drawEdge(r, { step = null, state = 'idle', place = null } = {}) {
151
237
  const { e, pts } = r;
152
238
  const seed = hashSeed(r.key) % 100000;
153
239
  const lit = state === 'lit', dim = state === 'dim';
@@ -170,21 +256,7 @@ export function drawEdge(r, { step = null, state = 'idle' } = {}) {
170
256
  // a box is far taller than the line is thick. A pair of edges running both
171
257
  // ways between the same two boxes splits above and below, so the two tags
172
258
  // do not then collide with each other.
173
- const label = (step != null ? String(step) + ' ' : '') + (e.label || '');
174
- const need = label.length * 6.5 + 20;
175
- let at = spot.at;
176
- if (need > spot.len * 0.95) {
177
- const { a, b } = r;
178
- if (!spot.vertical) {
179
- at = b.x >= a.x
180
- ? [spot.at[0], Math.min(a.y, b.y) - 13]
181
- : [spot.at[0], Math.max(a.y + a.h, b.y + b.h) + 13];
182
- } else {
183
- at = b.y >= a.y
184
- ? [Math.max(a.x + a.w, b.x + b.w) + need / 2 + 10, spot.at[1]]
185
- : [Math.min(a.x, b.x) - need / 2 - 10, spot.at[1]];
186
- }
187
- }
259
+ const at = place || tagBox(r, step).at;
188
260
  out += `<g class="tag">${edgeTag(at, step, e.label, { lit, seed: seed + 5 })}</g>`;
189
261
  }
190
262
  return out + `</g>`;
@@ -10,7 +10,7 @@
10
10
  import { PAPER, INK, MUTED, ACCENT, PANEL, handFilter } from './theme.js';
11
11
  import { roughRect, stroke, fill, text, hashSeed } from './ink.js';
12
12
  import { drawNode, drawBoundary } from './glyphs.js';
13
- import { drawEdge, planEdges, edgeKey } from './edges.js';
13
+ import { drawEdge, planEdges, planTags, edgeKey } from './edges.js';
14
14
  import { planCallouts, drawCallout } from './callout.js';
15
15
  import { assign, layout, boundaryBox } from './layout.js';
16
16
 
@@ -104,10 +104,14 @@ export function renderSVG(spec, { active, placed = null, margin = 0, hand = true
104
104
  }
105
105
 
106
106
  const planned = planEdges(edges, boxes);
107
+ /** @param {{e: any, key: string}} r */
108
+ const stepFor = r => (r.e.step != null ? r.e.step : (stepOf[r.key] ?? null));
109
+ // every tag resolved against every other before any is drawn, or two edges
110
+ // leaving the same box land their pills on the same spot
111
+ const tags = planTags(planned, stepFor);
107
112
  for (const r of planned) {
108
113
  const state = !focus ? 'idle' : focus.edges.has(r.key) ? 'lit' : 'dim';
109
- const step = r.e.step != null ? r.e.step : (stepOf[r.key] ?? null);
110
- body += drawEdge(r, { step, state });
114
+ body += drawEdge(r, { step: stepFor(r), state, place: tags.get(r.key) || null });
111
115
  }
112
116
 
113
117
  for (const n of nodes) {
package/test/smoke.mjs CHANGED
@@ -656,6 +656,53 @@ test('colour and glyphs stay out of piped output', async () => {
656
656
  assert.ok(short.endsWith('spiega'), 'the end is what names it');
657
657
  });
658
658
 
659
+ /* A node that fans out sends several edges through the same gutter. Each tag
660
+ lifts itself clear of the same box, independently, and lands in the same
661
+ place — and nothing downstream notices, because each pill draws over the
662
+ last. The shipped example did exactly this: "4 CHARGE" sat under "5 INSERT",
663
+ which is the first picture anyone renders. Overlap is the assertion; a
664
+ screenshot is the only other way to see it, and tests do not look. */
665
+ test('edge tags from the same box do not land on top of each other', async () => {
666
+ const { renderSVG } = await import('../src/render/render.js');
667
+
668
+ const tagRects = svg => [...svg.matchAll(/<g class="tag">([\s\S]*?)<\/g>/g)]
669
+ .map(m => {
670
+ const t = /<text[^>]*x="([-\d.]+)"[^>]*y="([-\d.]+)"[^>]*>([\s\S]*?)<\/text>/.exec(m[1]);
671
+ return t && { x: +t[1], y: +t[2], label: t[3], w: t[3].length * 6.51 + 20, h: 19 };
672
+ })
673
+ .filter(Boolean);
674
+
675
+ const overlaps = rs => {
676
+ const bad = [];
677
+ for (let i = 0; i < rs.length; i++)
678
+ for (let j = i + 1; j < rs.length; j++)
679
+ if (Math.abs(rs[i].x - rs[j].x) < (rs[i].w + rs[j].w) / 2 &&
680
+ Math.abs(rs[i].y - rs[j].y) < rs[i].h)
681
+ bad.push(`${rs[i].label} / ${rs[j].label}`);
682
+ return bad;
683
+ };
684
+
685
+ // the spec that ships, and the one that regressed
686
+ const example = JSON.parse(fs.readFileSync(path.join(ROOT, 'examples/order-flow.json'), 'utf8'));
687
+ const shipped = tagRects(renderSVG(example).svg);
688
+ assert.ok(shipped.length >= 7, `expected the example's tags, got ${shipped.length}`);
689
+ assert.deepEqual(overlaps(shipped), [], 'the example renders with its labels legible');
690
+
691
+ // a wider fan than the example has, so the resolver is tested past one clash
692
+ const fan = {
693
+ nodes: [{ id: 'hub', label: 'Hub' },
694
+ ...Array.from({ length: 6 }, (_, i) => ({ id: `n${i}`, label: `Target ${i}` }))],
695
+ edges: Array.from({ length: 6 }, (_, i) =>
696
+ ({ from: 'hub', to: `n${i}`, label: `a fairly long edge label ${i}` })),
697
+ };
698
+ const fanned = tagRects(renderSVG(fan).svg);
699
+ assert.equal(fanned.length, 6, 'every edge still gets its tag');
700
+ assert.deepEqual(overlaps(fanned), [], 'six tags out of one box all stay readable');
701
+
702
+ // and the resolver has to be stable: same spec, same answer
703
+ assert.equal(renderSVG(fan).svg, renderSVG(fan).svg, 'layout does not drift between renders');
704
+ });
705
+
659
706
  test('the herdr plugin declares the actions it ships, and its scripts are runnable', () => {
660
707
  const dir = path.join(ROOT, 'integrations/herdr');
661
708
  const toml = fs.readFileSync(path.join(dir, 'herdr-plugin.toml'), 'utf8');