@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.
- package/GUIDE.md +34 -4
- package/README.md +2 -2
- package/bin/fig.mjs +3 -3
- package/dist/viz.iife.js +10 -10
- package/examples/charts/entities.json +17 -0
- package/examples/consolidate.html +10 -6
- package/package.json +1 -1
- package/runtime/fig.css +7 -1
- package/runtime/fig.js +164 -33
- package/viz/draw.js +88 -0
- package/viz/spec.js +48 -3
|
@@ -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"
|
|
7
|
+
<div class="col grow" style="gap: 10px">
|
|
8
8
|
<span class="lane-title">Today: five places</span>
|
|
9
|
-
<div class="node"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
if (
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|