@liggi/figkit 0.1.0 → 0.2.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,17 @@
1
+ {
2
+ "type": "entities",
3
+ "title": "Every order line names one product and one order",
4
+ "records": [
5
+ { "id": "customer", "name": "Customer", "fields": ["email"] },
6
+ { "id": "order", "name": "Order", "fields": ["placed at", "status"] },
7
+ { "id": "line", "name": "Order line", "fields": ["quantity"], "tone": "accent" },
8
+ { "id": "product", "name": "Product", "fields": ["sku", "price"] },
9
+ { "id": "payment", "name": "Payment", "fields": ["amount"] }
10
+ ],
11
+ "refs": [
12
+ { "from": "order", "to": "customer" },
13
+ { "from": "line", "to": "order" },
14
+ { "from": "line", "to": "product" },
15
+ { "from": "payment", "to": "order", "count": "one", "optional": true }
16
+ ]
17
+ }
@@ -4,13 +4,17 @@
4
4
  <p class="read">Same behaviour at first, then changed in one place.</p>
5
5
  </header>
6
6
  <div class="row wide">
7
- <div class="col grow" id="today" style="gap: 10px">
7
+ <div class="col grow" style="gap: 10px">
8
8
  <span class="lane-title">Today: five places</span>
9
- <div class="node"><h3>Prices</h3><p>Spreadsheet</p></div>
10
- <div class="node"><h3>Delivery zones</h3><p>Database table</p></div>
11
- <div class="node"><h3>Opening hours</h3><p>Admin page</p></div>
12
- <div class="node"><h3>Discount rules</h3><p>Code</p></div>
13
- <div class="node"><h3>Email wording</h3><p>Templates folder</p></div>
9
+ <div class="node grow" id="today" style="gap: 14px; padding: 26px 28px">
10
+ <span class="num t-muted">5 owners</span>
11
+ <h3>Prices <span class="tag">spreadsheet</span></h3>
12
+ <h3>Delivery zones <span class="tag">database</span></h3>
13
+ <h3>Opening hours <span class="tag">admin page</span></h3>
14
+ <h3>Discount rules <span class="tag">code</span></h3>
15
+ <h3>Email wording <span class="tag">templates</span></h3>
16
+ <p style="margin-top: auto">Each changed its own way</p>
17
+ </div>
14
18
  </div>
15
19
  <div class="col grow" style="gap: 10px">
16
20
  <span class="lane-title t-accent">Proposed: one file per shop</span>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liggi/figkit",
3
- "version": "0.1.0",
3
+ "version": "0.2.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
@@ -57,7 +57,7 @@ html, body { margin: 0; padding: 0; background: transparent; }
57
57
  .fig p { margin: 0; }
58
58
  .fig .note { font-size: var(--small); color: var(--muted); max-width: 34ch; }
59
59
  .fig .num { font-family: var(--serif); font-size: var(--num); line-height: 1; letter-spacing: -.01em; }
60
- .fig .tag { font-family: var(--mono); font-size: var(--small); color: var(--soft); }
60
+ .fig .tag { font-family: var(--mono); font-size: var(--small); font-weight: 400; color: var(--soft); }
61
61
  .fig code { font-family: var(--mono); font-size: .92em; }
62
62
  .fig .t-accent { color: var(--accent); } .fig .t-second { color: var(--second); }
63
63
  .fig .t-good { color: var(--good); } .fig .t-bad { color: var(--bad); }
@@ -118,6 +118,12 @@ html, body { margin: 0; padding: 0; background: transparent; }
118
118
  .fig hr { border: 0; border-top: 1px solid var(--hair); margin: 0; width: 100%; }
119
119
  .fig .divide { border-left: 1px solid var(--hair); align-self: stretch; }
120
120
 
121
+ /* ---------- entities: records and the references between them (a spec of type "entities") ---------- */
122
+ .fig .entities { display: grid; column-gap: 110px; row-gap: 96px; align-items: start; }
123
+ .fig.phone .entities { column-gap: 72px; row-gap: 84px; }
124
+ .fig .record > ul { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
125
+ .fig .record > ul li { font-size: var(--small); color: var(--muted); }
126
+
121
127
  /* ---------- arrows (drawn by fig.js) ---------- */
122
128
  fig-arrow { display: none; }
123
129
  .fig > svg.fig-wires { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
package/runtime/fig.js CHANGED
@@ -63,6 +63,9 @@
63
63
  const DIRS = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] };
64
64
  function ports(el, r, other, allowed, at) {
65
65
  const diamond = el.classList.contains('decision');
66
+ // A record is joined at the side of its name, never beside a field, which a line there would seem to be.
67
+ const h3 = el.classList.contains('record') && el.querySelector(':scope > h3');
68
+ const head = h3 && (() => { const t = rectOf(h3), line = Math.min(t.h, parseFloat(getComputedStyle(h3).lineHeight) || t.h); return { mid: t.y + line / 2, lo: r.y + 12, hi: Math.max(t.y + t.h + 6, r.y + 12) }; })();
66
69
  const out = [];
67
70
  for (const s of allowed) {
68
71
  const d = DIRS[s];
@@ -76,10 +79,20 @@
76
79
  const lo = Math.max(r.y, other.y), hi = Math.min(r.y + r.h, other.y + other.h);
77
80
  y = !diamond && hi - lo >= 24 ? (lo + hi) / 2 : r.y + r.h / 2;
78
81
  if (at != null && !diamond) y = r.y + r.h * at;
82
+ if (head) y = Math.min((head.lo + head.hi) / 2, head.mid + 4); // the first line of the name, so a wrapped name joins level with its row
79
83
  x = s === 'left' ? r.x : r.x + r.w;
80
84
  }
81
85
  x = Math.round(x); y = Math.round(y);
82
- out.push({ s, d, x, y, sx: x + d[0] * M, sy: y + d[1] * M });
86
+ out.push({ s, d, x, y, sx: x + d[0] * M, sy: y + d[1] * M, off: 0 });
87
+ // Spares beside the chosen point, for when another line already ends there.
88
+ if (at != null || diamond) continue;
89
+ for (const k of [-1, 1, -2, 2]) {
90
+ const band = head && d[0] !== 0;
91
+ const v = (d[0] === 0 ? x : y) + k * (band ? 18 : 26), lo = band ? head.lo : (d[0] === 0 ? r.x : r.y) + 14, hi = band ? head.hi : (d[0] === 0 ? r.x + r.w : r.y + r.h) - 14;
92
+ if (v < lo || v > hi) continue;
93
+ const px = d[0] === 0 ? v : x, py = d[0] === 0 ? y : v;
94
+ out.push({ s, d, x: px, y: py, sx: px + d[0] * M, sy: py + d[1] * M, off: Math.abs(k) });
95
+ }
83
96
  }
84
97
  return out;
85
98
  }
@@ -99,12 +112,16 @@
99
112
  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));
100
113
  };
101
114
  const pa = ports(A.el, A.r, B.r, arrow.fromSides, arrow.fromAt), pb = ports(B.el, B.r, A.r, arrow.toSides, arrow.toAt);
115
+ // Arrows from one box share a trunk, and so do arrows into one box; references only share the end they land on.
116
+ const bus = (u) => ((u.from === arrow.from && !arrow.ref) || u.to === arrow.to) && u.tone === arrow.tone;
117
+ // A point where another line already starts or ends costs a lot; a spare beside it costs a little.
118
+ const portCost = (p) => p.off * 18 + (used.some((u) => !bus(u) && ((Math.abs(u.x1 - p.x) < 12 && Math.abs(u.y1 - p.y) < 12 && u.first) || (Math.abs(u.x2 - p.x) < 12 && Math.abs(u.y2 - p.y) < 12 && u.last))) ? 140 : 0);
102
119
 
103
120
  // Fast path: the two boxes face each other, so a straight line is the whole route.
104
121
  const facing = [['right', 'left'], ['left', 'right'], ['bottom', 'top'], ['top', 'bottom']];
105
122
  for (const [sa, sb] of facing) {
106
- const p = pa.find((q) => q.s === sa), q = pb.find((t) => t.s === sb);
107
- if (!p || !q) continue;
123
+ const p = pa.find((q) => q.s === sa && !q.off), q = pb.find((t) => t.s === sb && !t.off);
124
+ if (!p || !q || portCost(p) || portCost(q)) continue;
108
125
  const ahead = (q.x - p.x) * p.d[0] + (q.y - p.y) * p.d[1];
109
126
  const aligned = p.d[0] === 0 ? Math.abs(p.x - q.x) < 1 : Math.abs(p.y - q.y) < 1;
110
127
  if (ahead > 6 && aligned && clear(p.x, p.y, q.x, q.y)) return [[p.x, p.y], [q.x, q.y]];
@@ -139,7 +156,6 @@
139
156
  for (const r of soft) if (inside(r, x, y)) return 5;
140
157
  return 1;
141
158
  };
142
- const bus = (u) => (u.from === arrow.from || u.to === arrow.to) && u.tone === arrow.tone;
143
159
  const usedCost = (x1, y1, x2, y2) => {
144
160
  let k = 1, plus = 0;
145
161
  const horiz = y1 === y2;
@@ -150,19 +166,19 @@
150
166
  if (off >= 10) continue;
151
167
  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));
152
168
  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));
153
- if (hi - lo <= 0.5) continue;
154
- if (off < 0.5 && bus(u)) k = Math.min(k, 0.85); else k = Math.max(k, 4);
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);
155
171
  } else if (!bus(u)) {
156
172
  const cx = horiz ? u.x1 : x1, cy = horiz ? y1 : u.y1;
157
- const onMe = horiz ? cx > Math.min(x1, x2) && cx < Math.max(x1, x2) : cy > Math.min(y1, y2) && cy < Math.max(y1, y2);
173
+ // Strictly inside this step, or at its far end: two lines that meet on a grid point cross there too.
174
+ 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;
158
175
  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);
159
- if (onMe && onU) plus += 70;
176
+ if (onMe && onU) plus += arrow.ref ? 1500 : 70;
160
177
  }
161
178
  }
162
179
  return [k, plus];
163
180
  };
164
181
 
165
- const portCost = (p) => (used.some((u) => !bus(u) && ((Math.abs(u.x1 - p.x) < 12 && Math.abs(u.y1 - p.y) < 12 && u.first) || (Math.abs(u.x2 - p.x) < 12 && Math.abs(u.y2 - p.y) < 12 && u.last))) ? 140 : 0);
166
182
  // Dijkstra over (column, row, heading).
167
183
  const BEND = 64;
168
184
  const id = (ix, iy, d) => (iy * NX + ix) * 4 + d;
@@ -294,8 +310,9 @@
294
310
  if (!a || !b) { fig.problems.push(`Arrow ${from} → ${to}: there is no element with id "${!a ? from : to}".`); continue; }
295
311
  const tone = TONES.find((t) => tag.hasAttribute(t) || tag.getAttribute('tone') === t) ?? (tag.getAttribute('tone') === 'ink' ? 'ink' : 'line');
296
312
  const color = css('--' + tone);
313
+ const ref = tag.getAttribute('ref');
297
314
  const arrow = {
298
- from, to, tone, fromSides: sides(tag.getAttribute('from-side')), toSides: sides(tag.getAttribute('to-side')),
315
+ from, to, tone, ref, fromSides: sides(tag.getAttribute('from-side')), toSides: sides(tag.getAttribute('to-side')),
299
316
  fromAt: num(tag.getAttribute('from-at')), toAt: num(tag.getAttribute('to-at')),
300
317
  };
301
318
  const A = { el: a, r: rectOf(a) }, B = { el: b, r: rectOf(b) };
@@ -310,7 +327,7 @@
310
327
  if (mode === 'ortho') {
311
328
  pts = routeOrtho(A, B, mine, arrow, used, W, H);
312
329
  if (!pts) { fig.problems.push(`Arrow ${from} → ${to}: no route found. Leave a gap beside the boxes, or set from-side / to-side.`); continue; }
313
- for (let i = 1; i < pts.length; i++) used.push({ x1: pts[i - 1][0], y1: pts[i - 1][1], x2: pts[i][0], y2: pts[i][1], from, to, tone, first: i === 1, last: i === pts.length - 1 });
330
+ for (let i = 1; i < pts.length; i++) used.push({ x1: pts[i - 1][0], y1: pts[i - 1][1], x2: pts[i][0], y2: pts[i][1], from, to, tone, ref, first: i === 1, last: i === pts.length - 1 });
314
331
  } else {
315
332
  const { p, q } = routeLoose(A, B, arrow);
316
333
  if (mode === 'straight') {
@@ -328,26 +345,121 @@
328
345
  // Heads first, so the line stops at the base of each head.
329
346
  let start = pts[0], end = pts[pts.length - 1];
330
347
  const beforeEnd = pts.curve ? pts.curve[1] : pts[pts.length - 2], afterStart = pts.curve ? pts.curve[0] : pts[1];
331
- if (heads === 'blocked') end = blockedEnd(svg, end, beforeEnd, color, weight);
332
- if (heads === 'end' || heads === 'both') end = head(svg, end, beforeEnd, color, size);
333
- if (heads === 'both') start = head(svg, start, afterStart, color, size);
348
+ // A reference has no head: a dot on the record it names, and on the holder a crow's foot when many can hold
349
+ // one, a short bar when only one can.
350
+ if (ref) svgEl('circle', { cx: end[0], cy: end[1], r: weight + 4, fill: color }, svg);
351
+ if (ref && !pts.curve) {
352
+ const [ax, ay] = pts[0], [bx, by] = pts[1], len = Math.hypot(bx - ax, by - ay) || 1, ux = (bx - ax) / len, uy = (by - ay) / len;
353
+ const mark = (x1, y1, x2, y2) => svgEl('line', { x1, y1, x2, y2, stroke: color, 'stroke-width': weight, 'stroke-linecap': 'round' }, svg);
354
+ if (ref === 'many') {
355
+ const tip = [ax + ux * 20, ay + uy * 20];
356
+ for (const k of [-1, 0, 1]) mark(tip[0], tip[1], ax - uy * k * 11, ay + ux * k * 11);
357
+ } else {
358
+ const at = [ax + ux * 12, ay + uy * 12];
359
+ mark(at[0] - uy * 7, at[1] + ux * 7, at[0] + uy * 7, at[1] - ux * 7);
360
+ }
361
+ }
362
+ else if (heads === 'blocked') end = blockedEnd(svg, end, beforeEnd, color, weight);
363
+ if (!ref && (heads === 'end' || heads === 'both')) end = head(svg, end, beforeEnd, color, size);
364
+ if (!ref && heads === 'both') start = head(svg, start, afterStart, color, size);
334
365
  const line = [start, ...pts.slice(1, -1), end];
335
366
  pathD = pts.curve ? `M${start[0]},${start[1]} C${pts.curve[0][0]},${pts.curve[0][1]} ${pts.curve[1][0]},${pts.curve[1][1]} ${end[0]},${end[1]}` : roundedPath(line);
336
367
  const attrs = { d: pathD, fill: 'none', stroke: color, 'stroke-width': weight, 'stroke-linecap': 'round' };
337
368
  if (dash) attrs['stroke-dasharray'] = dash;
338
369
  svgEl('path', attrs, svg);
339
- fig.wires.push({ from, to, pts, mode });
370
+ fig.wires.push({ from, to, pts, mode, ref });
340
371
 
341
- const text = tag.getAttribute('label') ?? tag.textContent.trim();
372
+ const text = ref ? '' : tag.getAttribute('label') ?? tag.textContent.trim();
342
373
  if (text) labels.push({ text, tone, color, pts, at: labelAt, want: tag.getAttribute('label-at'), from, to });
343
374
  }
344
- placeLabels(labels, obs);
375
+ return placeLabels(labels, obs);
376
+ }
377
+
378
+ /** Pairs of drawn lines that cross. Arrows from one box, or into one box, share a trunk, so meeting is not crossing. */
379
+ function crossingPairs() {
380
+ 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
+ const out = [];
382
+ for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) {
383
+ const s = segs[i], t = segs[j];
384
+ if (s.w === t.w || (s.w.from === t.w.from && !(s.w.ref && t.w.ref)) || s.w.to === t.w.to) continue;
385
+ const sh = s.a[1] === s.b[1], th = t.a[1] === t.b[1];
386
+ if (sh === th) continue;
387
+ const h = sh ? s : t, v = sh ? t : s;
388
+ const x = v.a[0], y = h.a[1];
389
+ if (x > Math.min(h.a[0], h.b[0]) + 1 && x < Math.max(h.a[0], h.b[0]) - 1 && y > Math.min(v.a[1], v.b[1]) + 1 && y < Math.max(v.a[1], v.b[1]) - 1
390
+ && !out.some(([u, w]) => u === s.w && w === t.w)) out.push([s.w, t.w]);
391
+ }
392
+ return out;
393
+ }
394
+
395
+ /**
396
+ * A label that found no free place: open the gap between its two boxes so it has one, and say whether anything
397
+ * changed. The gap grows on the row, column or grid that holds both boxes, between the parts holding each.
398
+ */
399
+ function widen(fails, tried) {
400
+ let changed = false;
401
+ for (const f of fails) {
402
+ const key = f.from + '>' + f.to;
403
+ if (tried.has(key)) continue;
404
+ tried.set(key, null);
405
+ const a = document.getElementById(f.from), b = document.getElementById(f.to);
406
+ let lca = a.parentElement;
407
+ while (lca && !lca.contains(b)) lca = lca.parentElement;
408
+ if (!lca || !root().contains(lca)) continue;
409
+ const ca = [...lca.children].find((c) => c.contains(a)), cb = [...lca.children].find((c) => c.contains(b));
410
+ if (!ca || !cb || ca === cb) continue;
411
+ const cs = getComputedStyle(lca);
412
+ if (!/flex|grid/.test(cs.display)) continue;
413
+ const ra = ca.getBoundingClientRect(), rb = cb.getBoundingClientRect();
414
+ const across = ra.right <= rb.left + 1 || rb.right <= ra.left + 1;
415
+ const now = across ? Math.max(rb.left - ra.right, ra.left - rb.right) : Math.max(rb.top - ra.bottom, ra.top - rb.bottom);
416
+ const more = Math.max((across ? f.w : f.h) + 64 - now, 24);
417
+ const R = root();
418
+ const undo = { el: lca, prop: across ? 'columnGap' : 'rowGap', gap: lca.style[across ? 'columnGap' : 'rowGap'], w: R.style.width, v: R.style.getPropertyValue('--w') };
419
+ lca.style[undo.prop] = (parseFloat(cs[undo.prop]) || 0) + more + 'px';
420
+ // A wider gap must not squeeze a box until its text overflows: the figure grows instead, by at most a third.
421
+ if (across && squeezed()) {
422
+ const base = Number(R.dataset.baseWidth ||= String(R.offsetWidth));
423
+ const gaps = /grid/.test(cs.display) ? cs.gridTemplateColumns.split(' ').length - 1 : [...lca.children].filter((c) => c.offsetParent !== null).length - 1;
424
+ const wider = R.offsetWidth + more * Math.max(gaps, 1);
425
+ if (wider > base * 1.34) { lca.style[undo.prop] = undo.gap; continue; }
426
+ R.style.setProperty('--w', wider + 'px');
427
+ R.style.width = wider + 'px';
428
+ if (squeezed()) { lca.style[undo.prop] = undo.gap; R.style.width = undo.w; if (undo.v) R.style.setProperty('--w', undo.v); else R.style.removeProperty('--w'); continue; }
429
+ }
430
+ tried.set(key, undo);
431
+ changed = true;
432
+ }
433
+ return changed;
434
+ }
435
+
436
+ /** Whether any text now runs past the right edge of its box. */
437
+ function squeezed() {
438
+ return [...root().querySelectorAll('.node')].some((n) => {
439
+ const b = rectOf(n);
440
+ return [...n.querySelectorAll('*')].some((e) => [...e.childNodes].some((c) => c.nodeType === 3 && c.textContent.trim()) && (() => { const t = textRect(e); return t.w && t.x + t.w > b.x + b.w + 1; })());
441
+ });
442
+ }
443
+
444
+ /** Puts back a widening that did not free its label (an arrow that runs around the outside, not through the gap). */
445
+ function unwiden(fails, tried) {
446
+ let changed = false;
447
+ for (const f of fails) {
448
+ const undo = tried.get(f.from + '>' + f.to);
449
+ if (!undo) continue;
450
+ undo.el.style[undo.prop] = undo.gap;
451
+ root().style.width = undo.w;
452
+ if (undo.v) root().style.setProperty('--w', undo.v); else root().style.removeProperty('--w');
453
+ tried.set(f.from + '>' + f.to, null);
454
+ changed = true;
455
+ }
456
+ return changed;
345
457
  }
346
458
 
347
459
  /** Put each arrow label where it touches nothing: on its line if the line is long enough, otherwise beside it. */
348
460
  function placeLabels(labels, obs) {
349
461
  const R = root(), W = R.offsetWidth, H = R.offsetHeight;
350
- const placed = [];
462
+ const placed = [], fails = [];
351
463
  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]) }));
352
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)
353
465
  && 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)));
@@ -396,11 +508,12 @@
396
508
  if (placed.some((p) => overlap(grow(r, 4), p))) return false;
397
509
  return !wireHit(grow(r, 2), cand.on);
398
510
  };
399
- const pick = cands.find(free) ?? cands[0];
511
+ const pick = cands.find(free) ?? (fails.push({ from: L.from, to: L.to, w, h }), cands[0]);
400
512
  lab.style.left = pick.c[0] + 'px';
401
513
  lab.style.top = pick.c[1] + 'px';
402
514
  placed.push(rectAt(pick.c));
403
515
  }
516
+ return fails;
404
517
  }
405
518
 
406
519
  // ---------- split bar and bars (drawn by viz/draw.js, loaded as window.figkitViz) ----------
@@ -503,17 +616,10 @@
503
616
  if (hit) P.push(`Arrow ${w.from} → ${w.to} runs through ${name(o.el)}.`);
504
617
  }
505
618
  }
506
- const crossings = new Set();
507
- for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) {
508
- const s = segs[i], t = segs[j];
509
- if (s.w === t.w || s.w.from === t.w.from || s.w.to === t.w.to) continue;
510
- const sh = s.a[1] === s.b[1], th = t.a[1] === t.b[1];
511
- if (sh === th) continue;
512
- const h = sh ? s : t, v = sh ? t : s;
513
- const x = v.a[0], y = h.a[1];
514
- if (x > Math.min(h.a[0], h.b[0]) + 1 && x < Math.max(h.a[0], h.b[0]) - 1 && y > Math.min(v.a[1], v.b[1]) + 1 && y < Math.max(v.a[1], v.b[1]) - 1) crossings.add(`${s.w.from} → ${s.w.to} and ${t.w.from} → ${t.w.to}`);
515
- }
516
- if (crossings.size) P.push(`Arrows cross: ${[...crossings].join('; ')}. Reorder the boxes so they do not, or drop an arrow.`);
619
+ const crossings = new Set(crossingPairs().map(([u, w]) => `${u.from} → ${u.to} and ${w.from} → ${w.to}`));
620
+ if (crossings.size) P.push(R.querySelector('.entities')
621
+ ? `References cross: ${[...crossings].join('; ')}. Split the records over two figures; do not leave a reference out.`
622
+ : `Arrows cross: ${[...crossings].join('; ')}. Reorder the boxes so they do not, or drop an arrow.`);
517
623
 
518
624
  // Neighbours that almost line up.
519
625
  const boxes = obs.filter((o) => !o.text && !o.el.classList.contains('fig-wire-label') && !o.el.classList.contains('decision') && !o.el.parentElement.closest('.node'));
@@ -521,6 +627,8 @@
521
627
  for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
522
628
  const a = boxes[i], b = boxes[j];
523
629
  if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
630
+ // Records share a top edge in their row; their heights follow their fields, which figkit placed, not the author.
631
+ if (a.el.classList.contains('record') && b.el.classList.contains('record')) continue;
524
632
  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]];
525
633
  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)`); }
526
634
  }
@@ -529,9 +637,9 @@
529
637
  // Too much on the page.
530
638
  const words = R.innerText.split(/\s+/).filter(Boolean).length;
531
639
  const nodes = R.querySelectorAll('.node:not(.bare), .decision').length, arrows = (fig.wires ?? []).length;
532
- const budget = detail ? { words: 190, nodes: 18, arrows: 18 } : { words: 70, nodes: 8, arrows: 8 };
640
+ const budget = detail ? { words: 190, nodes: 18, arrows: 18 } : { words: 70, nodes: 5, arrows: 8 };
533
641
  if (words > budget.words) P.push(`${words} words; a ${detail ? 'detail' : 'glance'} figure holds about ${budget.words}. Cut sentences that explain, and keep labels.`);
534
- if (nodes > budget.nodes) P.push(`${nodes} boxes; more than ${budget.nodes} stops reading as one picture. Split it into two figures or merge boxes.`);
642
+ 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.'}`);
535
643
  if (arrows > budget.arrows) P.push(`${arrows} arrows; more than ${budget.arrows} tangles. Show the main path and drop the rest.`);
536
644
  const tones = TONES.filter((t) => R.querySelector(`.${t}, [${t}], .t-${t}`));
537
645
  if (tones.length > 2) P.push(`${tones.length} colours carry meaning (${tones.join(', ')}); use one for what the reader must notice and at most one more.`);
@@ -564,7 +672,30 @@
564
672
  else { buildSplits(); buildBars(); }
565
673
  for (const job of fig.queue) job();
566
674
  await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
567
- drawArrows();
675
+ // A label with no free place opens the gap it needs, then everything is drawn again.
676
+ const tried = new Map();
677
+ for (let i = 0; ; i++) {
678
+ const fails = drawArrows();
679
+ if (!fails.length || i === 6) break;
680
+ const undone = unwiden(fails, tried), widened = widen(fails, tried);
681
+ if (!undone && !widened) break;
682
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
683
+ }
684
+ // Two lines that cross can often pass each other if the later one is drawn first.
685
+ for (let i = 0; i < 4; i++) {
686
+ const pairs = crossingPairs();
687
+ if (!pairs.length) break;
688
+ const tags = [...document.querySelectorAll('fig-arrow')], at = (w) => tags.find((t) => t.getAttribute('from') === w.from && t.getAttribute('to') === w.to);
689
+ const [u, w] = pairs[0], first = at(u), second = at(w);
690
+ if (!first || !second) break;
691
+ const mark = document.createComment('');
692
+ second.replaceWith(mark); first.before(second);
693
+ drawArrows();
694
+ if (crossingPairs().length < pairs.length) { mark.remove(); continue; }
695
+ second.remove(); mark.replaceWith(second);
696
+ drawArrows();
697
+ break;
698
+ }
568
699
  check();
569
700
  } catch (e) { fig.problems.push('figkit failed: ' + (e.stack || e.message)); }
570
701
  window.__figDone = true;
package/viz/draw.js CHANGED
@@ -201,12 +201,100 @@ export function drawSeries(host, spec) {
201
201
  });
202
202
  }
203
203
 
204
+ /**
205
+ * Where each record sits: every arrangement of the records over a few small grids is scored, and the best kept.
206
+ * A score counts references that cross, references that pass over another record, long references, and records drawn
207
+ * above what they point at (a reference reads upward, to the thing it names). A phone gets at most two columns.
208
+ */
209
+ export function placeRecords(records, refs, phone) {
210
+ const n = records.length;
211
+ const shapes = [];
212
+ for (let rows = 1; rows <= 3; rows++) for (let cols = 1; cols <= (phone ? 2 : 3); cols++) {
213
+ if (rows * cols >= n && rows * cols - n < Math.min(rows, cols)) shapes.push([rows, cols]);
214
+ }
215
+ const idx = new Map(records.map((r, i) => [r.id, i]));
216
+ const edges = refs.map((r) => [idx.get(r.from), idx.get(r.to)]);
217
+ const cross = (a, b, c, d) => {
218
+ const o = (p, q, r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
219
+ return o(a, b, c) * o(a, b, d) < 0 && o(c, d, a) * o(c, d, b) < 0;
220
+ };
221
+ // A record's cell is about 1.6 times as wide as it is tall, gaps included.
222
+ const pt = ([r, c]) => [c * 1.6, r];
223
+ const over = (a, b, cell) => {
224
+ const [x1, y1] = pt(a), [x2, y2] = pt(b), [cx, cy] = pt(cell);
225
+ const hw = 0.55, hh = 0.32;
226
+ for (let t = 0.04; t < 1; t += 0.04) { const x = x1 + (x2 - x1) * t, y = y1 + (y2 - y1) * t; if (Math.abs(x - cx) < hw && Math.abs(y - cy) < hh) return true; }
227
+ return false;
228
+ };
229
+ let best = null;
230
+ for (const [rows, cols] of shapes) {
231
+ const cells = [];
232
+ for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) cells.push([r, c]);
233
+ const used = new Array(cells.length).fill(false), at = new Array(n);
234
+ const score = () => {
235
+ let s = rows * 0.6 + cols * 0.3;
236
+ const pos = at.map((k) => cells[k]);
237
+ edges.forEach(([f, t], i) => {
238
+ const a = pos[f], b = pos[t];
239
+ const [x1, y1] = pt(a), [x2, y2] = pt(b);
240
+ s += Math.hypot(x2 - x1, y2 - y1) * 1.2;
241
+ if (a[0] !== b[0] && a[1] !== b[1]) s += 1.5;
242
+ if (a[0] < b[0]) s += 3;
243
+ pos.forEach((p, k) => { if (k !== f && k !== t && over(a, b, p)) s += 12; });
244
+ for (const [g, u] of edges.slice(i + 1)) if (g !== f && g !== t && u !== f && u !== t && cross(pt(a), pt(b), pt(pos[g]), pt(pos[u]))) s += 8;
245
+ });
246
+ // An empty cell in the middle of a row reads as something missing.
247
+ for (let r = 0; r < rows; r++) for (let c = 1; c < cols - 1; c++) if (!pos.some((p) => p[0] === r && p[1] === c) && pos.some((p) => p[0] === r)) s += 4;
248
+ return s;
249
+ };
250
+ const walk = (i) => {
251
+ if (i === n) { const s = score(); if (!best || s < best.s - 1e-9) best = { s, rows, cols, pos: at.map((k) => cells[k]) }; return; }
252
+ for (let k = 0; k < cells.length; k++) if (!used[k]) { used[k] = true; at[i] = k; walk(i + 1); used[k] = false; }
253
+ };
254
+ walk(0);
255
+ }
256
+ return best;
257
+ }
258
+
259
+ /** Records and the references between them into the figure: boxes on a grid figkit chose, and <fig-arrow ref> lines. */
260
+ function drawEntities(spec, main) {
261
+ const phone = main.classList.contains('phone');
262
+ const { rows, cols, pos } = placeRecords(spec.records, spec.refs, phone);
263
+ const grid = document.createElement('div');
264
+ grid.className = 'entities';
265
+ grid.style.cssText = `grid-template-columns: repeat(${cols}, minmax(0, 1fr)); grid-template-rows: repeat(${rows}, auto);`;
266
+ spec.records.forEach((r, i) => {
267
+ const fields = r.fields ?? [];
268
+ const box = document.createElement('div');
269
+ box.className = 'node record' + (r.tone ? ' accent' : '');
270
+ box.id = 'rec-' + r.id;
271
+ box.style.gridArea = `${pos[i][0] + 1} / ${pos[i][1] + 1}`;
272
+ box.innerHTML = `<h3>${esc(r.name)}</h3>${fields.length ? `<ul>${fields.map((f) => `<li>${esc(f)}</li>`).join('')}</ul>` : ''}`;
273
+ grid.appendChild(box);
274
+ });
275
+ main.appendChild(grid);
276
+ for (const ref of spec.refs) {
277
+ const a = document.createElement('fig-arrow');
278
+ a.setAttribute('from', 'rec-' + ref.from);
279
+ a.setAttribute('to', 'rec-' + ref.to);
280
+ a.setAttribute('ref', ref.count);
281
+ if (ref.optional) a.setAttribute('dashed', '');
282
+ main.appendChild(a);
283
+ }
284
+ }
285
+
204
286
  const HOSTS = { split: 'fig-split', bars: 'fig-bars', 'before-after': 'fig-change', dumbbell: 'div', series: 'div' };
205
287
 
206
288
  /** A checked spec drawn into a figure: header, chart and footer, as figkit's own markup. */
207
289
  export function drawSpec(spec, main) {
208
290
  const header = document.createElement('header');
209
291
  header.innerHTML = `<h1>${esc(spec.title)}</h1>${spec.note ? `<p class="read">${esc(spec.note)}</p>` : ''}`;
292
+ if (spec.type === 'entities') {
293
+ main.append(header);
294
+ drawEntities(spec, main);
295
+ if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
296
+ return { chart: main.querySelector('.entities'), draw: () => {} };
297
+ }
210
298
  const chart = document.createElement(HOSTS[spec.type]);
211
299
  main.append(header, chart);
212
300
  if (spec.source) {
package/viz/spec.js CHANGED
@@ -84,10 +84,37 @@ export const SeriesSpec = z.object({
84
84
  zero: z.boolean().optional(),
85
85
  }).strict();
86
86
 
87
- export const Spec = z.discriminatedUnion('type', [SplitSpec, BarsSpec, BeforeAfterSpec, DumbbellSpec, SeriesSpec]);
88
- export const TYPES = { split: SplitSpec, bars: BarsSpec, 'before-after': BeforeAfterSpec, dumbbell: DumbbellSpec, series: SeriesSpec };
87
+ const slug = z.string({ error: 'needs an id, e.g. "order"' }).regex(/^[a-z][a-z0-9-]{0,23}$/, 'ids are short lowercase words, e.g. "order" or "order-line"');
89
88
 
90
- const items = (s) => s.parts ?? s.bars ?? s.rows ?? (s.after ? [s.after] : []);
89
+ /**
90
+ * What the things are and which points at which: records, and references from one record to another. A reference is
91
+ * a pointer one record holds (a foreign key, an id field), never a step in the order work happens.
92
+ */
93
+ export const EntitiesSpec = z.object({
94
+ type: z.literal('entities'),
95
+ title,
96
+ note: common.note,
97
+ source: common.source,
98
+ records: z.array(z.object({
99
+ id: slug,
100
+ name: label.max(32, 'keep record names to 32 characters'),
101
+ table: z.never({ error: 'no table: name the record in words; if the table name matters to the reader, put it in fields' }).optional(),
102
+ fields: z.array(z.string().trim().min(1).max(36, 'keep each field to 36 characters')).max(4, 'at most 4 fields: the ones that explain the picture').optional(),
103
+ tone: z.literal('accent', { error: 'tone: only "accent", on the one record to notice' }).optional(),
104
+ }).strict()).min(2, 'at least 2 records').max(5, 'at most 5 records; more is two figures (the core, then one corner of it), not one'),
105
+ refs: z.array(z.object({
106
+ from: slug,
107
+ to: slug,
108
+ field: z.never({ error: 'no field: the line is the reference, so its column is not written out' }).optional(),
109
+ count: z.enum(['many', 'one'], { error: 'count: "many" or "one": how many "from" records can point at one "to" record' }).default('many'),
110
+ optional: z.boolean().optional(),
111
+ }).strict()).min(1, 'at least 1 reference').max(8, 'at most 8 references; more is two figures, not a reference left out'),
112
+ }).strict();
113
+
114
+ export const Spec = z.discriminatedUnion('type', [SplitSpec, BarsSpec, BeforeAfterSpec, DumbbellSpec, SeriesSpec, EntitiesSpec]);
115
+ export const TYPES = { split: SplitSpec, bars: BarsSpec, 'before-after': BeforeAfterSpec, dumbbell: DumbbellSpec, series: SeriesSpec, entities: EntitiesSpec };
116
+
117
+ const items = (s) => s.parts ?? s.bars ?? s.rows ?? s.records ?? (s.after ? [s.after] : []);
91
118
  const kind = (v) => (typeof v === 'number' ? 'number' : 'date');
92
119
 
93
120
  /** Rules that span fields: x values of one kind, in order, and a mark that is one of the points. */
@@ -103,6 +130,24 @@ function crossChecks(s) {
103
130
  for (let i = 1; i < s.points.length; i++) if (at(s.points[i].x) <= at(s.points[i - 1].x)) return [`points[${i}].x: points must be in order of x, each later than the one before`];
104
131
  if (!s.points.some((p) => at(p.x) === at(s.mark.x))) return [`mark.x: must be the x of one of the points, not ${JSON.stringify(s.mark.x)}`];
105
132
  }
133
+ if (s.type === 'entities') {
134
+ const ids = s.records.map((r) => r.id);
135
+ const dup = ids.find((id, i) => ids.indexOf(id) !== i);
136
+ if (dup) return [`records: two records have the id "${dup}"`];
137
+ const out = [];
138
+ const pairs = new Set();
139
+ s.refs.forEach((r, i) => {
140
+ for (const end of ['from', 'to']) if (!ids.includes(r[end])) out.push(`refs[${i}].${end}: no record has the id "${r[end]}"; the ids are ${ids.map((x) => `"${x}"`).join(', ')}`);
141
+ if (r.from === r.to) out.push(`refs[${i}]: a record pointing at itself; say it in its fields instead`);
142
+ const key = r.from + '>' + r.to;
143
+ if (pairs.has(key)) out.push(`refs[${i}]: a second reference from "${r.from}" to "${r.to}"; make it one reference`);
144
+ pairs.add(key);
145
+ });
146
+ if (s.records.filter((r) => r.tone).length > 1) out.push('records: accent at most one record, the one to notice');
147
+ const loose = ids.filter((id) => !s.refs.some((r) => r.from === id || r.to === id));
148
+ if (loose.length) out.push(`records: ${loose.map((x) => `"${x}"`).join(', ')} ${loose.length === 1 ? 'is' : 'are'} not in any reference; leave ${loose.length === 1 ? 'it' : 'them'} out or add the reference`);
149
+ return out;
150
+ }
106
151
  return [];
107
152
  }
108
153