@liggi/figkit 0.1.0 → 0.3.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.css CHANGED
@@ -8,6 +8,8 @@ html, body { margin: 0; padding: 0; background: transparent; }
8
8
  --paper: #2d3142; --panel: #252938; --ink: #f5f5f5; --muted: #c3c6cf; --soft: #949eb2; --line: #8d95ad; --hair: rgba(245,245,245,.16);
9
9
  --accent: #f08a59; --second: #739fdf; --good: #6fc392; --bad: #ef6b73;
10
10
  --on-fill: #1d2030;
11
+ /* grey marks: the ones not to notice; fill2 a darker grey for a second series */
12
+ --fill: var(--soft); --fill2: var(--line);
11
13
  --sans: 'Geist Sans', 'Geist', system-ui, sans-serif;
12
14
  --serif: 'Newsreader', Georgia, serif;
13
15
  --mono: 'Geist Mono', ui-monospace, monospace;
@@ -57,7 +59,7 @@ html, body { margin: 0; padding: 0; background: transparent; }
57
59
  .fig p { margin: 0; }
58
60
  .fig .note { font-size: var(--small); color: var(--muted); max-width: 34ch; }
59
61
  .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); }
62
+ .fig .tag { font-family: var(--mono); font-size: var(--small); font-weight: 400; color: var(--soft); }
61
63
  .fig code { font-family: var(--mono); font-size: .92em; }
62
64
  .fig .t-accent { color: var(--accent); } .fig .t-second { color: var(--second); }
63
65
  .fig .t-good { color: var(--good); } .fig .t-bad { color: var(--bad); }
@@ -118,6 +120,12 @@ html, body { margin: 0; padding: 0; background: transparent; }
118
120
  .fig hr { border: 0; border-top: 1px solid var(--hair); margin: 0; width: 100%; }
119
121
  .fig .divide { border-left: 1px solid var(--hair); align-self: stretch; }
120
122
 
123
+ /* ---------- entities: records and the references between them (a spec of type "entities") ---------- */
124
+ .fig .entities { display: grid; column-gap: 110px; row-gap: 96px; align-items: start; }
125
+ .fig.phone .entities { column-gap: 72px; row-gap: 84px; }
126
+ .fig .record > ul { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
127
+ .fig .record > ul li { font-size: var(--small); color: var(--muted); }
128
+
121
129
  /* ---------- arrows (drawn by fig.js) ---------- */
122
130
  fig-arrow { display: none; }
123
131
  .fig > svg.fig-wires { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
@@ -129,7 +137,7 @@ fig-arrow { display: none; }
129
137
  /* ---------- split bar: parts of a whole ---------- */
130
138
  fig-split { display: flex; flex-direction: column; gap: 10px; }
131
139
  fig-split .fs-bar { display: flex; gap: 3px; height: 58px; }
132
- fig-split .fs-seg { --tone: var(--soft); background: var(--tone); color: var(--on-fill); min-width: 4px; display: flex; align-items: center; gap: 10px; padding: 0 14px; overflow: hidden; white-space: nowrap; }
140
+ fig-split .fs-seg { --tone: var(--fill); background: var(--tone); color: var(--on-fill); min-width: 4px; display: flex; align-items: center; gap: 10px; padding: 0 14px; overflow: hidden; white-space: nowrap; }
133
141
  fig-split .fs-seg:first-child { border-radius: 8px 0 0 8px; } fig-split .fs-seg:last-child { border-radius: 0 8px 8px 0; }
134
142
  fig-split .fs-seg.fs-out { padding: 0; }
135
143
  fig-split .fs-seg.fs-out > * { display: none; }
@@ -145,7 +153,7 @@ fig-change { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0,
145
153
  fig-change .fc-side { display: flex; flex-direction: column; gap: 10px; }
146
154
  fig-change .fc-when { font-size: var(--small); font-weight: 600; color: var(--muted); }
147
155
  fig-change .fc-num { font-family: var(--serif); font-weight: 400; font-size: calc(var(--num) * 1.5); line-height: 1; letter-spacing: -.01em; color: var(--ink); }
148
- fig-change .fc-bar { height: 14px; min-width: 3px; background: var(--soft); border-radius: 3px; }
156
+ fig-change .fc-bar { height: 14px; min-width: 3px; background: var(--fill); border-radius: 3px; }
149
157
  fig-change .before .fc-num { color: var(--muted); }
150
158
  fig-change .after .fc-num { color: var(--tone); }
151
159
  fig-change .after .fc-bar { background: var(--tone); }
@@ -155,7 +163,7 @@ fig-change .fc-arrow { color: var(--soft); display: flex; }
155
163
  fig-bars { display: grid; grid-template-columns: max-content minmax(0, 1fr); column-gap: 16px; row-gap: 10px; align-items: center; }
156
164
  fig-bars .fb-label { font-size: var(--body); }
157
165
  fig-bars .fb-track { display: flex; align-items: center; gap: 10px; min-width: 0; }
158
- fig-bars .fb-bar { --tone: var(--soft); height: 26px; background: var(--tone); border-radius: 3px; min-width: 3px; flex: 0 0 auto; }
166
+ fig-bars .fb-bar { --tone: var(--fill); height: 26px; background: var(--tone); border-radius: 3px; min-width: 3px; flex: 0 0 auto; }
159
167
  fig-bars .fb-val { font-family: var(--serif); font-size: 1.3em; font-weight: 400; font-variant-numeric: tabular-nums; white-space: nowrap; }
160
168
  fig-bars .fb-val.lead { color: var(--tone); }
161
169
  fig-bars .fb-sub { font-weight: 400; color: var(--soft); font-size: var(--small); margin-left: 6px; }
@@ -164,7 +172,7 @@ fig-bars .fb-sub { font-weight: 400; color: var(--soft); font-size: var(--small)
164
172
  .fig .chart { min-width: 0; }
165
173
  .fig .fd-key { display: flex; gap: 28px; margin: 0 0 18px; font-size: var(--small); font-weight: 600; color: var(--soft); }
166
174
  .fig .fd-key span { display: inline-flex; align-items: center; gap: 8px; }
167
- .fig .fd-key i { display: inline-block; width: 13px; height: 13px; border-radius: 50%; background: var(--soft); }
175
+ .fig .fd-key i { display: inline-block; width: 13px; height: 13px; border-radius: 50%; background: var(--fill); }
168
176
  .fig .fd-key .fd-to i { width: 0; height: 0; border-radius: 0; background: none; border-left: 13px solid currentColor; border-top: 7.5px solid transparent; border-bottom: 7.5px solid transparent; }
169
177
  .fig .fd-key .fd-to.back i { transform: scaleX(-1); }
170
178
  .fig.chart > footer { border-top: 0; padding-top: 0; }
@@ -180,3 +188,33 @@ fig-bars .fb-sub { font-weight: 400; color: var(--soft); font-size: var(--small)
180
188
  .fig .t-second.t-second { color: var(--second); }
181
189
  .fig .t-good.t-good { color: var(--good); } .fig .t-bad.t-bad { color: var(--bad); }
182
190
  .fig .decision:not(.accent):not(.second):not(.good):not(.bad) { --tone: var(--line); }
191
+
192
+ /* ---------- report: a light chart that sits in a page (a research report, a doc), set in one sans ---------- */
193
+ .fig.report {
194
+ --paper: #ffffff; --panel: #ffffff; --ink: #1d2130; --muted: #5b6170; --soft: #7a8092; --line: #9aa0ad; --hair: rgba(29,33,48,.12);
195
+ --fill: #b4b8c2; --fill2: #7d8391;
196
+ --accent: #cf5a23; --second: #2f6bc4; --good: #2f8f5b; --bad: #c9414a; --on-fill: #ffffff;
197
+ --serif: var(--sans);
198
+ --w: 720px; --pad: 32px; --gap: 14px;
199
+ --title: 26px; --read: 18px; --h: 18px; --body: 17px; --small: 16px; --num: 30px;
200
+ border: 1px solid #e6e2d8; border-radius: 10px; gap: 20px;
201
+ }
202
+ .fig.panels-fig { --w: 1000px; }
203
+ .fig.panels-fig.phone { --w: 560px; }
204
+ .fig.report h1 { font-weight: 600; letter-spacing: -.01em; line-height: 1.2; }
205
+ .fig.report > header { gap: 6px; }
206
+ .fig.report > footer { border-top: 0; padding-top: 0; }
207
+ .fig.report.phone { --w: 400px; --pad: 20px; --title: 22px; --read: 16px; --body: 16px; --small: 15px; }
208
+ .fig.report fig-bars .fb-val, .fig.report fig-split .fs-seg b, .fig.report fig-change .fc-num { font-weight: 500; letter-spacing: 0; }
209
+ .fig.report fig-bars .fb-val { font-size: 1.05em; }
210
+ .fig.report fig-bars .fb-val.lead { font-weight: 600; }
211
+
212
+ /* panels: the same chart drawn for each part, side by side, one above the other on a phone */
213
+ .fig .panels { display: grid; grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr)); column-gap: 36px; row-gap: 28px; }
214
+ .fig.phone .panels { grid-template-columns: minmax(0, 1fr); }
215
+ .fig .panel > h2 { font-size: calc(var(--h) * 1.15); margin: 0 0 4px; }
216
+
217
+ /* heat map scale: what the colour means, at readable size */
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
+ .fig .hm-key i { display: inline-block; width: 160px; height: 14px; border-radius: 3px; }
220
+ .fig.phone .hm-key i { width: 84px; }
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) ----------
@@ -431,7 +544,7 @@
431
544
  if (!el) { fig.problems.push(`fig.plot: nothing matches ${target}.`); return; }
432
545
  el.classList.add('chart');
433
546
  const width = Math.floor(el.clientWidth) || 640;
434
- const t = { ink: css('--ink'), muted: css('--muted'), soft: css('--soft'), line: css('--line'), hair: css('--hair'), accent: css('--accent'), second: css('--second'), good: css('--good'), bad: css('--bad'), paper: css('--paper'), panel: css('--panel') };
547
+ const t = { ink: css('--ink'), muted: css('--muted'), soft: css('--soft'), fill: css('--fill'), fill2: css('--fill2'), line: css('--line'), hair: css('--hair'), accent: css('--accent'), second: css('--second'), good: css('--good'), bad: css('--bad'), paper: css('--paper'), panel: css('--panel') };
435
548
  try {
436
549
  const opts = build({ Plot: window.Plot, d3: window.d3, width, theme: t });
437
550
  const size = parseFloat(css('--small')) || 15;
@@ -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,21 +627,31 @@
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
  }
527
635
  if (near.length) P.push(`Almost aligned but not quite: ${near.slice(0, 6).join('; ')}${near.length > 6 ? `; and ${near.length - 6} more` : ''}. Put them in one row, column or grid so they share the edge.`);
528
636
 
529
637
  // Too much on the page.
530
- const words = R.innerText.split(/\s+/).filter(Boolean).length;
638
+ // Panels repeat the same labels in each panel; a label read once counts once.
639
+ const panelText = R.querySelector('.panels') ? [...new Set([...R.querySelectorAll('.panels text, .panels h2')].map((e) => e.textContent.trim()))].join(' ') : null;
640
+ 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
+ const words = (panelText !== null ? outside + ' ' + panelText : R.innerText).split(/\s+/).filter(Boolean).length;
531
642
  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 };
643
+ const budget = detail ? { words: 190, nodes: 18, arrows: 18 } : { words: 70, nodes: 5, arrows: 8 };
533
644
  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.`);
645
+ 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
646
  if (arrows > budget.arrows) P.push(`${arrows} arrows; more than ${budget.arrows} tangles. Show the main path and drop the rest.`);
536
647
  const tones = TONES.filter((t) => R.querySelector(`.${t}, [${t}], .t-${t}`));
537
648
  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.`);
538
649
  for (const o of obs) if (o.text || o.el.matches('.node')) { const t = o.el.innerText ?? ''; if ((t.match(/ [·|•] /g) ?? []).length >= 1 && !o.el.querySelector('*')) P.push(`${name(o.el)} strings facts together with separators; give each fact its own line or cut one.`); }
650
+ // A face that never loaded draws in whatever the machine has, and every measurement above is of that fallback.
651
+ const faces = [...document.fonts];
652
+ const families = [...new Set(['--sans', '--serif'].map((v) => css(v).split(',')[0].replace(/["']/g, '').trim()))];
653
+ const missing = families.filter((f) => !faces.some((x) => x.family.replace(/["']/g, '') === f && x.status === 'loaded'));
654
+ if (missing.length) P.push(`figkit's fonts did not load (${missing.join(', ')}); the figure is drawn in fallback fonts. Reinstall figkit with its dependencies.`);
539
655
  const h1 = R.querySelector('h1');
540
656
  if (!h1) P.push('No <h1>: give the figure a title that states its point.');
541
657
  else if (h1.innerText.split(/\s+/).length > 14) P.push('The title is over 14 words; state the point more shortly.');
@@ -564,7 +680,30 @@
564
680
  else { buildSplits(); buildBars(); }
565
681
  for (const job of fig.queue) job();
566
682
  await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
567
- drawArrows();
683
+ // A label with no free place opens the gap it needs, then everything is drawn again.
684
+ const tried = new Map();
685
+ for (let i = 0; ; i++) {
686
+ const fails = drawArrows();
687
+ if (!fails.length || i === 6) break;
688
+ const undone = unwiden(fails, tried), widened = widen(fails, tried);
689
+ if (!undone && !widened) break;
690
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
691
+ }
692
+ // Two lines that cross can often pass each other if the later one is drawn first.
693
+ for (let i = 0; i < 4; i++) {
694
+ const pairs = crossingPairs();
695
+ if (!pairs.length) break;
696
+ const tags = [...document.querySelectorAll('fig-arrow')], at = (w) => tags.find((t) => t.getAttribute('from') === w.from && t.getAttribute('to') === w.to);
697
+ const [u, w] = pairs[0], first = at(u), second = at(w);
698
+ if (!first || !second) break;
699
+ const mark = document.createComment('');
700
+ second.replaceWith(mark); first.before(second);
701
+ drawArrows();
702
+ if (crossingPairs().length < pairs.length) { mark.remove(); continue; }
703
+ second.remove(); mark.replaceWith(second);
704
+ drawArrows();
705
+ break;
706
+ }
568
707
  check();
569
708
  } catch (e) { fig.problems.push('figkit failed: ' + (e.stack || e.message)); }
570
709
  window.__figDone = true;