@liggi/figkit 0.5.0 → 0.6.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.
@@ -0,0 +1,269 @@
1
+ {
2
+ "type": "map",
3
+ "title": "Every grocery order passes through the order service before stores pick or vans deliver",
4
+ "subsystems": [
5
+ {
6
+ "id": "shop",
7
+ "name": "Storefront",
8
+ "kind": "web and mobile"
9
+ },
10
+ {
11
+ "id": "orders",
12
+ "name": "Orders",
13
+ "kind": "orders team"
14
+ },
15
+ {
16
+ "id": "stores",
17
+ "name": "Store fulfilment",
18
+ "kind": "stores team"
19
+ },
20
+ {
21
+ "id": "delivery",
22
+ "name": "Delivery",
23
+ "kind": "logistics team"
24
+ },
25
+ {
26
+ "id": "outside",
27
+ "name": "Outside services"
28
+ }
29
+ ],
30
+ "parts": [
31
+ {
32
+ "id": "web",
33
+ "name": "Shop website",
34
+ "kind": "Next.js app",
35
+ "in": "shop"
36
+ },
37
+ {
38
+ "id": "mobile",
39
+ "name": "Shop app",
40
+ "kind": "iOS and Android",
41
+ "in": "shop"
42
+ },
43
+ {
44
+ "id": "gateway",
45
+ "name": "API gateway",
46
+ "kind": "Envoy",
47
+ "in": "shop"
48
+ },
49
+ {
50
+ "id": "search",
51
+ "name": "Product search",
52
+ "kind": "OpenSearch",
53
+ "in": "shop"
54
+ },
55
+ {
56
+ "id": "basket",
57
+ "name": "Basket service",
58
+ "kind": "Go service",
59
+ "in": "shop"
60
+ },
61
+ {
62
+ "id": "order",
63
+ "name": "Order service",
64
+ "kind": "Go service",
65
+ "in": "orders",
66
+ "tone": "accent"
67
+ },
68
+ {
69
+ "id": "orderdb",
70
+ "name": "Orders database",
71
+ "kind": "Postgres",
72
+ "in": "orders"
73
+ },
74
+ {
75
+ "id": "payment",
76
+ "name": "Payment service",
77
+ "kind": "Go service",
78
+ "in": "orders"
79
+ },
80
+ {
81
+ "id": "fraud",
82
+ "name": "Fraud check",
83
+ "kind": "rules engine",
84
+ "in": "orders"
85
+ },
86
+ {
87
+ "id": "events",
88
+ "name": "Order events",
89
+ "kind": "Kafka topic",
90
+ "in": "orders"
91
+ },
92
+ {
93
+ "id": "planner",
94
+ "name": "Picking planner",
95
+ "kind": "nightly batch",
96
+ "in": "stores"
97
+ },
98
+ {
99
+ "id": "stock",
100
+ "name": "Store stock",
101
+ "kind": "per-store counts",
102
+ "in": "stores"
103
+ },
104
+ {
105
+ "id": "picker",
106
+ "name": "Picker app",
107
+ "kind": "handheld scanner",
108
+ "in": "stores"
109
+ },
110
+ {
111
+ "id": "subs",
112
+ "name": "Substitution rules",
113
+ "kind": "YAML in repo",
114
+ "in": "stores"
115
+ },
116
+ {
117
+ "id": "slots",
118
+ "name": "Van slots",
119
+ "kind": "1-hour windows",
120
+ "in": "delivery"
121
+ },
122
+ {
123
+ "id": "routes",
124
+ "name": "Route planner",
125
+ "kind": "OR-Tools job",
126
+ "in": "delivery"
127
+ },
128
+ {
129
+ "id": "driver",
130
+ "name": "Driver app",
131
+ "kind": "Android",
132
+ "in": "delivery"
133
+ },
134
+ {
135
+ "id": "tracking",
136
+ "name": "Live tracking",
137
+ "kind": "WebSocket service",
138
+ "in": "delivery"
139
+ },
140
+ {
141
+ "id": "cards",
142
+ "name": "Card processor",
143
+ "kind": "Stripe",
144
+ "in": "outside"
145
+ },
146
+ {
147
+ "id": "sms",
148
+ "name": "Text messages",
149
+ "kind": "Twilio",
150
+ "in": "outside"
151
+ },
152
+ {
153
+ "id": "maps",
154
+ "name": "Road map data",
155
+ "kind": "Mapbox",
156
+ "in": "outside"
157
+ }
158
+ ],
159
+ "links": [
160
+ {
161
+ "from": "web",
162
+ "to": "gateway",
163
+ "tone": "accent"
164
+ },
165
+ {
166
+ "from": "mobile",
167
+ "to": "gateway",
168
+ "tone": "accent"
169
+ },
170
+ {
171
+ "from": "gateway",
172
+ "to": "search",
173
+ "label": "queries"
174
+ },
175
+ {
176
+ "from": "gateway",
177
+ "to": "basket",
178
+ "tone": "accent"
179
+ },
180
+ {
181
+ "from": "search",
182
+ "to": "stock"
183
+ },
184
+ {
185
+ "from": "basket",
186
+ "to": "slots",
187
+ "label": "books a slot"
188
+ },
189
+ {
190
+ "from": "basket",
191
+ "to": "order",
192
+ "label": "checkout",
193
+ "tone": "accent"
194
+ },
195
+ {
196
+ "from": "order",
197
+ "to": "payment",
198
+ "label": "charge"
199
+ },
200
+ {
201
+ "from": "payment",
202
+ "to": "fraud"
203
+ },
204
+ {
205
+ "from": "payment",
206
+ "to": "cards"
207
+ },
208
+ {
209
+ "from": "order",
210
+ "to": "orderdb",
211
+ "label": "writes"
212
+ },
213
+ {
214
+ "from": "order",
215
+ "to": "events",
216
+ "label": "publishes",
217
+ "tone": "accent"
218
+ },
219
+ {
220
+ "from": "events",
221
+ "to": "planner",
222
+ "tone": "accent",
223
+ "label": "new order"
224
+ },
225
+ {
226
+ "from": "events",
227
+ "to": "routes",
228
+ "tone": "accent",
229
+ "label": "new order"
230
+ },
231
+ {
232
+ "from": "planner",
233
+ "to": "picker",
234
+ "label": "pick lists"
235
+ },
236
+ {
237
+ "from": "picker",
238
+ "to": "stock",
239
+ "label": "takes off stock"
240
+ },
241
+ {
242
+ "from": "picker",
243
+ "to": "subs"
244
+ },
245
+ {
246
+ "from": "routes",
247
+ "to": "maps"
248
+ },
249
+ {
250
+ "from": "routes",
251
+ "to": "driver",
252
+ "label": "route stops"
253
+ },
254
+ {
255
+ "from": "driver",
256
+ "to": "tracking",
257
+ "label": "location"
258
+ },
259
+ {
260
+ "from": "tracking",
261
+ "to": "sms",
262
+ "label": "ETA texts"
263
+ },
264
+ {
265
+ "from": "slots",
266
+ "to": "routes"
267
+ }
268
+ ]
269
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liggi/figkit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Explanatory figures for agents and people: write a little HTML or a chart spec, get a laid-out PNG and a list of what is wrong with it.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,6 +34,7 @@
34
34
  "agents"
35
35
  ],
36
36
  "dependencies": {
37
+ "@dagrejs/dagre": "^3.1.3",
37
38
  "@fontsource/geist-mono": "^5.3.0",
38
39
  "@fontsource/geist-sans": "^5.3.0",
39
40
  "@fontsource/newsreader": "^5.3.0",
package/runtime/fig.css CHANGED
@@ -313,3 +313,20 @@ fig-bars.has-ref .fb-range.accent { --tone: var(--ink); }
313
313
  /* the bracket beside the states that share one move, which leads to it with one arrow */
314
314
  .fig .node.bare.st-bracket { justify-self: end; width: 14px; margin-right: -30px; padding: 0; border: 1.5px solid var(--line); border-left: 0; border-radius: 0 4px 4px 0; background: none; }
315
315
  .fig.phone .node.bare.st-bracket { margin-right: -24px; width: 10px; }
316
+
317
+ /* ---------- map: a whole system, parts in subsystem regions (a spec of type "map"), laid out by dagre and packed by figkit ---------- */
318
+ .fig .map { position: relative; }
319
+ .fig .map > .map-part, .fig .map-region > .map-part { position: absolute; max-width: 260px; min-width: 120px; padding: 10px 14px; gap: 2px; }
320
+ .fig .map-part > h3 { font-size: var(--body); line-height: 1.2; }
321
+ .fig .map-part > p { font-size: var(--small); white-space: nowrap; }
322
+ .fig .map-region { position: absolute; display: block; padding: 0; border: 0; border-radius: 14px; background: color-mix(in srgb, var(--ink) 4%, var(--paper)); box-shadow: inset 0 0 0 1px var(--hair); }
323
+ .fig .map-region > .mp-head { position: absolute; top: 14px; left: 20px; display: flex; flex-direction: column; gap: 2px; white-space: nowrap; }
324
+ .fig .map-region > .mp-head > h2 { font-family: var(--serif); font-size: calc(var(--h) * 1.35); font-weight: 400; line-height: 1.1; color: var(--ink); }
325
+ .fig .map > svg.map-wires { position: absolute; inset: 0; pointer-events: none; overflow: visible; }
326
+ .fig .map .map-label { padding: 0 4px; line-height: 1.25; }
327
+ .fig .map-overview > .map-sub { max-width: 230px; min-width: 150px; }
328
+ .fig .map-sub > ul { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
329
+ .fig .map-sub > ul li { font-size: var(--small); color: var(--muted); }
330
+ .fig .map-sub > .mp-count { color: var(--soft); }
331
+ /* A map's link label outlined, so a line visibly runs into it rather than stopping short of a floating word. */
332
+ .fig:has(.map) .fig-wire-label { box-shadow: inset 0 0 0 1px var(--hair); }
package/runtime/fig.js CHANGED
@@ -107,6 +107,11 @@
107
107
  function routeOrtho(A, B, obs, arrow, used, W, H) {
108
108
  const hard = obs.map((o) => grow(o.r, 3)), soft = obs.map((o) => grow(o.r, M - 1));
109
109
  const others = obs.filter((o) => o.el !== A.el && o.el !== B.el).map((o) => grow(o.r, 3));
110
+ // On a map, a line running along a region's border reads as part of the border, so a step beside one costs more.
111
+ const borders = [...root().querySelectorAll('.map-region')].map(rectOf);
112
+ const hugs = (x1, y1, x2, y2) => borders.some((r) => (y1 === y2
113
+ ? Math.min(Math.abs(y1 - r.y), Math.abs(y1 - r.y - r.h)) < 16 && Math.max(x1, x2) > r.x && Math.min(x1, x2) < r.x + r.w
114
+ : Math.min(Math.abs(x1 - r.x), Math.abs(x1 - r.x - r.w)) < 16 && Math.max(y1, y2) > r.y && Math.min(y1, y2) < r.y + r.h));
110
115
  const clear = (x1, y1, x2, y2) => {
111
116
  const seg = { x: Math.min(x1, x2) - 0.5, y: Math.min(y1, y2) - 0.5, w: Math.abs(x2 - x1) + 1, h: Math.abs(y2 - y1) + 1 };
112
117
  return !others.some((r) => Math.min(seg.x + seg.w, r.x + r.w) > Math.max(seg.x, r.x) && Math.min(seg.y + seg.h, r.y + r.h) > Math.max(seg.y, r.y));
@@ -144,7 +149,8 @@
144
149
  }
145
150
  add(xs, 14, 'edge'); add(xs, W - 14, 'edge'); add(ys, 14, 'edge'); add(ys, H - 14, 'edge');
146
151
  const pad = parseFloat(getComputedStyle(root()).paddingLeft) || 0;
147
- if (pad > 14) { add(xs, pad, 'mid'); add(xs, W - pad, 'mid'); }
152
+ // A map's regions start at the padding, so a line there would run along their edges.
153
+ if (pad > 14 && !root().querySelector('.map')) { add(xs, pad, 'mid'); add(xs, W - pad, 'mid'); }
148
154
  for (const p of [...pa, ...pb]) { add(xs, p.sx, 'mid'); add(ys, p.sy, 'mid'); add(xs, p.x, 'mid'); add(ys, p.y, 'mid'); }
149
155
  const X = [...xs.keys()].sort((a, b) => a - b), Y = [...ys.keys()].sort((a, b) => a - b);
150
156
  const NX = X.length, NY = Y.length;
@@ -156,6 +162,8 @@
156
162
  for (const r of soft) if (inside(r, x, y)) return 5;
157
163
  return 1;
158
164
  };
165
+ // On a map, lines run a long way side by side between regions; closer than 24px apart they cannot be told apart.
166
+ const near = borders.length ? 24 : 10;
159
167
  const usedCost = (x1, y1, x2, y2) => {
160
168
  let k = 1, plus = 0;
161
169
  const horiz = y1 === y2;
@@ -163,17 +171,19 @@
163
171
  const uh = u.y1 === u.y2;
164
172
  if (uh === horiz) {
165
173
  const off = horiz ? Math.abs(u.y1 - y1) : Math.abs(u.x1 - x1);
166
- if (off >= 10) continue;
174
+ if (off >= near) continue;
167
175
  const lo = horiz ? Math.max(Math.min(x1, x2), Math.min(u.x1, u.x2)) : Math.max(Math.min(y1, y2), Math.min(u.y1, u.y2));
168
176
  const hi = horiz ? Math.min(Math.max(x1, x2), Math.max(u.x1, u.x2)) : Math.min(Math.max(y1, y2), Math.max(u.y1, u.y2));
169
- if (hi - lo <= 0.5) { if (arrow.ref && off < 0.5 && hi - lo > -160 && !bus(u)) plus += 400; continue; }
170
- if (off < 0.5 && bus(u)) k = Math.min(k, 0.85); else k = Math.max(k, arrow.ref ? 12 : 4);
177
+ // Carrying straight on where another line stops reads as that line going on; for the highlighted path, as the path going somewhere it doesn't.
178
+ if (hi - lo <= 0.5) { if ((arrow.ref || (borders.length && u.tone === 'accent' && arrow.tone !== 'accent')) && off < 0.5 && hi - lo > -160 && !bus(u)) plus += 400; continue; }
179
+ if (off < 0.5 && bus(u)) k = Math.min(k, 0.85); else k = Math.max(k, arrow.ref || (borders.length && u.tone === 'accent' && arrow.tone !== 'accent') ? 12 : 4);
171
180
  } else if (!bus(u)) {
172
181
  const cx = horiz ? u.x1 : x1, cy = horiz ? y1 : u.y1;
173
182
  // Strictly inside this step, or at its far end: two lines that meet on a grid point cross there too.
174
183
  const onMe = horiz ? (cx > Math.min(x1, x2) && cx < Math.max(x1, x2)) || cx === x2 : (cy > Math.min(y1, y2) && cy < Math.max(y1, y2)) || cy === y2;
175
184
  const onU = horiz ? cy > Math.min(u.y1, u.y2) && cy < Math.max(u.y1, u.y2) : cx > Math.min(u.x1, u.x2) && cx < Math.max(u.x1, u.x2);
176
- if (onMe && onU) plus += arrow.ref ? 1500 : 70;
185
+ // On a map, a grey line crossing the highlighted path breaks the run the reader follows, so it goes round.
186
+ if (onMe && onU) plus += arrow.ref ? 1500 : borders.length && u.tone === 'accent' && arrow.tone !== 'accent' ? 900 : 70;
177
187
  }
178
188
  }
179
189
  return [k, plus];
@@ -210,8 +220,9 @@
210
220
  const jx = ix + VEC[nd][0], jy = iy + VEC[nd][1];
211
221
  if (jx < 0 || jy < 0 || jx >= NX || jy >= NY) continue;
212
222
  const x1 = X[ix], y1 = Y[iy], x2 = X[jx], y2 = Y[jy];
213
- const m = mult((x1 + x2) / 2, (y1 + y2) / 2);
223
+ let m = mult((x1 + x2) / 2, (y1 + y2) / 2);
214
224
  if (m === Infinity) continue;
225
+ if (borders.length && hugs(x1, y1, x2, y2)) m *= 3;
215
226
  const [k, plus] = usedCost(x1, y1, x2, y2);
216
227
  const len = Math.abs(x2 - x1) + Math.abs(y2 - y1);
217
228
  const cost = c + len * m * k * (VEC[nd][0] === 0 ? kx[ix] : ky[iy]) + plus + (nd === d ? 0 : BEND);
@@ -367,7 +378,7 @@
367
378
  const attrs = { d: pathD, fill: 'none', stroke: color, 'stroke-width': weight, 'stroke-linecap': 'round' };
368
379
  if (dash) attrs['stroke-dasharray'] = dash;
369
380
  svgEl('path', attrs, svg);
370
- fig.wires.push({ from, to, pts, mode, ref });
381
+ fig.wires.push({ from, to, pts, mode, ref, tone });
371
382
 
372
383
  const text = ref ? '' : tag.getAttribute('label') ?? tag.textContent.trim();
373
384
  if (text) labels.push({ text, sub: tag.getAttribute('sub'), tone, color, pts, at: labelAt, want: tag.getAttribute('label-at'), from, to });
@@ -376,6 +387,49 @@
376
387
  }
377
388
 
378
389
  /** Pairs of drawn lines that cross. Arrows from one box, or into one box, share a trunk, so meeting is not crossing. */
390
+ /** Pairs of lines that run along each other on the same track, so the eye cannot tell which goes where. */
391
+ function overlapPairs() {
392
+ const segs = (fig.wires ?? []).filter((w) => w.mode !== 'curve').flatMap((w) => w.pts.slice(1).map((b, i) => ({ w, a: w.pts[i], b })));
393
+ const out = [];
394
+ for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) {
395
+ const s = segs[i], t = segs[j];
396
+ if (s.w === t.w || s.w.from === t.w.from || s.w.to === t.w.to) continue;
397
+ const sh = s.a[1] === s.b[1], th = t.a[1] === t.b[1];
398
+ if (sh !== th) continue;
399
+ const k = sh ? 1 : 0, q = 1 - k;
400
+ if (Math.abs(s.a[k] - t.a[k]) >= 3) continue;
401
+ const lo = Math.max(Math.min(s.a[q], s.b[q]), Math.min(t.a[q], t.b[q])), hi = Math.min(Math.max(s.a[q], s.b[q]), Math.max(t.a[q], t.b[q]));
402
+ if (hi - lo > 4 && !out.some(([u, w]) => u === s.w && w === t.w)) out.push([s.w, t.w]);
403
+ }
404
+ return out;
405
+ }
406
+
407
+ /**
408
+ * On a map, how the highlighted path reads: grey links crossing it, and each run along it (from a highlighted link
409
+ * no other highlighted link feeds, to one that feeds none) that doubles back on itself, left then right or down then up.
410
+ */
411
+ function accentStats() {
412
+ const ws = (fig.wires ?? []).filter((w) => w.mode !== 'curve'), hot = ws.filter((w) => w.tone === 'accent');
413
+ const crossed = crossingPairs().filter(([u, w]) => (u.tone === 'accent') !== (w.tone === 'accent'));
414
+ const runs = [];
415
+ const walk = (w, path) => {
416
+ const next = hot.filter((n) => n.from === w.to && !path.includes(n));
417
+ if (!next.length) runs.push([...path, w]);
418
+ for (const n of next) walk(n, [...path, w]);
419
+ };
420
+ for (const w of hot) if (!hot.some((u) => u.to === w.from)) walk(w, []);
421
+ const back = runs.filter((run) => {
422
+ const seen = new Set();
423
+ for (const w of run) for (let i = 1; i < w.pts.length; i++) {
424
+ const dx = w.pts[i][0] - w.pts[i - 1][0], dy = w.pts[i][1] - w.pts[i - 1][1];
425
+ if (Math.abs(dx) >= 40) seen.add(dx > 0 ? 'r' : 'l');
426
+ if (Math.abs(dy) >= 40) seen.add(dy > 0 ? 'd' : 'u');
427
+ }
428
+ return (seen.has('r') && seen.has('l')) || (seen.has('d') && seen.has('u'));
429
+ });
430
+ return { crossed, back, runs, any: hot.length > 0 };
431
+ }
432
+
379
433
  function crossingPairs() {
380
434
  const segs = (fig.wires ?? []).filter((w) => w.mode !== 'curve').flatMap((w) => w.pts.slice(1).map((b, i) => ({ w, a: w.pts[i], b })));
381
435
  const out = [];
@@ -460,8 +514,10 @@
460
514
  function placeLabels(labels, obs) {
461
515
  const R = root(), W = R.offsetWidth, H = R.offsetHeight;
462
516
  const placed = [], fails = [];
517
+ const onLine = !!R.querySelector('.map-region');
518
+ const heads = [...R.querySelectorAll('.mp-head')].map(rectOf);
463
519
  const segsOf = (pts) => pts.slice(1).map((b, i) => ({ a: pts[i], b, len: Math.hypot(b[0] - pts[i][0], b[1] - pts[i][1]) }));
464
- const wireHit = (r, skip) => (fig.wires ?? []).some((w) => segsOf(w.pts).some((s) => s !== skip && !(skip && s.a === skip.a && s.b === skip.b)
520
+ const wireHit = (r, skip, own) => (fig.wires ?? []).some((w) => w.pts !== own && segsOf(w.pts).some((s) => s !== skip && !(skip && s.a === skip.a && s.b === skip.b)
465
521
  && Math.min(Math.max(s.a[0], s.b[0]), r.x + r.w) >= Math.max(Math.min(s.a[0], s.b[0]), r.x) && Math.min(Math.max(s.a[1], s.b[1]), r.y + r.h) >= Math.max(Math.min(s.a[1], s.b[1]), r.y)));
466
522
  for (const L of labels) {
467
523
  const lab = document.createElement('div');
@@ -492,9 +548,15 @@
492
548
  for (const seg of segs) {
493
549
  const horiz = seg.a[1] === seg.b[1], mid = [(seg.a[0] + seg.b[0]) / 2, (seg.a[1] + seg.b[1]) / 2];
494
550
  const real = segsOf(mine ? mine.pts : L.pts).find((s) => s.a[0] === seg.a[0] && s.a[1] === seg.a[1] && s.b[0] === seg.b[0] && s.b[1] === seg.b[1]);
495
- for (const t of [0.5, 0.3, 0.7]) {
551
+ // On a map, many lines run side by side between regions, so a label beside a line reads as naming its
552
+ // neighbour: there a label sits only on its own line, moved along it until it is clear.
553
+ // Clear of the line's ends: a little at a bend or the start, more before the arrowhead on the last segment.
554
+ const half = (horiz ? w : h) / 2, lastSeg = seg.b === L.pts[L.pts.length - 1], lo = half + 8, hi = seg.len - half - (lastSeg ? 26 : 8), slide = [];
555
+ if (onLine && hi >= lo) for (let d = 0, mid = (lo + hi) / 2; d <= (hi - lo) / 2; d += 4) slide.push((mid - d) / seg.len, (mid + d) / seg.len);
556
+ for (const t of onLine ? slide : [0.5, 0.3, 0.7]) {
496
557
  const c = [seg.a[0] + (seg.b[0] - seg.a[0]) * t, seg.a[1] + (seg.b[1] - seg.a[1]) * t];
497
- if (seg.len >= (horiz ? w : h) + 36) cands.push({ c, on: real });
558
+ if (onLine || seg.len >= (horiz ? w : h) + 36) cands.push({ c, on: real });
559
+ if (onLine) continue;
498
560
  if (horiz) cands.push({ c: [c[0], c[1] - h / 2 - 7] }, { c: [c[0], c[1] + h / 2 + 7] });
499
561
  else cands.push({ c: [c[0] + w / 2 + 9, c[1]] }, { c: [c[0] - w / 2 - 9, c[1]] });
500
562
  }
@@ -506,11 +568,19 @@
506
568
  const r = rectAt(cand.c);
507
569
  if (r.x < 8 || r.y < 8 || r.x + r.w > W - 8 || r.y + r.h > H - 8) return false;
508
570
  const within = (g) => r.x > g.x + 4 && r.y > g.y + 4 && r.x + r.w < g.x + g.w - 4 && r.y + r.h < g.y + g.h - 4;
509
- if (obs.some((o) => (o.el.classList.contains('group') ? overlap(grow(r, 3), o.r) && !within(o.r) : hits({ r: grow(r, 3) }, o)))) return false;
510
- if (placed.some((p) => overlap(grow(r, 4), p))) return false;
511
- return !wireHit(grow(r, 2), cand.on);
571
+ // A map's region is a tinted area: a label sits wholly inside it or wholly outside, never across its edge.
572
+ if (obs.some((o) => (o.el.classList.contains('map-region') ? overlap(grow(r, 8), o.r) && !(r.x > o.r.x + 12 && r.y > o.r.y + 12 && r.x + r.w < o.r.x + o.r.w - 12 && r.y + r.h < o.r.y + o.r.h - 12) : o.el.classList.contains('group') ? overlap(grow(r, 3), o.r) && !within(o.r) : hits({ r: grow(r, 3) }, o)))) return false;
573
+ if (placed.some((p) => overlap(grow(r, 4), p)) || heads.some((g) => overlap(grow(r, 8), g))) return false;
574
+ return onLine ? !wireHit(grow(r, 8), null, L.pts) : !wireHit(grow(r, 2), cand.on);
512
575
  };
513
- const pick = cands.find(free) ?? (fails.push({ from: L.from, to: L.to, w, h }), cands[0]);
576
+ let pick = cands.find(free);
577
+ if (!pick) {
578
+ fails.push({ from: L.from, to: L.to, w, h });
579
+ lab.dataset.stuck = '1';
580
+ // With no clear place, the label still goes on its own line, where check() reports it.
581
+ const long = segsOf(L.pts).sort((p, q) => q.len - p.len)[0];
582
+ pick = cands[0] ?? { c: [(long.a[0] + long.b[0]) / 2, (long.a[1] + long.b[1]) / 2] };
583
+ }
514
584
  lab.style.left = pick.c[0] + 'px';
515
585
  lab.style.top = pick.c[1] + 'px';
516
586
  placed.push(rectAt(pick.c));
@@ -666,6 +736,15 @@
666
736
  if (reach > 1.02) P.push(`The text of decision ${name(d)} does not fit inside its diamond: shorten it or give the diamond a min-width.`);
667
737
  }
668
738
 
739
+ // On a map, a label names the line it sits on: its centre must lie on a segment of its own arrow, clear of the rest.
740
+ if (R.querySelector('.map-region')) for (const lab of R.querySelectorAll('.fig-wire-label')) {
741
+ const [from, to] = lab.dataset.arrow.split(' → '), w = (fig.wires ?? []).find((x) => x.from === from && x.to === to);
742
+ const r = rectOf(lab), cx = r.x + r.w / 2, cy = r.y + r.h / 2;
743
+ const on = w && w.pts.slice(1).some((b, i) => { const a = w.pts[i]; return Math.abs(a[0] - b[0]) < 0.5 ? Math.abs(cx - a[0]) <= 3 && cy >= Math.min(a[1], b[1]) && cy <= Math.max(a[1], b[1]) : Math.abs(cy - a[1]) <= 3 && cx >= Math.min(a[0], b[0]) && cx <= Math.max(a[0], b[0]); });
744
+ if (!on) P.push(`The label "${lab.firstChild.textContent}" of arrow ${lab.dataset.arrow} is not on its own line.`);
745
+ else if (lab.dataset.stuck) P.push(`The label "${lab.firstChild.textContent}" of arrow ${lab.dataset.arrow} has no clear place on its own line: other lines or boxes cover every spot. Drop the label, or shorten it.`);
746
+ }
747
+
669
748
  // Boxes and labels that collide.
670
749
  for (let i = 0; i < obs.length; i++) for (let j = i + 1; j < obs.length; j++) {
671
750
  const a = obs[i], b = obs[j];
@@ -690,8 +769,24 @@
690
769
  if (hit) P.push(`Arrow ${w.from} → ${w.to} runs through ${name(o.el)}.`);
691
770
  }
692
771
  }
772
+ if (R.querySelector('.map')) {
773
+ const along = overlapPairs().map(([u, w]) => `${u.from} → ${u.to} and ${w.from} → ${w.to}`);
774
+ if (along.length) P.push(`Links run on top of each other: ${along.join('; ')}. figkit found no arrangement that keeps them apart; leave out a link the question does not need.`);
775
+ }
693
776
  const crossings = new Set(crossingPairs().map(([u, w]) => `${u.from} → ${u.to} and ${w.from} → ${w.to}`));
694
- if (crossings.size) P.push(R.querySelector('.entities')
777
+ // A map of many links crosses some at right angles, which still reads full screen; up to one crossing per 2 links is
778
+ // allowed and named. Real systems of 24 parts and 30 links cross about 10 times; past 30 parts, 28 times or more.
779
+ const allowed = R.querySelector('.map') ? Math.floor((fig.wires ?? []).length / 2) : 0;
780
+ if (R.querySelector('.map')) {
781
+ // The highlighted path is what the map is about: it reads as one run, and grey links crossing it are named.
782
+ const st = accentStats(), arrow = (w) => `${w.from} → ${w.to}`;
783
+ if (st.back.length) P.push(`The highlighted path turns back on itself: ${st.back.map((run) => [run[0].from, ...run.map((w) => w.to)].join(' → ')).join('; ')}. figkit could not lay it out as one run; highlight only the links the question follows, in one direction.`);
784
+ const grey = [...new Set(st.crossed.map(([u, w]) => (u.tone === 'accent' ? arrow(w) : arrow(u))))];
785
+ if (grey.length) fig.notes = [...(fig.notes ?? []), `${grey.length} grey link${grey.length === 1 ? ' crosses' : 's cross'} the highlighted path: ${grey.join('; ')}.`];
786
+ }
787
+ if (crossings.size && crossings.size <= allowed) fig.notes = [...(fig.notes ?? []), `${crossings.size} crossing${crossings.size === 1 ? '' : 's'} of links (a map of ${(fig.wires ?? []).length} links is allowed ${allowed}): ${[...crossings].join('; ')}.`];
788
+ else if (crossings.size && R.querySelector('.map')) P.push(`Links cross ${crossings.size} times; a map of ${(fig.wires ?? []).length} links holds at most ${allowed}: ${[...crossings].join('; ')}. Leave out links the question does not need, or move a part to the subsystem it talks to most.`);
789
+ else if (crossings.size) P.push(R.querySelector('.entities')
695
790
  ? `References cross: ${[...crossings].join('; ')}. Split the records over two figures; do not leave a reference out.`
696
791
  : R.querySelector('.states') ? `Moves cross: ${[...crossings].join('; ')}. figkit could not lay them out apart; draw the side branch as a second figure.`
697
792
  : `Arrows cross: ${[...crossings].join('; ')}. Reorder the boxes so they do not, or drop an arrow.`);
@@ -704,6 +799,7 @@
704
799
  if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
705
800
  // Records share a top edge in their row; their heights follow their fields, which figkit placed, not the author.
706
801
  if (a.el.classList.contains('record') && b.el.classList.contains('record')) continue;
802
+ if (a.el.closest('.map') && b.el.closest('.map')) continue;
707
803
  const edges = [['left edges', a.r.x, b.r.x], ['right edges', a.r.x + a.r.w, b.r.x + b.r.w], ['top edges', a.r.y, b.r.y], ['bottom edges', a.r.y + a.r.h, b.r.y + b.r.h]];
708
804
  for (const [what, u, v] of edges) { const d = Math.abs(u - v); if (d > 0.75 && d <= 6) near.push(`${name(a.el)} and ${name(b.el)} (${what}, ${d.toFixed(0)}px apart)`); }
709
805
  }
@@ -717,7 +813,9 @@
717
813
  const nodes = R.querySelectorAll('.node:not(.bare), .decision').length, arrows = (fig.wires ?? []).length;
718
814
  // A states picture reads down one path of short names, so it holds up to 7 states (see the states spec).
719
815
  const states = !!R.querySelector('.states');
720
- const budget = detail ? { words: 190, nodes: 18, arrows: 18 } : states ? { words: 90, nodes: 7, arrows: 12 } : { words: 70, nodes: 5, arrows: 8 };
816
+ // A map is many parts laid out by figkit, so it holds what the map spec allows (map-spec.js), not a detail figure's 18.
817
+ const map = !!R.querySelector('.map');
818
+ const budget = map ? { words: 320, nodes: 24, arrows: 30 } : detail ? { words: 190, nodes: 18, arrows: 18 } : states ? { words: 90, nodes: 7, arrows: 12 } : { words: 70, nodes: 5, arrows: 8 };
721
819
  if (words > budget.words) P.push(`${words} words; a ${detail ? 'detail' : 'glance'} figure holds about ${budget.words}. Cut sentences that explain, and keep labels.`);
722
820
  if (nodes > budget.nodes) P.push(`${nodes} boxes; more than ${budget.nodes} stops reading as one picture. ${detail ? 'Split it into two figures or merge boxes.' : 'Split it into two or three figures in a row (what exists, one case through it, what changes), or merge boxes.'}`);
723
821
  if (arrows > budget.arrows) P.push(`${arrows} arrows; more than ${budget.arrows} tangles. Show the main path and drop the rest.`);
@@ -753,10 +851,40 @@
753
851
  await Promise.all([...document.images].map((img) => (img.complete ? null : new Promise((res) => { img.onload = img.onerror = res; }))));
754
852
  for (const d of root().querySelectorAll('.decision')) { const h = Math.max(...[...d.children].map((k) => k.offsetHeight), 20); d.style.paddingBlock = Math.max(30, Math.round(h * 0.7)) + 'px'; }
755
853
  const spec = document.getElementById('figkit-spec');
756
- if (spec) window.figkitViz.drawSpec(JSON.parse(spec.textContent), root());
757
- else { buildSplits(); buildBars(); }
854
+ const drawn = spec ? window.figkitViz.drawSpec(JSON.parse(spec.textContent), root()) : (buildSplits(), buildBars(), null);
855
+ if (drawn?.ready) await drawn.ready;
758
856
  for (const job of fig.queue) job();
759
857
  await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
858
+ // A map offers a few arrangements of its regions: each is routed, and the one whose arrows cross least is kept.
859
+ if (fig.mapTry?.length > 1) {
860
+ let best = null;
861
+ for (const [i, apply] of fig.mapTry.entries()) {
862
+ apply();
863
+ await new Promise((r) => requestAnimationFrame(r));
864
+ const before = fig.problems.length;
865
+ const fails = drawArrows().length, crossings = crossingPairs().length + 2 * overlapPairs().length, lost = fig.problems.length - before;
866
+ // The checks themselves, so an arrangement whose arrows run through labels or boxes loses.
867
+ const notes = fig.notes;
868
+ check();
869
+ const flagged = fig.problems.length - before - lost;
870
+ fig.problems.length = before;
871
+ fig.notes = notes;
872
+ // The highlighted path should read as one run: grey links crossing it, and a run that doubles back, cost most.
873
+ const st = accentStats();
874
+ // And, as when the regions were packed, a map read larger on a full screen is better.
875
+ // With a highlighted path the arrangements differ a lot in size: one read larger on a full screen is better.
876
+ const read = st.any ? Math.min(1440 / Math.max(root().offsetWidth, 960), 860 / root().offsetHeight) : 1;
877
+ // Those arrangements are also built for the path rather than packed, so they vary more in what the checks refuse,
878
+ // and a refused figure is worse than any crossing: there, each problem counts double.
879
+ const k = st.any ? 2 : 1;
880
+ // And long detours, which those arrangements make more of, are lines the reader must trace further.
881
+ const far = st.any ? (fig.wires ?? []).reduce((t, w) => t + w.pts.slice(1).reduce((u, b, j) => u + Math.abs(b[0] - w.pts[j][0]) + Math.abs(b[1] - w.pts[j][1]), 0), 0) / 1000 : 0;
882
+ const score = crossings + k * (2 * fails + 3 * lost + 4 * flagged) + 3 * st.crossed.length + 8 * st.back.length - 8 * Math.log(read) + far + i * 0.1;
883
+ if (!best || score < best.score) best = { score, apply };
884
+ }
885
+ best.apply();
886
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
887
+ }
760
888
  // A label with no free place opens the gap it needs, then everything is drawn again.
761
889
  const tried = new Map();
762
890
  for (let i = 0; ; i++) {
package/viz/draw.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import * as Plot from '@observablehq/plot';
3
3
  import { drawSequence } from './sequence.js';
4
4
  import { drawStates } from './states.js';
5
+ import { drawMap } from './map.js';
5
6
  const NS = 'http://www.w3.org/2000/svg';
6
7
  const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
7
8
 
@@ -363,6 +364,18 @@ export function drawSpec(spec, main) {
363
364
  if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
364
365
  return { chart, draw: () => {} };
365
366
  }
367
+ if (spec.type === 'map') {
368
+ if (main.classList.contains('phone')) {
369
+ const key = document.createElement('p');
370
+ key.className = 'read mp-key';
371
+ key.textContent = 'Each box is a subsystem; numbers count the links between their parts.';
372
+ header.appendChild(key);
373
+ }
374
+ main.append(header);
375
+ const footer = spec.source ? Object.assign(document.createElement('footer'), { innerHTML: `<p>${esc(spec.source)}</p>` }) : null;
376
+ const ready = drawMap(spec, main).then((chart) => { if (footer) main.append(footer); return chart; });
377
+ return { chart: null, draw: () => {}, ready };
378
+ }
366
379
  if (spec.type === 'sequence') {
367
380
  main.append(header);
368
381
  const chart = drawSequence(spec, main);