@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/viz/draw.js CHANGED
@@ -1,6 +1,8 @@
1
1
  /* The chart types, drawn into a host element styled by runtime/fig.css. Figures use them through runtime/fig.js. */
2
2
  import * as Plot from '@observablehq/plot';
3
3
  import { drawSequence } from './sequence.js';
4
+ import { drawStates } from './states.js';
5
+ import { drawMap } from './map.js';
4
6
  const NS = 'http://www.w3.org/2000/svg';
5
7
  const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
6
8
 
@@ -84,11 +86,15 @@ export function drawSplit(host, parts, unit) {
84
86
  * A few numbers compared into a <fig-bars>, on an axis from zero to max (default: the largest value or high). A bar
85
87
  * with `low` and `high` gets a range drawn across its end, and its number moves past the range; `n` goes under its label.
86
88
  */
87
- export function drawBars(host, bars, unit, max, each) {
88
- const top = max ?? Math.max(...bars.map((b) => Math.max(b.value, b.high ?? 0)));
89
+ export function drawBars(host, bars, unit, max, each, ref) {
90
+ const top = max ?? Math.max(...bars.map((b) => Math.max(b.value, b.high ?? 0)), ref?.value ?? 0);
89
91
  host.innerHTML = '';
92
+ host.classList.toggle('has-ref', !!ref);
93
+ // A reference is its own first row, in the second colour; a bar above it (its whole range, when it has one) is
94
+ // accented and every other bar is grey.
95
+ if (ref) bars = [{ ...ref, text: String(ref.value), tone: 'second', isRef: true }, ...bars.map((b) => ({ ...b, tone: (b.low ?? b.value) > ref.value ? 'accent' : undefined }))];
90
96
  const fills = bars.map((b) => {
91
- const label = Object.assign(document.createElement('div'), { className: 'fb-label', innerHTML: b.label + (b.n !== undefined ? `<span class="fb-n">${each ? `${esc(b.n.toLocaleString('en-GB'))} ${esc(each)}` : `n = ${esc(b.n.toLocaleString('en-GB'))}`}</span>` : '') });
97
+ const label = Object.assign(document.createElement('div'), { className: 'fb-label' + (b.isRef ? ' ref' : ''), innerHTML: b.label + (b.n !== undefined ? `<span class="fb-n">${each ? `${esc(b.n.toLocaleString('en-GB'))} ${esc(each)}` : `n = ${esc(b.n.toLocaleString('en-GB'))}`}</span>` : '') });
92
98
  const track = Object.assign(document.createElement('div'), { className: 'fb-track' });
93
99
  const tone = b.tone ? ' ' + b.tone : '';
94
100
  const ranged = b.low !== undefined;
@@ -109,6 +115,20 @@ export function drawBars(host, bars, unit, max, each) {
109
115
  f.range.style.width = at(f.hi - f.lo);
110
116
  f.val.style.marginLeft = `max(0px, calc((100% - ${room}px) * ${f.hi - f.ratio} - 3px))`;
111
117
  }
118
+ if (!ref) return;
119
+ // The reference level as a dashed line from the end of its bar down through every other row; a value that would
120
+ // sit on the line is moved past it.
121
+ const line = document.createElement('div');
122
+ line.className = 'fb-refline';
123
+ host.appendChild(line);
124
+ const hostBox = host.getBoundingClientRect();
125
+ const refBar = fills[0].bar.getBoundingClientRect(), last = fills[fills.length - 1].bar.parentElement.getBoundingClientRect();
126
+ const x = refBar.right - hostBox.left;
127
+ Object.assign(line.style, { left: `${x - 1}px`, top: `${refBar.bottom - hostBox.top}px`, height: `${last.bottom - refBar.bottom}px` });
128
+ for (const f of fills.slice(1)) {
129
+ const v = f.val.getBoundingClientRect();
130
+ if (v.left - hostBox.left < x + 8 && v.right - hostBox.left > x - 8) f.val.style.marginLeft = `${parseFloat(getComputedStyle(f.val).marginLeft) + (x + 10 - (v.left - hostBox.left))}px`;
131
+ }
112
132
  }
113
133
 
114
134
  /** What the ranges are, as a key line under the reading line: the mark as drawn, then the words. */
@@ -116,7 +136,8 @@ function rangeKey(spec) {
116
136
  const p = document.createElement('p');
117
137
  p.className = 'range-key';
118
138
  p.innerHTML = (spec.range ? `<span><i class="${spec.type === 'bars' ? 'whisker' : 'band'}"></i>${esc(spec.range)}</span>` : '')
119
- + (spec.ref ? `<span><i class="dash"></i>${esc(spec.ref.label)}</span>` : '');
139
+ + (spec.ref && spec.type !== 'bars' ? `<span><i class="dash"></i>${esc(spec.ref.label)}</span>` : '')
140
+ + (spec.ref && spec.type === 'bars' && spec.bars.some((b) => (b.low ?? b.value) > spec.ref.value) ? `<span><i class="swatch"></i>${spec.range ? 'whole range ' : ''}above ${esc(spec.ref.label)}</span>` : '');
120
141
  return p;
121
142
  }
122
143
 
@@ -316,20 +337,45 @@ function drawEntities(spec, main) {
316
337
  }
317
338
  }
318
339
 
319
- const HOSTS = { split: 'fig-split', bars: 'fig-bars', 'before-after': 'fig-change', dumbbell: 'div', series: 'div', groups: 'div', lines: 'div', panels: 'div', heatmap: 'div', dots: 'div', scatter: 'div', units: 'div' };
340
+ const HOSTS = { split: 'fig-split', bars: 'fig-bars', estimates: 'div', 'before-after': 'fig-change', dumbbell: 'div', series: 'div', groups: 'div', lines: 'div', panels: 'div', heatmap: 'div', dots: 'div', scatter: 'div', units: 'div' };
320
341
 
321
342
  /** A checked spec drawn into a figure: header, chart and footer, as figkit's own markup. */
322
343
  export function drawSpec(spec, main) {
323
344
  const header = document.createElement('header');
324
345
  header.innerHTML = `<h1>${esc(spec.title)}</h1>${spec.note ? `<p class="read">${esc(spec.note)}</p>` : ''}`;
325
346
  // What the ranges and a reference line are, said once under the reading line: there they never sit on the data.
326
- if (spec.range || spec.ref) header.appendChild(rangeKey(spec));
347
+ if (spec.type === 'estimates') header.appendChild(estimatesKey(spec));
348
+ else if (spec.range || spec.ref) header.appendChild(rangeKey(spec));
327
349
  if (spec.type === 'entities') {
328
350
  main.append(header);
329
351
  drawEntities(spec, main);
330
352
  if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
331
353
  return { chart: main.querySelector('.entities'), draw: () => {} };
332
354
  }
355
+ if (spec.type === 'states') {
356
+ if (spec.moves.some((m) => m.count != null)) {
357
+ const key = document.createElement('p');
358
+ key.className = 'read st-key';
359
+ key.textContent = `Counts are ${spec.each} who made each move at least once${spec.shares ? '; % of those who reached the state it leaves' : ''}.`;
360
+ header.appendChild(key);
361
+ }
362
+ main.append(header);
363
+ const chart = drawStates(spec, main);
364
+ if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
365
+ return { chart, draw: () => {} };
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
+ }
333
379
  if (spec.type === 'sequence') {
334
380
  main.append(header);
335
381
  const chart = drawSequence(spec, main);
@@ -346,6 +392,7 @@ export function drawSpec(spec, main) {
346
392
  const draw = () => {
347
393
  if (spec.type === 'before-after') return drawChange(chart, spec);
348
394
  if (spec.type === 'dumbbell') return drawDumbbell(chart, spec);
395
+ if (spec.type === 'estimates') return drawEstimates(chart, spec);
349
396
  if (spec.type === 'series') return drawSeries(chart, spec);
350
397
  if (spec.type === 'groups') return drawGroups(chart, spec, { unit: spec.unit, max: spec.max });
351
398
  if (spec.type === 'lines') return drawLines(chart, spec, { unit: spec.unit, xTicks: spec.xTicks });
@@ -356,7 +403,7 @@ export function drawSpec(spec, main) {
356
403
  if (spec.type === 'units') return drawUnits(chart, spec);
357
404
  const items = (spec.parts ?? spec.bars).map((x) => ({ ...x, text: String(x.value), label: esc(x.label) }));
358
405
  if (spec.type === 'split') drawSplit(chart, items, spec.unit);
359
- else drawBars(chart, items, spec.unit, spec.max, spec.each);
406
+ else drawBars(chart, items, spec.unit, spec.max, spec.each, spec.ref && { ...spec.ref, label: esc(spec.ref.label) });
360
407
  };
361
408
  draw();
362
409
  return { chart, draw };
@@ -484,14 +531,26 @@ export function drawPanels(host, spec) {
484
531
  */
485
532
  export function drawHeatmap(host, spec) {
486
533
  const t = themeOf(host);
534
+ host.classList.add('heatmap');
487
535
  const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 420;
488
536
  const cells = spec.rows.flatMap((r, i) => spec.columns.map((c, j) => ({ r: String(r), c: String(c), v: spec.values[i][j] })));
489
537
  const vs = cells.map((d) => d.v), lo = Math.min(...vs), hi = Math.max(...vs);
490
- // One hue, near white to dark: darker is always more, and busy does not read as alarm the way red does.
491
- const scale = Plot.scale({ color: { type: 'linear', scheme: 'blues', domain: [lo - (hi - lo) * 0.08, hi] } });
492
- const colour = (v) => scale.apply(v);
493
- const ramp = [0, 0.25, 0.5, 0.75, 1].map((p) => colour(lo + (hi - lo) * p)).join(', ');
494
- const fmt = (v) => num(v) + (spec.unit ?? '');
538
+ // Any value below zero makes the grid signed: a diverging scale around zero, orange below and blue above, equal
539
+ // distances from zero equally strong. Otherwise one hue, near white to dark: darker is always more.
540
+ const diverging = lo < 0;
541
+ const places = placesOf(vs);
542
+ const fmt = (v) => (diverging ? signed(v, places) : Number(v.toFixed(places)).toLocaleString('en-GB', { minimumFractionDigits: places })) + (spec.unit ?? '');
543
+ const scale = diverging ? null : Plot.scale({ color: { type: 'linear', scheme: 'blues', domain: [lo - (hi - lo) * 0.08, hi] } });
544
+ const MID = '#f2f2f0', NEG = '#a63603', POS = '#08519c', far = Math.max(-lo, hi) || 1;
545
+ const colour = (v) => (!diverging ? scale.apply(v) : v < 0 ? mix(MID, NEG, -v / far) : mix(MID, POS, v / far));
546
+ // Cell text in dark or white, whichever reads against the cell, whatever the theme's ink.
547
+ const lum = (c) => { const m = /rgb\((\d+), (\d+), (\d+)\)/.exec(c); const x = m ? m.slice(1).map(Number) : hex(c) ?? [0, 0, 0]; const [r, g, b] = x.map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
548
+ const textOn = (v) => { const L = lum(colour(v)); return (L + 0.05) / (lum('#1d2130') + 0.05) >= 1.05 / (L + 0.05) ? '#1d2130' : '#ffffff'; };
549
+ const K = phone ? 120 : 200, z = Math.round((100 * -lo) / (hi - lo || 1));
550
+ // Signed: one bar through zero, with 0 marked where it falls on the scale.
551
+ const ramp = diverging
552
+ ? `<span>${esc(fmt(lo))}</span><i class="hm-signed" style="width:${K}px;background:linear-gradient(90deg, ${colour(lo)}, ${MID} ${z}%, ${colour(Math.max(hi, 0))})"><b style="left:${z}%">0</b></i><span>${esc(fmt(hi))}</span>`
553
+ : `<span>${esc(fmt(lo))}</span><i style="background:linear-gradient(90deg, ${[0, 0.25, 0.5, 0.75, 1].map((p) => colour(lo + (hi - lo) * p)).join(', ')})"></i><span>${esc(fmt(hi))}</span>`;
495
554
  const cols = spec.columns.map(String), rows = spec.rows.map(String);
496
555
  const labelW = Math.ceil(widest(host, rows, t.small)) + 12;
497
556
  const cellW = (width - labelW - 8) / cols.length;
@@ -502,7 +561,7 @@ export function drawHeatmap(host, spec) {
502
561
  const rowH = phone ? 30 : 36;
503
562
  const key = document.createElement('p');
504
563
  key.className = 'hm-key';
505
- key.innerHTML = `<span>${esc(fmt(lo))}</span><i style="background:linear-gradient(90deg, ${ramp})"></i><span>${esc(fmt(hi))}</span>`
564
+ key.innerHTML = ramp
506
565
  + (spec.mark ? `<span style="margin-left:14px;white-space:nowrap;display:inline-flex;align-items:center;gap:8px;color:${t.ink};font-weight:600"><i style="width:16px;height:16px;border:2.5px solid ${t.accent};border-radius:2px;background:none"></i>${esc(spec.mark.label)}</span>` : '');
507
566
  plotInto(host, t, {
508
567
  width, height: rows.length * rowH + 36, marginLeft: labelW, marginRight: 8, marginTop: 0, marginBottom: 36,
@@ -510,7 +569,7 @@ export function drawHeatmap(host, spec) {
510
569
  y: { domain: rows, label: null, tickSize: 0, padding: 0.08 },
511
570
  marks: [
512
571
  Plot.cell(cells, { x: 'c', y: 'r', fill: (d) => colour(d.v), inset: 0.5 }),
513
- show ? Plot.text(cells, { x: 'c', y: 'r', text: (d) => fmt(d.v), fill: (d) => ((d.v - lo) / (hi - lo || 1) > 0.55 ? '#ffffff' : t.ink), fontSize: t.small }) : null,
572
+ show ? Plot.text(cells, { x: 'c', y: 'r', text: (d) => fmt(d.v), fill: (d) => textOn(d.v), fontSize: t.small }) : null,
514
573
  // The marked cell: an accent ring, the one thing in the chart that is not the scale.
515
574
  spec.mark ? Plot.cell([{ r: String(spec.mark.row), c: String(spec.mark.column) }], { x: 'c', y: 'r', fill: 'none', stroke: t.accent, strokeWidth: 2.5 }) : null,
516
575
  ],
@@ -519,6 +578,101 @@ export function drawHeatmap(host, spec) {
519
578
  host.prepend(key);
520
579
  }
521
580
 
581
+ // ---------- signed estimates with ranges around zero (a forest plot) ----------
582
+
583
+ /** Decimal places the author wrote, at most 3, so -0.18 is not printed as -0.2. */
584
+ const placesOf = (vs) => Math.min(3, Math.max(0, ...vs.map((v) => { const m = /\.(\d+)/.exec(String(v)); return m ? m[1].length : 0; })));
585
+ /** A signed number with a true minus sign: +7.4, −0.7, 0. */
586
+ const signed = (v, places) => { const r = Math.abs(v).toFixed(places); return (+r === 0 ? '' : v > 0 ? '+' : '−') + r; };
587
+
588
+ /**
589
+ * One row per estimate into a .chart: a dot at the value, a line across its range and a zero line through all of
590
+ * them. A row whose range clears zero is coloured; one whose range includes zero is grey with a hollow dot. Each row's
591
+ * value and range are printed in a column at the right; on a phone they go on a line above the row with its label.
592
+ */
593
+ export function drawEstimates(host, spec) {
594
+ const t = themeOf(host);
595
+ host.classList.add('estimates');
596
+ const width = Math.floor(host.clientWidth) || 640;
597
+ const places = placesOf(spec.rows.flatMap((r) => [r.value, r.low, r.high]));
598
+ const u = spec.unit ?? '';
599
+ const rows = spec.rows.map((r, i) => {
600
+ const clear = r.low > 0 || r.high < 0;
601
+ const good = spec.better ? (r.value > 0) === (spec.better === 'higher') : null;
602
+ const color = !clear ? t.soft : good === null ? t.accent : good ? t.good : t.bad;
603
+ const n = r.n === undefined ? '' : spec.each ? `${r.n.toLocaleString('en-GB')} ${spec.each}` : `n = ${r.n.toLocaleString('en-GB')}`;
604
+ return { ...r, i, clear, color, n, val: signed(r.value, places) + u, span: `${signed(r.low, places)} to ${signed(r.high, places)}` };
605
+ });
606
+ // Zero always shows, with room on its empty side, so the line it is measured from is never the frame's edge.
607
+ const lo0 = Math.min(0, ...rows.map((r) => r.low)), hi0 = Math.max(0, ...rows.map((r) => r.high));
608
+ const room = (hi0 - lo0 || 1) * 0.1;
609
+ let lo = Math.min(lo0 - room * 0.5, -room), hi = Math.max(hi0 + room * 0.5, room);
610
+ const spanW = Math.ceil(widest(host, rows.map((r) => r.span), t.small));
611
+ const labelW = Math.ceil(widest(host, rows.map((r) => r.label), t.body));
612
+ const side = 12, plotW = width - 2 * side, ticks = Math.max(3, Math.min(6, Math.floor(plotW / 120)));
613
+ // Room each side of zero for the words saying what that side means, so they meet at the zero line.
614
+ if (spec.sides) {
615
+ const need = (txt) => Math.min(plotW * 0.35, widest(host, [txt], t.small) + 24);
616
+ const nb = need('← ' + spec.sides.below), na = need(spec.sides.above + ' →');
617
+ if (-lo / (hi - lo) * plotW < nb) lo = -(nb * hi) / (plotW - nb);
618
+ if (hi / (hi - lo) * plotW < na) hi = (na * -lo) / (plotW - na);
619
+ }
620
+ // Each row is a line of text (its label at the left, its value and range at the right) over its range. y runs in
621
+ // row units from the top: the text sits at a third of the row, the range at 0.72 of it.
622
+ const rowH = 84, T = 0.32, M = 0.72;
623
+ const halo = { stroke: t.paper, strokeWidth: 8, paintOrder: 'stroke' };
624
+ const tick = (d) => signed(d, Math.max(0, places - 1)) + (u.length <= 2 ? u : '');
625
+ const raw = (hi - lo) / ticks, mag = 10 ** Math.floor(Math.log10(raw)), step = [1, 2, 2.5, 5, 10].map((k) => k * mag).find((k) => (hi - lo) / k <= ticks + 1);
626
+ const tickVals = []; for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) tickVals.push(+v.toFixed(10));
627
+ // The grid and the zero line run from just under each row's text to the next row, never through text.
628
+ const spans = rows.map((r) => ({ y1: r.i + 0.5, y2: r.i + 1 }));
629
+ const nW = rows[0].n ? Math.ceil(widest(host, rows.map((r) => r.n), t.small)) : 0;
630
+ plotInto(host, t, {
631
+ width, height: rows.length * rowH + 40, marginLeft: side, marginRight: side, marginTop: 0, marginBottom: 40,
632
+ x: { domain: [lo, hi], ticks: tickVals, label: null, tickFormat: tick, tickSize: 0 },
633
+ y: { domain: [0, rows.length], range: [0, rows.length * rowH], axis: null },
634
+ marks: [
635
+ ...tickVals.filter((v) => v !== 0).map((v) => Plot.ruleX(spans, { x: () => v, y1: 'y1', y2: 'y2', stroke: t.hair })),
636
+ Plot.ruleX(spans, { x: () => 0, y1: 'y1', y2: 'y2', stroke: t.ink, strokeWidth: 1.5 }),
637
+ Plot.link(rows, { x1: 'low', x2: 'high', y1: (r) => r.i + M, y2: (r) => r.i + M, stroke: 'color', strokeWidth: 3 }),
638
+ Plot.ruleX(rows.flatMap((r) => [{ ...r, x: r.low }, { ...r, x: r.high }]), { x: 'x', y1: (r) => r.i + M - 9 / rowH, y2: (r) => r.i + M + 9 / rowH, stroke: 'color', strokeWidth: 3 }),
639
+ Plot.dot(rows, { x: 'value', y: (r) => r.i + M, r: 7.5, fill: (r) => (r.clear ? r.color : t.paper), stroke: 'color', strokeWidth: 2.5 }),
640
+ Plot.text(rows, { y: (r) => r.i + T, frameAnchor: 'left', dx: -side, text: 'label', textAnchor: 'start', fill: t.ink, fontSize: t.body, ...halo }),
641
+ nW ? Plot.text(rows, { y: (r) => r.i + T, frameAnchor: 'left', dx: -side + labelW + 12, text: 'n', textAnchor: 'start', fill: t.soft, fontSize: t.small, ...halo }) : null,
642
+ Plot.text(rows, { y: (r) => r.i + T, frameAnchor: 'right', dx: side - spanW - 12, text: 'val', textAnchor: 'end', fill: (r) => (r.clear ? r.color : t.muted), fontWeight: (r) => (r.clear ? 600 : 400), fontSize: t.body, ...halo }),
643
+ Plot.text(rows, { y: (r) => r.i + T, frameAnchor: 'right', dx: side, text: 'span', textAnchor: 'end', fill: t.muted, fontSize: t.small, ...halo }),
644
+ ],
645
+ });
646
+ // What each side of zero means, under the axis, each reading away from the zero line.
647
+ if (spec.sides) {
648
+ const x0 = host.querySelector('svg').scale('x').apply(0);
649
+ const p = document.createElement('div');
650
+ p.className = 'est-sides';
651
+ p.innerHTML = `<span>← ${esc(spec.sides.below)}</span><span>${esc(spec.sides.above)} →</span>`;
652
+ host.appendChild(p);
653
+ const [below, above] = p.children;
654
+ const bw = below.offsetWidth, aw = above.offsetWidth;
655
+ if (x0 - 12 - bw < 0) window.fig?.problems.push(`sides.below: "${spec.sides.below}" is too long to end at the zero line; shorten it`);
656
+ if (x0 + 12 + aw > width) window.fig?.problems.push(`sides.above: "${spec.sides.above}" is too long to start at the zero line; shorten it`);
657
+ const bLeft = Math.max(0, x0 - 12 - bw);
658
+ const aLeft = Math.min(width - aw, Math.max(x0 + 12, bLeft + bw + 24));
659
+ below.style.left = `${bLeft}px`;
660
+ above.style.left = `${aLeft}px`;
661
+ }
662
+ }
663
+
664
+ /** The estimates key, under the reading line: what the ranges are, and what a filled or hollow dot says. */
665
+ function estimatesKey(spec) {
666
+ const clear = spec.rows.map((r) => r.low > 0 || r.high < 0);
667
+ const p = document.createElement('p');
668
+ p.className = 'range-key';
669
+ const ink = spec.better ? 'var(--ink)' : 'var(--accent)';
670
+ p.innerHTML = `<span><i class="whisker"></i>${esc(spec.range)}</span>`
671
+ + (clear.some(Boolean) ? `<span><i class="est-dot" style="background:${ink};border-color:${ink}"></i>clear of 0</span>` : '')
672
+ + (clear.some((c) => !c) ? '<span><i class="est-dot" style="border-color:var(--soft)"></i>includes 0</span>' : '');
673
+ return p;
674
+ }
675
+
522
676
  // ---------- every value as a dot, two measures against each other, small counts as units ----------
523
677
 
524
678
  /**
@@ -0,0 +1,121 @@
1
+ /* The "map" spec: a whole system, its parts grouped by subsystem and the links between them. spec.js adds it to the union. */
2
+ import { z } from 'zod';
3
+
4
+ const slug = z.string({ error: 'needs an id, e.g. "queue"' }).regex(/^[a-z][a-z0-9-]{0,31}$/, 'ids are short lowercase words, e.g. "queue" or "canary-gate"');
5
+ const tone = z.literal('accent', { error: 'tone: only "accent", on what the question is about' }).optional();
6
+
7
+ /** Limits, kept in one place so the guide and the refusals say the same numbers. */
8
+ export const MAP_LIMITS = { parts: 24, minParts: 6, subsystems: 6, perSubsystem: 8, links: 30, accentLinks: 28, sideLinks: 3 };
9
+
10
+ /**
11
+ * A system with many parts: each part sits in one subsystem, and each link says one part uses, calls or feeds another.
12
+ * figkit lays it out; the author only says what exists and what connects.
13
+ */
14
+ export const mapShape = {
15
+ subsystems: z.array(z.object({
16
+ id: slug,
17
+ name: z.string({ error: 'needs a name' }).trim().min(1, 'needs a name').max(32, 'keep subsystem names to 32 characters'),
18
+ kind: z.string().trim().min(1).max(36, 'keep the kind to 36 characters: where it lives, e.g. "ash_builder/detectors/"').optional(),
19
+ }).strict()).min(2, 'at least 2 subsystems; with one, draw a plain figure').max(MAP_LIMITS.subsystems, `at most ${MAP_LIMITS.subsystems} subsystems; more do not read as regions, so merge the small ones or draw two maps`),
20
+ parts: z.array(z.object({
21
+ id: slug,
22
+ name: z.string({ error: 'needs a name' }).trim().min(1, 'needs a name').max(28, 'keep part names to 28 characters'),
23
+ kind: z.string().trim().min(1).max(32, 'keep the kind to 32 characters: what it is or the file it lives in').optional(),
24
+ in: slug.optional(),
25
+ tone,
26
+ }).strict()).min(MAP_LIMITS.minParts, `at least ${MAP_LIMITS.minParts} parts; fewer is a plain figure of at most 5 boxes`).max(MAP_LIMITS.parts, `at most ${MAP_LIMITS.parts} parts; past that the links tangle even full screen, so draw the subsystems as one map and the busiest subsystem as a second`),
27
+ links: z.array(z.object({
28
+ from: slug,
29
+ to: slug,
30
+ label: z.string().trim().min(1).max(22, 'keep link labels to 22 characters: a verb or the thing passed, e.g. "writes rows"').optional(),
31
+ tone,
32
+ }).strict()).min(1, 'at least 1 link').max(MAP_LIMITS.links, `at most ${MAP_LIMITS.links} links; more cross too often to follow, so leave out links the question does not need and say so in the note`),
33
+ };
34
+
35
+ const list = (ids) => ids.map((x) => `"${x}"`).join(', ');
36
+ /** The id one or two letters away from a mistyped one, if there is exactly one. */
37
+ function near(id, ids) {
38
+ const d = (a, b) => {
39
+ const row = Array.from({ length: b.length + 1 }, (_, j) => j);
40
+ for (let i = 1; i <= a.length; i++) {
41
+ let prev = row[0]; row[0] = i;
42
+ for (let j = 1; j <= b.length; j++) { const t = row[j]; row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1)); prev = t; }
43
+ }
44
+ return row[b.length];
45
+ };
46
+ const close = ids.filter((x) => d(id, x) <= 2);
47
+ return close.length === 1 ? `; did you mean "${close[0]}"?` : '';
48
+ }
49
+
50
+ /** Rules that span fields: ids that exist, every part in a subsystem, every part linked, no repeats. */
51
+ export function mapChecks(s) {
52
+ const out = [];
53
+ const subIds = s.subsystems.map((x) => x.id), partIds = s.parts.map((x) => x.id);
54
+ const all = [...subIds, ...partIds];
55
+ const dup = all.find((id, i) => all.indexOf(id) !== i);
56
+ if (dup) return [`ids: "${dup}" is used twice; every part and subsystem needs its own id`];
57
+ const names = s.parts.map((p) => p.name.toLowerCase());
58
+ const twice = s.parts.find((p, i) => names.indexOf(p.name.toLowerCase()) !== i);
59
+ if (twice) out.push(`parts: two parts are named "${twice.name}"; name each part so the reader can tell them apart`);
60
+
61
+ s.parts.forEach((p, i) => {
62
+ if (p.in == null) out.push(`parts[${i}]: "${p.id}" is in no subsystem; give it "in", one of ${list(subIds)}. Something outside the system still goes in a subsystem, such as "Outside"`);
63
+ else if (!subIds.includes(p.in)) out.push(`parts[${i}].in: no subsystem has the id "${p.in}"${partIds.includes(p.in) ? ' (that is a part; parts cannot hold parts)' : ''}; the subsystems are ${list(subIds)}`);
64
+ });
65
+ s.links.forEach((l, i) => {
66
+ for (const end of ['from', 'to']) if (!partIds.includes(l[end])) {
67
+ out.push(`links[${i}].${end}: no part has the id "${l[end]}"${subIds.includes(l[end]) ? '; that is a subsystem, and links join parts, so name the part inside it' : near(l[end], partIds)}`);
68
+ }
69
+ });
70
+ if (out.length) return out;
71
+
72
+ for (const sub of s.subsystems) {
73
+ const n = s.parts.filter((p) => p.in === sub.id).length;
74
+ if (n === 0) out.push(`subsystems: "${sub.id}" has no parts; put parts in it with "in": "${sub.id}", or leave it out`);
75
+ if (n > MAP_LIMITS.perSubsystem) out.push(`subsystems: "${sub.id}" has ${n} parts, more than ${MAP_LIMITS.perSubsystem}; split it in two, or merge parts the question does not need`);
76
+ }
77
+ const seen = new Map();
78
+ s.links.forEach((l, i) => {
79
+ if (l.from === l.to) out.push(`links[${i}]: "${l.from}" links to itself; say what it does to itself in its kind instead`);
80
+ const key = l.from + '>' + l.to;
81
+ if (seen.has(key)) out.push(`links[${i}]: a second link from "${l.from}" to "${l.to}" (links[${seen.get(key)}] is the first); make it one link with one label`);
82
+ const back = seen.get(l.to + '>' + l.from);
83
+ if (back != null) out.push(`links[${i}]: "${l.from}" and "${l.to}" link both ways (links[${back}] is the other); keep the one from the part that starts the exchange, the reply is implied`);
84
+ seen.set(key, i);
85
+ });
86
+ const loose = partIds.filter((id) => !s.links.some((l) => l.from === id || l.to === id));
87
+ if (loose.length) out.push(`parts: ${list(loose)} ${loose.length === 1 ? 'is' : 'are'} in no link; a part with no link says nothing on a map, so link ${loose.length === 1 ? 'it' : 'them'} or leave ${loose.length === 1 ? 'it' : 'them'} out`);
88
+ const accented = s.parts.filter((p) => p.tone).length + s.links.filter((l) => l.tone).length;
89
+ if (accented && accented === s.parts.length + s.links.length) out.push('tone: everything is accented, so nothing stands out; accent only what the question is about');
90
+ if (s.links.some((l) => l.tone)) {
91
+ // Measured on two real maps: the highlighted path stays one straight run at 28 links, but not at 29 or 30, and not with 4 grey links on one part of it.
92
+ if (s.links.length > MAP_LIMITS.accentLinks) out.push(`links: ${s.links.length} links with a highlighted path; at most ${MAP_LIMITS.accentLinks}, or figkit cannot keep the path one straight run, so leave out links the question does not need and say so in the note`);
93
+ const onPath = new Set(s.parts.filter((p) => p.tone).map((p) => p.id));
94
+ for (const l of s.links) if (l.tone) { onPath.add(l.from); onPath.add(l.to); }
95
+ for (const id of onPath) {
96
+ const grey = s.links.filter((l) => !l.tone && (l.from === id || l.to === id));
97
+ if (grey.length > MAP_LIMITS.sideLinks) out.push(`links: "${id}" is on the highlighted path and has ${grey.length} grey links (${grey.map((l) => `${l.from} → ${l.to}`).join(', ')}); a part on the path holds at most ${MAP_LIMITS.sideLinks}, because the path takes its top and bottom and the grey links must come in from the sides. Leave out the ones the question does not need`);
98
+ }
99
+ }
100
+ if (s.parts.filter((p) => p.tone).length > 8) out.push('parts: more than 8 parts are accented; a map points at one corner of the system, so accent only that corner');
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * The map with each subsystem as one box: the links between subsystems counted, the links inside one left out.
106
+ * For the phone layout, where the full map cannot be read.
107
+ */
108
+ export function collapse(s) {
109
+ const where = Object.fromEntries(s.parts.map((p) => [p.id, p.in]));
110
+ const pairs = new Map();
111
+ for (const l of s.links) {
112
+ const a = where[l.from], b = where[l.to];
113
+ if (a === b) continue;
114
+ const key = a + '>' + b;
115
+ const e = pairs.get(key) ?? { from: a, to: b, count: 0, tone: undefined };
116
+ e.count++;
117
+ if (l.tone) e.tone = 'accent';
118
+ pairs.set(key, e);
119
+ }
120
+ return [...pairs.values()];
121
+ }