@liggi/figkit 0.3.0 → 0.5.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,6 @@
1
+ { "type": "bars", "style": "report", "title": "The new reply check catches more, but the ranges still overlap",
2
+ "note": "Share of unsafe replies caught, on the same test set", "unit": "%", "max": 100,
3
+ "range": "likely range (95%)", "each": "replies",
4
+ "bars": [ { "label": "Current check", "value": 64, "low": 53, "high": 74, "n": 72 },
5
+ { "label": "New check", "value": 78, "low": 67, "high": 86, "n": 72, "tone": "accent" } ],
6
+ "source": "Invented example data" }
@@ -0,0 +1,6 @@
1
+ { "type": "bars", "title": "Two of the four new layouts beat the current page",
2
+ "note": "Share of visitors who finished checkout", "unit": "%",
3
+ "ref": { "label": "Current page", "value": 61 },
4
+ "bars": [ { "label": "One-page form", "value": 68 }, { "label": "Saved cards first", "value": 64 },
5
+ { "label": "Address lookup", "value": 60 }, { "label": "Guest by default", "value": 57 } ],
6
+ "source": "Invented data, for illustration." }
@@ -0,0 +1,6 @@
1
+ { "type": "dots", "style": "report", "title": "Most builds finish in four minutes; a few take over ten",
2
+ "note": "One dot per build: minutes from push to result", "unit": " min", "summary": "median", "zero": true, "each": "builds",
3
+ "groups": [ { "label": "Main branch", "values": [3.8,4.1,3.6,4.4,3.9,4.0,4.2,3.7,4.6,3.9,4.3,11.8,4.0,3.5,4.1,4.8,3.9,4.2,12.6,4.0,3.8,4.4,4.1,3.7,4.5,3.9,10.4,4.2] },
4
+ { "label": "Pull requests", "values": [4.9,5.2,4.6,5.8,5.1,4.8,6.2,5.0,5.5,4.7,5.3,5.9,5.0,4.9,6.4,5.2,5.6,4.8,5.1,5.4], "tone": "accent" } ],
5
+ "mark": { "group": "Main branch", "value": 12.6, "label": "12.6 min, cache miss" },
6
+ "source": "Invented example data" }
@@ -0,0 +1,7 @@
1
+ { "type": "estimates", "title": "Free delivery lifts orders in two regions; the north's range includes zero",
2
+ "note": "Change in orders per 100 visitors, free delivery minus paid", "range": "95% interval",
3
+ "sides": { "below": "Paid better", "above": "Free better" },
4
+ "rows": [ { "label": "South", "value": 2.4, "low": 1.1, "high": 3.7 },
5
+ { "label": "Midlands", "value": 1.8, "low": 0.6, "high": 3.0 },
6
+ { "label": "North", "value": 0.7, "low": -0.9, "high": 2.3 } ],
7
+ "source": "Invented data, for illustration." }
@@ -0,0 +1,5 @@
1
+ { "type": "heatmap", "title": "The new menu helped every region except the north",
2
+ "note": "Change in weekly orders per store after the new menu", "unit": "%",
3
+ "rows": ["South", "Midlands", "North"], "columns": ["Wk 1", "Wk 2", "Wk 3", "Wk 4"],
4
+ "values": [ [3.1, 4.4, 5.0, 4.8], [1.2, 2.6, 3.3, 3.9], [-2.4, -3.8, -1.9, -2.7] ],
5
+ "source": "Invented data, for illustration." }
@@ -0,0 +1,6 @@
1
+ { "type": "scatter", "style": "report", "title": "Bigger pull requests wait longer for review",
2
+ "note": "One dot per merged pull request", "x": { "label": "Lines changed", "log": true }, "y": { "label": "Time to first review", "time": "hours", "zero": true },
3
+ "points": [ {"x":12,"y":1.5},{"x":30,"y":2.1},{"x":8,"y":0.6},{"x":45,"y":3.2},{"x":120,"y":5.5},{"x":260,"y":9.1},{"x":75,"y":2.8},
4
+ {"x":18,"y":1.1},{"x":540,"y":22.0,"label":"Search rewrite","tone":"accent"},{"x":95,"y":6.0},{"x":160,"y":4.2},{"x":22,"y":3.9},
5
+ {"x":64,"y":2.2},{"x":310,"y":12.5},{"x":40,"y":1.8},{"x":5,"y":0.4},{"x":210,"y":7.7},{"x":880,"y":30.5},{"x":15,"y":0.9},{"x":130,"y":3.6} ],
6
+ "source": "Invented example data" }
@@ -0,0 +1,67 @@
1
+ {
2
+ "type": "sequence",
3
+ "title": "The shopper has an order number before the receipt is sent",
4
+ "participants": [
5
+ {
6
+ "id": "app",
7
+ "name": "Shop app",
8
+ "kind": "browser"
9
+ },
10
+ {
11
+ "id": "api",
12
+ "name": "Checkout API"
13
+ },
14
+ {
15
+ "id": "pay",
16
+ "name": "Card processor"
17
+ },
18
+ {
19
+ "id": "mail",
20
+ "name": "Email queue"
21
+ }
22
+ ],
23
+ "messages": [
24
+ {
25
+ "from": "app",
26
+ "to": "api",
27
+ "label": "Place order"
28
+ },
29
+ {
30
+ "from": "api",
31
+ "to": "api",
32
+ "label": "Check stock and price"
33
+ },
34
+ {
35
+ "from": "api",
36
+ "to": "pay",
37
+ "label": "Charge the card",
38
+ "tone": "accent"
39
+ },
40
+ {
41
+ "from": "pay",
42
+ "to": "api",
43
+ "label": "Approved or declined",
44
+ "reply": true,
45
+ "tone": "accent"
46
+ },
47
+ {
48
+ "from": "api",
49
+ "to": "app",
50
+ "label": "Order number",
51
+ "reply": true
52
+ },
53
+ {
54
+ "from": "api",
55
+ "to": "mail",
56
+ "label": "Send the receipt",
57
+ "note": "within a minute"
58
+ }
59
+ ],
60
+ "repeats": [
61
+ {
62
+ "from": 3,
63
+ "to": 4,
64
+ "label": "Up to 3 tries"
65
+ }
66
+ ]
67
+ }
@@ -0,0 +1,6 @@
1
+ { "type": "series", "style": "report", "title": "The weekly pass rate dipped in week 6, beyond its usual range",
2
+ "note": "Share of test runs that passed, each week", "unit": "%", "zero": false, "range": "likely range (95%)",
3
+ "points": [ {"x":1,"y":91,"low":86,"high":94}, {"x":2,"y":93,"low":88,"high":96}, {"x":3,"y":90,"low":85,"high":94},
4
+ {"x":4,"y":92,"low":87,"high":95}, {"x":5,"y":91,"low":86,"high":95}, {"x":6,"y":79,"low":72,"high":85},
5
+ {"x":7,"y":89,"low":84,"high":93}, {"x":8,"y":92,"low":87,"high":95} ],
6
+ "mark": { "x": 6, "label": "79%, week 6" }, "source": "Invented example data" }
@@ -0,0 +1,71 @@
1
+ {
2
+ "type": "states",
3
+ "title": "An order waits after a declined card until a new card is added",
4
+ "entry": "Shopper checks out",
5
+ "states": [
6
+ {
7
+ "id": "waiting",
8
+ "name": "Awaiting payment",
9
+ "start": true
10
+ },
11
+ {
12
+ "id": "declined",
13
+ "name": "Card declined",
14
+ "note": "Shopper emailed",
15
+ "tone": "accent"
16
+ },
17
+ {
18
+ "id": "paid",
19
+ "name": "Paid"
20
+ },
21
+ {
22
+ "id": "packed",
23
+ "name": "Packed"
24
+ },
25
+ {
26
+ "id": "shipped",
27
+ "name": "Shipped",
28
+ "end": true
29
+ },
30
+ {
31
+ "id": "cancelled",
32
+ "name": "Cancelled",
33
+ "end": true
34
+ }
35
+ ],
36
+ "moves": [
37
+ {
38
+ "from": "waiting",
39
+ "to": "paid",
40
+ "label": "Charge succeeds"
41
+ },
42
+ {
43
+ "from": "waiting",
44
+ "to": "declined",
45
+ "label": "Charge fails"
46
+ },
47
+ {
48
+ "from": "declined",
49
+ "to": "waiting",
50
+ "label": "New card added"
51
+ },
52
+ {
53
+ "from": "paid",
54
+ "to": "packed",
55
+ "label": "Warehouse picks"
56
+ },
57
+ {
58
+ "from": "packed",
59
+ "to": "shipped",
60
+ "label": "Carrier collects"
61
+ },
62
+ {
63
+ "from": [
64
+ "paid",
65
+ "packed"
66
+ ],
67
+ "to": "cancelled",
68
+ "label": "Shopper cancels"
69
+ }
70
+ ]
71
+ }
@@ -0,0 +1,4 @@
1
+ { "type": "units", "style": "report", "title": "11 of the 29 flagged days were real problems",
2
+ "note": "One square per day the monitor flagged in October", "total": 29,
3
+ "parts": [ { "label": "real problems", "count": 11 } ], "rest": "false alarms",
4
+ "source": "Invented example data" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liggi/figkit",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
package/runtime/fig.css CHANGED
@@ -218,3 +218,98 @@ fig-bars .fb-sub { font-weight: 400; color: var(--soft); font-size: var(--small)
218
218
  .fig .hm-key { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: var(--small); color: var(--muted); margin: 0 0 12px; }
219
219
  .fig .hm-key i { display: inline-block; width: 160px; height: 14px; border-radius: 3px; }
220
220
  .fig.phone .hm-key i { width: 84px; }
221
+
222
+ /* ---------- sequence: who calls whom, in order (a spec of type "sequence") ---------- */
223
+ .fig .sequence { position: relative; }
224
+ .fig .sequence > svg { position: absolute; left: 0; top: 0; overflow: visible; }
225
+ .fig .seq-lanes { position: relative; }
226
+ .fig .seq-who { position: absolute; }
227
+ .fig .seq-who { text-align: center; align-items: center; justify-content: center; }
228
+ .fig.phone .seq-who { padding: 10px 8px; }
229
+ .fig.phone .seq-who > h3 { font-size: var(--body); }
230
+ /* The paper shows only behind the words themselves, line by line, so a line passing a label stops just at its text. */
231
+ .fig .seq-msg { position: absolute; box-sizing: border-box; width: max-content; display: flex; gap: 10px; align-items: baseline; padding: 1px 6px; }
232
+ .fig .seq-ink { background: var(--paper); box-decoration-break: clone; -webkit-box-decoration-break: clone; padding: 1px 0; }
233
+ .fig .seq-n { font-size: var(--small); font-variant-numeric: tabular-nums; color: var(--soft); flex: 0 0 auto; min-width: 2ch; text-align: right; }
234
+ .fig .seq-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
235
+ .fig .seq-note { font-size: var(--small); color: var(--muted); }
236
+ .fig .seq-msg.reply .seq-text { color: var(--muted); }
237
+ .fig .seq-msg.accent .seq-text, .fig .seq-msg.accent .seq-n { color: var(--accent); }
238
+ .fig .seq-msg.accent .seq-text { font-weight: 600; }
239
+ .fig .seq-msg.accent .seq-note { font-weight: 400; }
240
+ .fig .seq-frame-label { position: absolute; white-space: nowrap; background: var(--paper); padding: 0 8px; font-size: var(--small); font-weight: 600; color: var(--muted); }
241
+ .fig .seq-lanes.tight .seq-who { padding: 8px 4px; }
242
+ .fig .seq-lanes.tight .seq-who > h3 { font-size: var(--small); }
243
+
244
+ /* ranges: what they are, said once under the reading line; on bars a line with end caps across the bar's end */
245
+ .fig .range-key { display: flex; flex-wrap: wrap; column-gap: 28px; row-gap: 6px; margin: 2px 0 0; font-size: var(--small); color: var(--muted); }
246
+ .fig .range-key > span { display: inline-flex; align-items: center; gap: 10px; }
247
+ .fig .range-key i { flex: 0 0 auto; }
248
+ .fig .range-key i.dash { width: 34px; height: 0; border-top: 2px dashed var(--ink); }
249
+ .fig .range-key i.whisker { position: relative; width: 34px; height: 14px; border-left: 2px solid var(--ink); border-right: 2px solid var(--ink); }
250
+ .fig .range-key i.whisker::after { content: ''; position: absolute; left: 0; right: 0; top: 6px; border-top: 2px solid var(--ink); }
251
+ .fig .range-key i.median { width: 34px; height: 4px; border-radius: 2px; }
252
+ .fig .range-key i.band { width: 34px; height: 14px; border-radius: 2px; background: var(--fill); opacity: .45; }
253
+ fig-bars .fb-track { position: relative; }
254
+ fig-bars .fb-range { --tone: var(--ink); position: absolute; top: 50%; height: 12px; margin-top: -6px; border-left: 2px solid var(--tone); border-right: 2px solid var(--tone); pointer-events: none; }
255
+ fig-bars .fb-range::after { content: ''; position: absolute; left: 0; right: 0; top: 4.5px; border-top: 3px solid var(--tone); }
256
+ fig-bars .fb-bar.dim { opacity: .4; }
257
+ fig-bars .fb-val .fb-sub + .fb-sub { margin-left: 10px; }
258
+ fig-bars .fb-label .fb-n { display: block; font-size: var(--small); color: var(--soft); }
259
+
260
+ /* dots: the count of a single group, above its dots */
261
+ .fig .dots-n { margin: 0 0 4px; font-size: var(--small); color: var(--muted); }
262
+
263
+ /* units: one square per thing counted */
264
+ .fig .units { display: flex; flex-direction: column; gap: 22px; }
265
+ .fig .un-grid { display: grid; }
266
+ .fig .un-grid i { width: var(--un); height: var(--un); border-radius: 4px; }
267
+ .fig .un-grid i.empty, .fig .un-key i.empty { background: var(--hair); }
268
+ .fig .un-key { display: flex; flex-wrap: wrap; column-gap: 28px; row-gap: 8px; font-size: var(--body); color: var(--muted); }
269
+ .fig .un-key span { display: inline-flex; align-items: center; gap: 8px; }
270
+ .fig .un-key i { width: 18px; height: 18px; border-radius: 3px; display: inline-block; }
271
+ .fig .un-key b { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }
272
+
273
+ /* bars on a phone: the name and count above each bar, so the bar and its range get the full width */
274
+ .fig.phone fig-bars { grid-template-columns: minmax(0, 1fr); row-gap: 4px; }
275
+ .fig.phone fig-bars .fb-label { margin-top: 10px; }
276
+ .fig.phone fig-bars .fb-label .fb-n { display: inline; margin-left: 8px; }
277
+
278
+ /* estimates: signed values with ranges around a zero line; what each side of zero means under the axis */
279
+ .fig .estimates { position: relative; }
280
+
281
+ .fig .range-key i.est-dot { width: 14px; height: 14px; border-radius: 50%; border: 2.5px solid; box-sizing: border-box; }
282
+ .fig .est-sides { position: relative; height: calc(var(--small) * 1.4); margin-top: -6px; font-size: var(--small); color: var(--muted); }
283
+ .fig .est-sides span { position: absolute; top: 0; white-space: nowrap; }
284
+
285
+ /* bars with a reference: its own first row in the second colour, and a dashed line from its end through the rest */
286
+ fig-bars.has-ref { position: relative; }
287
+ fig-bars .fb-label.ref { color: var(--second); }
288
+ fig-bars .fb-refline { position: absolute; width: 0; border-left: 2px dashed var(--second); pointer-events: none; }
289
+ .fig .range-key i.swatch { width: 18px; height: 14px; border-radius: 2px; background: var(--accent); }
290
+ fig-bars.has-ref .fb-label.ref + .fb-track .fb-bar { opacity: .55; }
291
+ /* on a phone the names sit above the bars, so the reference line stops at each name rather than crossing it */
292
+ .fig.phone fig-bars.has-ref .fb-label { position: relative; z-index: 1; justify-self: start; background: var(--paper); padding-right: 8px; }
293
+ .fig .hm-key i.hm-signed { position: relative; margin-bottom: calc(var(--small) * 1.3); }
294
+ .fig .hm-key i.hm-signed b { font-style: normal; position: absolute; top: 100%; transform: translateX(-50%); font-weight: 400; color: var(--muted); padding-top: 2px; line-height: 1; }
295
+ .fig .hm-key i.hm-signed b::before { content: ''; position: absolute; left: 50%; top: -14px; height: 14px; border-left: 2px solid var(--paper); }
296
+ fig-bars.has-ref .fb-val:not(.lead) { color: var(--muted); }
297
+ /* Against a reference, a bar that clears it is solid accent (a faded orange reads as brown on dark); its range is drawn in ink on top. */
298
+ fig-bars.has-ref .fb-bar.dim.accent { opacity: 1; }
299
+ fig-bars.has-ref .fb-range.accent { --tone: var(--ink); }
300
+
301
+ /* ---------- states: what one thing moves through (a spec of type "states") ---------- */
302
+ .fig .states { display: grid; column-gap: 120px; row-gap: 76px; align-items: stretch; justify-content: start; }
303
+ .fig.phone .states { column-gap: 64px; row-gap: 68px; }
304
+ .fig.phone .state { padding: 12px 10px; }
305
+ .fig.phone .state > h3 { font-size: var(--body); }
306
+ .fig .state { text-align: center; align-items: center; justify-content: center; justify-self: stretch; }
307
+ .fig .state > p { margin-top: 4px; }
308
+ .fig .state > .st-stayed { color: var(--ink); }
309
+ .fig .node.bare.st-dot { width: 22px; height: 22px; padding: 0; border-radius: 50%; background: var(--line); justify-self: center; align-self: center; }
310
+ .fig .fig-wire-label .fig-wire-sub { display: block; font-weight: 400; font-variant-numeric: tabular-nums; }
311
+ .fig .st-key { font-size: var(--small); }
312
+ .fig .state.mid { align-self: center; }
313
+ /* the bracket beside the states that share one move, which leads to it with one arrow */
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
+ .fig.phone .node.bare.st-bracket { margin-right: -24px; width: 10px; }
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()) {
@@ -370,7 +370,7 @@
370
370
  fig.wires.push({ from, to, pts, mode, ref });
371
371
 
372
372
  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 });
373
+ if (text) labels.push({ text, sub: tag.getAttribute('sub'), tone, color, pts, at: labelAt, want: tag.getAttribute('label-at'), from, to });
374
374
  }
375
375
  return placeLabels(labels, obs);
376
376
  }
@@ -467,6 +467,8 @@
467
467
  const lab = document.createElement('div');
468
468
  lab.className = 'fig-wire-label';
469
469
  lab.textContent = L.text;
470
+ // Further lines under the label, such as a count: one span each.
471
+ 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
472
  if (L.tone !== 'line') lab.style.color = L.color;
471
473
  lab.dataset.arrow = `${L.from} → ${L.to}`;
472
474
  R.appendChild(lab);
@@ -553,10 +555,66 @@
553
555
  });
554
556
 
555
557
  // ---------- checks ----------
558
+ /** 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). */
559
+ function rgba(c) {
560
+ const m = /^rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)$/.exec((c || '').trim());
561
+ if (m) return [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]];
562
+ const s = /^color\(srgb ([\d.e-]+) ([\d.e-]+) ([\d.e-]+)(?: \/ ([\d.]+))?\)$/.exec((c || '').trim());
563
+ return s ? [+s[1] * 255, +s[2] * 255, +s[3] * 255, s[4] === undefined ? 1 : +s[4]] : null;
564
+ }
565
+ 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);
566
+ const over = (top, under) => [0, 1, 2].map((i) => top[i] * top[3] + under[i] * (1 - top[3])).concat(1);
567
+ function opacityOf(el) { let o = 1; for (let x = el; x && x !== document.documentElement; x = x.parentElement) o *= parseFloat(getComputedStyle(x).opacity); return o; }
568
+ /** The colour an element paints over its own area, with its opacity, or undefined when it paints nothing there. */
569
+ function paintOf(el) {
570
+ const cs = getComputedStyle(el);
571
+ if (el instanceof SVGElement) {
572
+ if (!/^(rect|path|circle|ellipse|polygon)$/.test(el.tagName)) return undefined;
573
+ if (cs.fill === 'none') return undefined;
574
+ const c = rgba(cs.fill);
575
+ return c ? [c[0], c[1], c[2], c[3] * parseFloat(cs.fillOpacity) * opacityOf(el)] : null;
576
+ }
577
+ if (cs.backgroundImage !== 'none') return null;
578
+ const c = rgba(cs.backgroundColor);
579
+ if (!c) return null;
580
+ return c[3] === 0 ? undefined : [c[0], c[1], c[2], c[3] * opacityOf(el)];
581
+ }
582
+ /** Contrast between a text element and what lies under its middle, as a WCAG ratio; null when it cannot be told. */
583
+ function contrastOf(el) {
584
+ const b = el.getBoundingClientRect();
585
+ if (b.top < 0 || b.bottom > innerHeight || b.left < 0 || b.right > innerWidth) el.scrollIntoView({ block: 'center', inline: 'center' });
586
+ 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 }; })();
587
+ const stack = document.elementsFromPoint(r.x + r.width / 2, r.y + r.height / 2);
588
+ let i = stack.findIndex((x) => x === el || x.contains(el));
589
+ if (i < 0) return null;
590
+ if (el.tagName === 'text') i++;
591
+ const layers = [];
592
+ for (; i < stack.length; i++) {
593
+ const p = paintOf(stack[i]);
594
+ if (p === null) return null;
595
+ if (p === undefined) continue;
596
+ layers.push(p);
597
+ if (p[3] >= 0.99) break;
598
+ }
599
+ let bg = [255, 255, 255, 1];
600
+ for (const l of layers.reverse()) bg = over(l, bg);
601
+ const cs = getComputedStyle(el);
602
+ const fg = rgba(el.tagName === 'text' ? cs.fill : cs.color);
603
+ if (!fg) return null;
604
+ const ink = over([fg[0], fg[1], fg[2], fg[3] * opacityOf(el)], bg);
605
+ const la = lumOf(ink), lb = lumOf(bg);
606
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
607
+ }
608
+ const R0 = () => { const b = root().getBoundingClientRect(); return { x: b.x, y: b.y }; };
609
+
556
610
  function check() {
557
611
  const R = root(), P = fig.problems;
558
612
  const W = R.offsetWidth, H = R.offsetHeight;
559
613
  const detail = R.classList.contains('detail');
614
+ // A phone layout is 560px wide (fig.css); opening gaps for labels can widen it past that.
615
+ const phoneW = R.classList.contains('phone') ? 560 : 0;
616
+ const wide = Math.max(W, R.scrollWidth, R.getBoundingClientRect().width);
617
+ 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
618
  const minText = detail ? 13 : 15;
561
619
  const obs = obstacles();
562
620
 
@@ -567,6 +625,22 @@
567
625
  for (const b of marks.slice(i + 1)) if (overlap(a.r, b.r) && !a.e.contains(b.e) && !b.e.contains(a.e)) P.push(`Chart labels overlap: "${a.e.textContent.trim()}" and "${b.e.textContent.trim()}".`);
568
626
  }
569
627
 
628
+ // Chart labels over dots: a label drawn across a dot hides the value it sits on.
629
+ const dots = [...R.querySelectorAll('.chart svg circle')].filter(visible).map(rectOf).map((r) => grow(r, -1.5));
630
+ for (const a of marks) {
631
+ const hit = dots.filter((d) => overlap(grow(a.r, -1), d)).length;
632
+ if (hit) P.push(`Chart label covers ${hit === 1 ? 'a dot' : hit + ' dots'}: "${a.e.textContent.trim()}".`);
633
+ }
634
+
635
+ // Text that cannot be read against what is drawn behind it, such as white numbers on a pale cell.
636
+ for (const e of [...R.querySelectorAll('*')].filter((x) => !x.closest('svg') || x.tagName === 'text')) {
637
+ if (e.tagName !== 'text' && ![...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) continue;
638
+ if (!e.textContent.trim() || !visible(e) || e.matches('script, style')) continue;
639
+ const ratio = contrastOf(e);
640
+ 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).`);
641
+ }
642
+ window.scrollTo(0, 0);
643
+
570
644
  // Text: too small, cut off, or past its box.
571
645
  const small = [];
572
646
  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()))))) {
@@ -619,6 +693,7 @@
619
693
  const crossings = new Set(crossingPairs().map(([u, w]) => `${u.from} → ${u.to} and ${w.from} → ${w.to}`));
620
694
  if (crossings.size) P.push(R.querySelector('.entities')
621
695
  ? `References cross: ${[...crossings].join('; ')}. Split the records over two figures; do not leave a reference out.`
696
+ : R.querySelector('.states') ? `Moves cross: ${[...crossings].join('; ')}. figkit could not lay them out apart; draw the side branch as a second figure.`
622
697
  : `Arrows cross: ${[...crossings].join('; ')}. Reorder the boxes so they do not, or drop an arrow.`);
623
698
 
624
699
  // Neighbours that almost line up.
@@ -640,7 +715,9 @@
640
715
  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;
641
716
  const words = (panelText !== null ? outside + ' ' + panelText : R.innerText).split(/\s+/).filter(Boolean).length;
642
717
  const nodes = R.querySelectorAll('.node:not(.bare), .decision').length, arrows = (fig.wires ?? []).length;
643
- const budget = detail ? { words: 190, nodes: 18, arrows: 18 } : { words: 70, nodes: 5, arrows: 8 };
718
+ // A states picture reads down one path of short names, so it holds up to 7 states (see the states spec).
719
+ 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 };
644
721
  if (words > budget.words) P.push(`${words} words; a ${detail ? 'detail' : 'glance'} figure holds about ${budget.words}. Cut sentences that explain, and keep labels.`);
645
722
  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.'}`);
646
723
  if (arrows > budget.arrows) P.push(`${arrows} arrows; more than ${budget.arrows} tangles. Show the main path and drop the rest.`);
package/viz/build.mjs CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
6
6
  const HOME = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
7
- const SOURCES = ['viz/draw.js', 'package.json'].map((f) => path.join(HOME, f));
7
+ const SOURCES = [...fs.readdirSync(path.join(HOME, 'viz')).filter((f) => f.endsWith('.js')).map((f) => 'viz/' + f), 'package.json'].map((f) => path.join(HOME, f));
8
8
  const OUT = path.join(HOME, 'dist/viz.iife.js');
9
9
 
10
10
  const mtime = (f) => (fs.existsSync(f) ? fs.statSync(f).mtimeMs : 0);