@liggi/figkit 0.4.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.
package/runtime/fig.js CHANGED
@@ -42,7 +42,7 @@
42
42
  function obstacles() {
43
43
  const out = [];
44
44
  const solid = [...root().querySelectorAll(SOLID)].filter(visible);
45
- for (const el of solid) if (!el.classList.contains('bare')) out.push({ el, r: rectOf(el) });
45
+ for (const el of solid) if (!el.classList.contains('bare') || el.classList.contains('st-bracket')) out.push({ el, r: rectOf(el) });
46
46
  const walker = document.createTreeWalker(root(), NodeFilter.SHOW_TEXT);
47
47
  const seen = new Set();
48
48
  for (let n = walker.nextNode(); n; n = walker.nextNode()) {
@@ -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,15 +378,58 @@
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
- if (text) labels.push({ text, tone, color, pts, at: labelAt, want: tag.getAttribute('label-at'), from, to });
384
+ if (text) labels.push({ text, sub: tag.getAttribute('sub'), tone, color, pts, at: labelAt, want: tag.getAttribute('label-at'), from, to });
374
385
  }
375
386
  return placeLabels(labels, obs);
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,13 +514,17 @@
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');
468
524
  lab.className = 'fig-wire-label';
469
525
  lab.textContent = L.text;
526
+ // Further lines under the label, such as a count: one span each.
527
+ for (const t of (L.sub ?? '').split('\n').filter(Boolean)) { const e = document.createElement('span'); e.className = 'fig-wire-sub'; e.textContent = t; lab.appendChild(e); }
470
528
  if (L.tone !== 'line') lab.style.color = L.color;
471
529
  lab.dataset.arrow = `${L.from} → ${L.to}`;
472
530
  R.appendChild(lab);
@@ -490,9 +548,15 @@
490
548
  for (const seg of segs) {
491
549
  const horiz = seg.a[1] === seg.b[1], mid = [(seg.a[0] + seg.b[0]) / 2, (seg.a[1] + seg.b[1]) / 2];
492
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]);
493
- 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]) {
494
557
  const c = [seg.a[0] + (seg.b[0] - seg.a[0]) * t, seg.a[1] + (seg.b[1] - seg.a[1]) * t];
495
- 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;
496
560
  if (horiz) cands.push({ c: [c[0], c[1] - h / 2 - 7] }, { c: [c[0], c[1] + h / 2 + 7] });
497
561
  else cands.push({ c: [c[0] + w / 2 + 9, c[1]] }, { c: [c[0] - w / 2 - 9, c[1]] });
498
562
  }
@@ -504,11 +568,19 @@
504
568
  const r = rectAt(cand.c);
505
569
  if (r.x < 8 || r.y < 8 || r.x + r.w > W - 8 || r.y + r.h > H - 8) return false;
506
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;
507
- 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;
508
- if (placed.some((p) => overlap(grow(r, 4), p))) return false;
509
- 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);
510
575
  };
511
- 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
+ }
512
584
  lab.style.left = pick.c[0] + 'px';
513
585
  lab.style.top = pick.c[1] + 'px';
514
586
  placed.push(rectAt(pick.c));
@@ -553,10 +625,66 @@
553
625
  });
554
626
 
555
627
  // ---------- checks ----------
628
+ /** The colour as [r, g, b, a] (0–255, alpha 0–1), or null when it is not a plain colour (none, a gradient, a pattern). */
629
+ function rgba(c) {
630
+ const m = /^rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)$/.exec((c || '').trim());
631
+ if (m) return [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]];
632
+ const s = /^color\(srgb ([\d.e-]+) ([\d.e-]+) ([\d.e-]+)(?: \/ ([\d.]+))?\)$/.exec((c || '').trim());
633
+ return s ? [+s[1] * 255, +s[2] * 255, +s[3] * 255, s[4] === undefined ? 1 : +s[4]] : null;
634
+ }
635
+ const lumOf = ([r, g, b]) => [r, g, b].map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }).reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);
636
+ const over = (top, under) => [0, 1, 2].map((i) => top[i] * top[3] + under[i] * (1 - top[3])).concat(1);
637
+ function opacityOf(el) { let o = 1; for (let x = el; x && x !== document.documentElement; x = x.parentElement) o *= parseFloat(getComputedStyle(x).opacity); return o; }
638
+ /** The colour an element paints over its own area, with its opacity, or undefined when it paints nothing there. */
639
+ function paintOf(el) {
640
+ const cs = getComputedStyle(el);
641
+ if (el instanceof SVGElement) {
642
+ if (!/^(rect|path|circle|ellipse|polygon)$/.test(el.tagName)) return undefined;
643
+ if (cs.fill === 'none') return undefined;
644
+ const c = rgba(cs.fill);
645
+ return c ? [c[0], c[1], c[2], c[3] * parseFloat(cs.fillOpacity) * opacityOf(el)] : null;
646
+ }
647
+ if (cs.backgroundImage !== 'none') return null;
648
+ const c = rgba(cs.backgroundColor);
649
+ if (!c) return null;
650
+ return c[3] === 0 ? undefined : [c[0], c[1], c[2], c[3] * opacityOf(el)];
651
+ }
652
+ /** Contrast between a text element and what lies under its middle, as a WCAG ratio; null when it cannot be told. */
653
+ function contrastOf(el) {
654
+ const b = el.getBoundingClientRect();
655
+ if (b.top < 0 || b.bottom > innerHeight || b.left < 0 || b.right > innerWidth) el.scrollIntoView({ block: 'center', inline: 'center' });
656
+ const r = el.tagName === 'text' ? el.getBoundingClientRect() : (() => { const t = textRect(el), o = R0(); return { x: t.x + o.x, y: t.y + o.y, width: t.w, height: t.h }; })();
657
+ const stack = document.elementsFromPoint(r.x + r.width / 2, r.y + r.height / 2);
658
+ let i = stack.findIndex((x) => x === el || x.contains(el));
659
+ if (i < 0) return null;
660
+ if (el.tagName === 'text') i++;
661
+ const layers = [];
662
+ for (; i < stack.length; i++) {
663
+ const p = paintOf(stack[i]);
664
+ if (p === null) return null;
665
+ if (p === undefined) continue;
666
+ layers.push(p);
667
+ if (p[3] >= 0.99) break;
668
+ }
669
+ let bg = [255, 255, 255, 1];
670
+ for (const l of layers.reverse()) bg = over(l, bg);
671
+ const cs = getComputedStyle(el);
672
+ const fg = rgba(el.tagName === 'text' ? cs.fill : cs.color);
673
+ if (!fg) return null;
674
+ const ink = over([fg[0], fg[1], fg[2], fg[3] * opacityOf(el)], bg);
675
+ const la = lumOf(ink), lb = lumOf(bg);
676
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
677
+ }
678
+ const R0 = () => { const b = root().getBoundingClientRect(); return { x: b.x, y: b.y }; };
679
+
556
680
  function check() {
557
681
  const R = root(), P = fig.problems;
558
682
  const W = R.offsetWidth, H = R.offsetHeight;
559
683
  const detail = R.classList.contains('detail');
684
+ // A phone layout is 560px wide (fig.css); opening gaps for labels can widen it past that.
685
+ const phoneW = R.classList.contains('phone') ? 560 : 0;
686
+ const wide = Math.max(W, R.scrollWidth, R.getBoundingClientRect().width);
687
+ if (phoneW && wide > phoneW + 1) P.push(`The figure is ${Math.round(wide)}px wide on a phone, wider than ${phoneW}px; shorten the widest labels.`);
560
688
  const minText = detail ? 13 : 15;
561
689
  const obs = obstacles();
562
690
 
@@ -574,6 +702,15 @@
574
702
  if (hit) P.push(`Chart label covers ${hit === 1 ? 'a dot' : hit + ' dots'}: "${a.e.textContent.trim()}".`);
575
703
  }
576
704
 
705
+ // Text that cannot be read against what is drawn behind it, such as white numbers on a pale cell.
706
+ for (const e of [...R.querySelectorAll('*')].filter((x) => !x.closest('svg') || x.tagName === 'text')) {
707
+ if (e.tagName !== 'text' && ![...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) continue;
708
+ if (!e.textContent.trim() || !visible(e) || e.matches('script, style')) continue;
709
+ const ratio = contrastOf(e);
710
+ if (ratio !== null && ratio < 3) P.push(`Text cannot be read against what is behind it: "${e.textContent.trim().replace(/\s+/g, ' ')}" (contrast ${ratio.toFixed(1)} to 1; it needs 3).`);
711
+ }
712
+ window.scrollTo(0, 0);
713
+
577
714
  // Text: too small, cut off, or past its box.
578
715
  const small = [];
579
716
  for (const o of obs.flatMap((b) => (b.text ? [b.el] : [...b.el.querySelectorAll('*')].filter((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()))))) {
@@ -599,6 +736,15 @@
599
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.`);
600
737
  }
601
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
+
602
748
  // Boxes and labels that collide.
603
749
  for (let i = 0; i < obs.length; i++) for (let j = i + 1; j < obs.length; j++) {
604
750
  const a = obs[i], b = obs[j];
@@ -623,9 +769,26 @@
623
769
  if (hit) P.push(`Arrow ${w.from} → ${w.to} runs through ${name(o.el)}.`);
624
770
  }
625
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
+ }
626
776
  const crossings = new Set(crossingPairs().map(([u, w]) => `${u.from} → ${u.to} and ${w.from} → ${w.to}`));
627
- 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')
628
790
  ? `References cross: ${[...crossings].join('; ')}. Split the records over two figures; do not leave a reference out.`
791
+ : R.querySelector('.states') ? `Moves cross: ${[...crossings].join('; ')}. figkit could not lay them out apart; draw the side branch as a second figure.`
629
792
  : `Arrows cross: ${[...crossings].join('; ')}. Reorder the boxes so they do not, or drop an arrow.`);
630
793
 
631
794
  // Neighbours that almost line up.
@@ -636,6 +799,7 @@
636
799
  if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
637
800
  // Records share a top edge in their row; their heights follow their fields, which figkit placed, not the author.
638
801
  if (a.el.classList.contains('record') && b.el.classList.contains('record')) continue;
802
+ if (a.el.closest('.map') && b.el.closest('.map')) continue;
639
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]];
640
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)`); }
641
805
  }
@@ -647,7 +811,11 @@
647
811
  const outside = R.querySelector('.panels') ? [...R.children].filter((c) => !c.matches('script, style') && !c.querySelector('.panels') && !c.matches('.panels')).map((c) => c.innerText).join(' ') : null;
648
812
  const words = (panelText !== null ? outside + ' ' + panelText : R.innerText).split(/\s+/).filter(Boolean).length;
649
813
  const nodes = R.querySelectorAll('.node:not(.bare), .decision').length, arrows = (fig.wires ?? []).length;
650
- const budget = detail ? { words: 190, nodes: 18, arrows: 18 } : { words: 70, nodes: 5, arrows: 8 };
814
+ // A states picture reads down one path of short names, so it holds up to 7 states (see the states spec).
815
+ const states = !!R.querySelector('.states');
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 };
651
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.`);
652
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.'}`);
653
821
  if (arrows > budget.arrows) P.push(`${arrows} arrows; more than ${budget.arrows} tangles. Show the main path and drop the rest.`);
@@ -683,10 +851,40 @@
683
851
  await Promise.all([...document.images].map((img) => (img.complete ? null : new Promise((res) => { img.onload = img.onerror = res; }))));
684
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'; }
685
853
  const spec = document.getElementById('figkit-spec');
686
- if (spec) window.figkitViz.drawSpec(JSON.parse(spec.textContent), root());
687
- 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;
688
856
  for (const job of fig.queue) job();
689
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
+ }
690
888
  // A label with no free place opens the gap it needs, then everything is drawn again.
691
889
  const tried = new Map();
692
890
  for (let i = 0; ; i++) {