@liggi/figkit 0.0.0-stage → 0.1.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 +273 -0
- package/LICENSE +21 -0
- package/README.md +98 -2
- package/THIRD_PARTY_NOTICES.md +632 -0
- package/bin/fig.mjs +124 -0
- package/dist/viz.iife.js +59 -0
- package/examples/assets/settings-page.html +14 -0
- package/examples/assets/settings-page.png +0 -0
- package/examples/boundary.html +25 -0
- package/examples/charts/bars.json +8 -0
- package/examples/charts/before-after.json +16 -0
- package/examples/charts/dumbbell.json +9 -0
- package/examples/charts/series.json +46 -0
- package/examples/charts/split.json +10 -0
- package/examples/consolidate.html +29 -0
- package/examples/custom-bar.html +25 -0
- package/examples/flow.html +50 -0
- package/examples/grouping.html +33 -0
- package/examples/line-chart.html +25 -0
- package/examples/screenshot-notes.html +16 -0
- package/examples/sides.html +35 -0
- package/examples/split.html +13 -0
- package/examples/timeline.html +33 -0
- package/package.json +55 -4
- package/runtime/fig.css +182 -0
- package/runtime/fig.js +573 -0
- package/viz/build.mjs +27 -0
- package/viz/draw.js +227 -0
- package/viz/index.js +2 -0
- package/viz/spec.js +132 -0
package/runtime/fig.js
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/* figkit runtime: arrows routed around boxes, split bars, bars, Plot charts, and checks measured in the browser. */
|
|
2
|
+
(() => {
|
|
3
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
4
|
+
const M = 16; // clearance an arrow keeps from a box
|
|
5
|
+
const fig = (window.fig = { problems: [], info: {}, queue: [] });
|
|
6
|
+
const root = () => document.querySelector('.fig');
|
|
7
|
+
const css = (name) => getComputedStyle(root()).getPropertyValue(name).trim();
|
|
8
|
+
const TONES = ['accent', 'second', 'good', 'bad'];
|
|
9
|
+
|
|
10
|
+
fig.theme = new Proxy({}, { get: (_t, k) => css('--' + String(k)) });
|
|
11
|
+
|
|
12
|
+
// ---------- geometry ----------
|
|
13
|
+
function rectOf(el) {
|
|
14
|
+
const r = el.getBoundingClientRect(), o = root().getBoundingClientRect();
|
|
15
|
+
return { x: r.left - o.left, y: r.top - o.top, w: r.width, h: r.height };
|
|
16
|
+
}
|
|
17
|
+
function textRect(el) {
|
|
18
|
+
const range = document.createRange();
|
|
19
|
+
range.selectNodeContents(el);
|
|
20
|
+
const r = range.getBoundingClientRect(), o = root().getBoundingClientRect();
|
|
21
|
+
return { x: r.left - o.left, y: r.top - o.top, w: r.width, h: r.height };
|
|
22
|
+
}
|
|
23
|
+
const grow = (r, d) => ({ x: r.x - d, y: r.y - d, w: r.w + 2 * d, h: r.h + 2 * d });
|
|
24
|
+
const inside = (r, x, y) => x > r.x && x < r.x + r.w && y > r.y && y < r.y + r.h;
|
|
25
|
+
const overlap = (a, b) => Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 2 && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 2;
|
|
26
|
+
const name = (el) => (el.id ? '#' + el.id : '"' + (el.innerText ?? el.textContent).trim().replace(/\s+/g, ' ') + '"');
|
|
27
|
+
const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
|
|
28
|
+
const SOLID = '.node, .decision, .group, fig-split, fig-bars, fig-change, .chart, .shot, .fig-wire-label';
|
|
29
|
+
/** Overlap that knows a decision is a diamond, not its bounding box. */
|
|
30
|
+
function hits(a, b) {
|
|
31
|
+
if (!overlap(a.r, b.r)) return false;
|
|
32
|
+
const d = a.el?.classList.contains('decision') ? a : b.el?.classList.contains('decision') ? b : null;
|
|
33
|
+
if (!d) return true;
|
|
34
|
+
const o = d === a ? b : a;
|
|
35
|
+
if (o.el?.classList.contains('decision')) return true;
|
|
36
|
+
const cx = d.r.x + d.r.w / 2, cy = d.r.y + d.r.h / 2;
|
|
37
|
+
const px = Math.max(o.r.x, Math.min(cx, o.r.x + o.r.w)), py = Math.max(o.r.y, Math.min(cy, o.r.y + o.r.h));
|
|
38
|
+
return Math.abs(px - cx) / (d.r.w / 2) + Math.abs(py - cy) / (d.r.h / 2) < 0.98;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Things an arrow must not run through: boxes, charts, and any loose text. */
|
|
42
|
+
function obstacles() {
|
|
43
|
+
const out = [];
|
|
44
|
+
const solid = [...root().querySelectorAll(SOLID)].filter(visible);
|
|
45
|
+
for (const el of solid) if (!el.classList.contains('bare')) out.push({ el, r: rectOf(el) });
|
|
46
|
+
const walker = document.createTreeWalker(root(), NodeFilter.SHOW_TEXT);
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
49
|
+
if (!n.textContent.trim()) continue;
|
|
50
|
+
let el = n.parentElement;
|
|
51
|
+
// Inline pieces of one paragraph (<em>, <b>, <code>) are one block of text, not separate things that can collide.
|
|
52
|
+
while (el !== root() && el.parentElement && getComputedStyle(el).display === 'inline') el = el.parentElement;
|
|
53
|
+
if (seen.has(el) || !visible(el) || el.closest('svg, script, style, fig-arrow')) continue;
|
|
54
|
+
const box = el.closest(SOLID);
|
|
55
|
+
if (box && !box.classList.contains('bare') && !box.classList.contains('group')) continue;
|
|
56
|
+
seen.add(el);
|
|
57
|
+
out.push({ el, r: textRect(el), text: true });
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------- orthogonal router ----------
|
|
63
|
+
const DIRS = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] };
|
|
64
|
+
function ports(el, r, other, allowed, at) {
|
|
65
|
+
const diamond = el.classList.contains('decision');
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const s of allowed) {
|
|
68
|
+
const d = DIRS[s];
|
|
69
|
+
let x, y;
|
|
70
|
+
if (d[0] === 0) {
|
|
71
|
+
const lo = Math.max(r.x, other.x), hi = Math.min(r.x + r.w, other.x + other.w);
|
|
72
|
+
x = !diamond && hi - lo >= 24 ? (lo + hi) / 2 : r.x + r.w / 2;
|
|
73
|
+
if (at != null && !diamond) x = r.x + r.w * at;
|
|
74
|
+
y = s === 'top' ? r.y : r.y + r.h;
|
|
75
|
+
} else {
|
|
76
|
+
const lo = Math.max(r.y, other.y), hi = Math.min(r.y + r.h, other.y + other.h);
|
|
77
|
+
y = !diamond && hi - lo >= 24 ? (lo + hi) / 2 : r.y + r.h / 2;
|
|
78
|
+
if (at != null && !diamond) y = r.y + r.h * at;
|
|
79
|
+
x = s === 'left' ? r.x : r.x + r.w;
|
|
80
|
+
}
|
|
81
|
+
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 });
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class Heap {
|
|
88
|
+
constructor() { this.a = []; }
|
|
89
|
+
push(k, v) { const a = this.a; a.push([k, v]); let i = a.length - 1; while (i > 0) { const p = (i - 1) >> 1; if (a[p][0] <= a[i][0]) break; [a[p], a[i]] = [a[i], a[p]]; i = p; } }
|
|
90
|
+
pop() { const a = this.a; const top = a[0]; const last = a.pop(); if (a.length) { a[0] = last; let i = 0; for (;;) { const l = 2 * i + 1, r = l + 1; let m = i; if (l < a.length && a[l][0] < a[m][0]) m = l; if (r < a.length && a[r][0] < a[m][0]) m = r; if (m === i) break; [a[m], a[i]] = [a[i], a[m]]; i = m; } } return top; }
|
|
91
|
+
get size() { return this.a.length; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function routeOrtho(A, B, obs, arrow, used, W, H) {
|
|
95
|
+
const hard = obs.map((o) => grow(o.r, 3)), soft = obs.map((o) => grow(o.r, M - 1));
|
|
96
|
+
const others = obs.filter((o) => o.el !== A.el && o.el !== B.el).map((o) => grow(o.r, 3));
|
|
97
|
+
const clear = (x1, y1, x2, y2) => {
|
|
98
|
+
const seg = { x: Math.min(x1, x2) - 0.5, y: Math.min(y1, y2) - 0.5, w: Math.abs(x2 - x1) + 1, h: Math.abs(y2 - y1) + 1 };
|
|
99
|
+
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
|
+
};
|
|
101
|
+
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);
|
|
102
|
+
|
|
103
|
+
// Fast path: the two boxes face each other, so a straight line is the whole route.
|
|
104
|
+
const facing = [['right', 'left'], ['left', 'right'], ['bottom', 'top'], ['top', 'bottom']];
|
|
105
|
+
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;
|
|
108
|
+
const ahead = (q.x - p.x) * p.d[0] + (q.y - p.y) * p.d[1];
|
|
109
|
+
const aligned = p.d[0] === 0 ? Math.abs(p.x - q.x) < 1 : Math.abs(p.y - q.y) < 1;
|
|
110
|
+
if (ahead > 6 && aligned && clear(p.x, p.y, q.x, q.y)) return [[p.x, p.y], [q.x, q.y]];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Grid lines: box edges plus clearance, the middle of each gap, and the ports.
|
|
114
|
+
const xs = new Map(), ys = new Map();
|
|
115
|
+
const add = (m, v, kind) => { v = Math.round(v); if (!m.has(v) || kind === 'mid') m.set(v, kind); };
|
|
116
|
+
for (const o of obs) {
|
|
117
|
+
const g = grow(o.r, M);
|
|
118
|
+
add(xs, g.x, 'edge'); add(xs, g.x + g.w, 'edge'); add(ys, g.y, 'edge'); add(ys, g.y + g.h, 'edge');
|
|
119
|
+
let nx = null, ny = null;
|
|
120
|
+
for (const p of obs) {
|
|
121
|
+
if (p === o) continue;
|
|
122
|
+
if (p.r.x >= o.r.x + o.r.w && (nx === null || p.r.x < nx)) nx = p.r.x;
|
|
123
|
+
if (p.r.y >= o.r.y + o.r.h && (ny === null || p.r.y < ny)) ny = p.r.y;
|
|
124
|
+
}
|
|
125
|
+
if (nx !== null && nx - (o.r.x + o.r.w) >= 2 * M) add(xs, (nx + o.r.x + o.r.w) / 2, 'mid');
|
|
126
|
+
if (ny !== null && ny - (o.r.y + o.r.h) >= 2 * M) add(ys, (ny + o.r.y + o.r.h) / 2, 'mid');
|
|
127
|
+
}
|
|
128
|
+
add(xs, 14, 'edge'); add(xs, W - 14, 'edge'); add(ys, 14, 'edge'); add(ys, H - 14, 'edge');
|
|
129
|
+
const pad = parseFloat(getComputedStyle(root()).paddingLeft) || 0;
|
|
130
|
+
if (pad > 14) { add(xs, pad, 'mid'); add(xs, W - pad, 'mid'); }
|
|
131
|
+
for (const p of [...pa, ...pb]) { add(xs, p.sx, 'mid'); add(ys, p.sy, 'mid'); add(xs, p.x, 'mid'); add(ys, p.y, 'mid'); }
|
|
132
|
+
const X = [...xs.keys()].sort((a, b) => a - b), Y = [...ys.keys()].sort((a, b) => a - b);
|
|
133
|
+
const NX = X.length, NY = Y.length;
|
|
134
|
+
const xi = new Map(X.map((v, i) => [v, i])), yi = new Map(Y.map((v, i) => [v, i]));
|
|
135
|
+
const kx = X.map((v) => (xs.get(v) === 'edge' ? 1.12 : 1)), ky = Y.map((v) => (ys.get(v) === 'edge' ? 1.12 : 1));
|
|
136
|
+
|
|
137
|
+
const mult = (x, y) => {
|
|
138
|
+
for (const r of hard) if (inside(r, x, y)) return Infinity;
|
|
139
|
+
for (const r of soft) if (inside(r, x, y)) return 5;
|
|
140
|
+
return 1;
|
|
141
|
+
};
|
|
142
|
+
const bus = (u) => (u.from === arrow.from || u.to === arrow.to) && u.tone === arrow.tone;
|
|
143
|
+
const usedCost = (x1, y1, x2, y2) => {
|
|
144
|
+
let k = 1, plus = 0;
|
|
145
|
+
const horiz = y1 === y2;
|
|
146
|
+
for (const u of used) {
|
|
147
|
+
const uh = u.y1 === u.y2;
|
|
148
|
+
if (uh === horiz) {
|
|
149
|
+
const off = horiz ? Math.abs(u.y1 - y1) : Math.abs(u.x1 - x1);
|
|
150
|
+
if (off >= 10) continue;
|
|
151
|
+
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
|
+
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);
|
|
155
|
+
} else if (!bus(u)) {
|
|
156
|
+
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);
|
|
158
|
+
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;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return [k, plus];
|
|
163
|
+
};
|
|
164
|
+
|
|
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
|
+
// Dijkstra over (column, row, heading).
|
|
167
|
+
const BEND = 64;
|
|
168
|
+
const id = (ix, iy, d) => (iy * NX + ix) * 4 + d;
|
|
169
|
+
const VEC = [[0, -1], [1, 0], [0, 1], [-1, 0]];
|
|
170
|
+
const dirIndex = (d) => VEC.findIndex((v) => v[0] === d[0] && v[1] === d[1]);
|
|
171
|
+
const dist = new Map(), prev = new Map(), heap = new Heap();
|
|
172
|
+
for (const p of pa) {
|
|
173
|
+
if (mult(p.sx, p.sy) === Infinity) continue;
|
|
174
|
+
const s = id(xi.get(p.sx), yi.get(p.sy), dirIndex(p.d));
|
|
175
|
+
const c0 = portCost(p);
|
|
176
|
+
dist.set(s, c0); prev.set(s, { port: p }); heap.push(c0, s);
|
|
177
|
+
}
|
|
178
|
+
const goals = new Map();
|
|
179
|
+
for (const p of pb) if (mult(p.sx, p.sy) !== Infinity) goals.set(xi.get(p.sx) + ',' + yi.get(p.sy), p);
|
|
180
|
+
let best = null, bestCost = Infinity;
|
|
181
|
+
while (heap.size) {
|
|
182
|
+
const [c, s] = heap.pop();
|
|
183
|
+
if (c > (dist.get(s) ?? Infinity)) continue;
|
|
184
|
+
if (c >= bestCost) break;
|
|
185
|
+
const d = s % 4, cell = (s - d) / 4, ix = cell % NX, iy = (cell - ix) / NX;
|
|
186
|
+
const goal = goals.get(ix + ',' + iy);
|
|
187
|
+
if (goal) {
|
|
188
|
+
const inward = dirIndex([-goal.d[0], -goal.d[1]]);
|
|
189
|
+
const total = c + (d === inward ? 0 : BEND) + portCost(goal);
|
|
190
|
+
if (total < bestCost) { bestCost = total; best = { s, port: goal }; }
|
|
191
|
+
}
|
|
192
|
+
for (let nd = 0; nd < 4; nd++) {
|
|
193
|
+
if ((nd + 2) % 4 === d) continue;
|
|
194
|
+
const jx = ix + VEC[nd][0], jy = iy + VEC[nd][1];
|
|
195
|
+
if (jx < 0 || jy < 0 || jx >= NX || jy >= NY) continue;
|
|
196
|
+
const x1 = X[ix], y1 = Y[iy], x2 = X[jx], y2 = Y[jy];
|
|
197
|
+
const m = mult((x1 + x2) / 2, (y1 + y2) / 2);
|
|
198
|
+
if (m === Infinity) continue;
|
|
199
|
+
const [k, plus] = usedCost(x1, y1, x2, y2);
|
|
200
|
+
const len = Math.abs(x2 - x1) + Math.abs(y2 - y1);
|
|
201
|
+
const cost = c + len * m * k * (VEC[nd][0] === 0 ? kx[ix] : ky[iy]) + plus + (nd === d ? 0 : BEND);
|
|
202
|
+
const t = id(jx, jy, nd);
|
|
203
|
+
if (cost < (dist.get(t) ?? Infinity)) { dist.set(t, cost); prev.set(t, { from: s }); heap.push(cost, t); }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!best) return null;
|
|
207
|
+
const pts = [[best.port.x, best.port.y]];
|
|
208
|
+
let s = best.s;
|
|
209
|
+
for (;;) {
|
|
210
|
+
const d = s % 4, cell = (s - d) / 4, ix = cell % NX, iy = (cell - ix) / NX;
|
|
211
|
+
pts.push([X[ix], Y[iy]]);
|
|
212
|
+
const p = prev.get(s);
|
|
213
|
+
if (p.port) { pts.push([p.port.x, p.port.y]); break; }
|
|
214
|
+
s = p.from;
|
|
215
|
+
}
|
|
216
|
+
pts.reverse();
|
|
217
|
+
return simplify(pts);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function simplify(pts) {
|
|
221
|
+
const out = [pts[0]];
|
|
222
|
+
for (let i = 1; i < pts.length; i++) {
|
|
223
|
+
const a = out[out.length - 1], b = pts[i], c = pts[i + 1];
|
|
224
|
+
if (a[0] === b[0] && a[1] === b[1]) continue;
|
|
225
|
+
if (c && ((a[0] === b[0] && b[0] === c[0]) || (a[1] === b[1] && b[1] === c[1]))) continue;
|
|
226
|
+
out.push(b);
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function sideToward(r, o) {
|
|
232
|
+
const dx = o.x + o.w / 2 - (r.x + r.w / 2), dy = o.y + o.h / 2 - (r.y + r.h / 2);
|
|
233
|
+
const gapX = Math.max(o.x - (r.x + r.w), r.x - (o.x + o.w)), gapY = Math.max(o.y - (r.y + r.h), r.y - (o.y + o.h));
|
|
234
|
+
if (gapX >= gapY) return dx > 0 ? 'right' : 'left';
|
|
235
|
+
return dy > 0 ? 'bottom' : 'top';
|
|
236
|
+
}
|
|
237
|
+
function routeLoose(A, B, arrow) {
|
|
238
|
+
const sa = arrow.fromSides.length === 1 ? arrow.fromSides[0] : sideToward(A.r, B.r);
|
|
239
|
+
const sb = arrow.toSides.length === 1 ? arrow.toSides[0] : sideToward(B.r, A.r);
|
|
240
|
+
const p = ports(A.el, A.r, B.r, [sa], arrow.fromAt)[0], q = ports(B.el, B.r, A.r, [sb], arrow.toAt)[0];
|
|
241
|
+
return { p, q };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------- drawing ----------
|
|
245
|
+
function svgEl(tag, attrs, parent) {
|
|
246
|
+
const el = document.createElementNS(NS, tag);
|
|
247
|
+
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
|
248
|
+
if (parent) parent.appendChild(el);
|
|
249
|
+
return el;
|
|
250
|
+
}
|
|
251
|
+
function roundedPath(pts, r = 10) {
|
|
252
|
+
let d = `M${pts[0][0]},${pts[0][1]}`;
|
|
253
|
+
for (let i = 1; i < pts.length - 1; i++) {
|
|
254
|
+
const [ax, ay] = pts[i - 1], [bx, by] = pts[i], [cx, cy] = pts[i + 1];
|
|
255
|
+
const l1 = Math.hypot(bx - ax, by - ay), l2 = Math.hypot(cx - bx, cy - by);
|
|
256
|
+
const k = Math.min(r, l1 / 2, l2 / 2);
|
|
257
|
+
const p1 = [bx - ((bx - ax) / l1) * k, by - ((by - ay) / l1) * k], p2 = [bx + ((cx - bx) / l2) * k, by + ((cy - by) / l2) * k];
|
|
258
|
+
d += ` L${p1[0]},${p1[1]} Q${bx},${by} ${p2[0]},${p2[1]}`;
|
|
259
|
+
}
|
|
260
|
+
const last = pts[pts.length - 1];
|
|
261
|
+
return d + ` L${last[0]},${last[1]}`;
|
|
262
|
+
}
|
|
263
|
+
function head(svg, tip, from, color, size) {
|
|
264
|
+
const a = Math.atan2(tip[1] - from[1], tip[0] - from[0]);
|
|
265
|
+
const L = size, Wd = size * 0.42;
|
|
266
|
+
const bx = tip[0] - Math.cos(a) * L, by = tip[1] - Math.sin(a) * L;
|
|
267
|
+
const pts = [tip, [bx + Math.sin(a) * Wd, by - Math.cos(a) * Wd], [bx - Math.sin(a) * Wd, by + Math.cos(a) * Wd]];
|
|
268
|
+
svgEl('polygon', { points: pts.map((p) => p.join(',')).join(' '), fill: color }, svg);
|
|
269
|
+
return [tip[0] - Math.cos(a) * (L - 1), tip[1] - Math.sin(a) * (L - 1)];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** A cross a little short of the target: the route that does not exist. Returns where the line should stop. */
|
|
273
|
+
function blockedEnd(svg, tip, from, color, weight) {
|
|
274
|
+
const a = Math.atan2(tip[1] - from[1], tip[0] - from[0]);
|
|
275
|
+
const cx = tip[0] - Math.cos(a) * 26, cy = tip[1] - Math.sin(a) * 26, k = 8;
|
|
276
|
+
for (const s of [1, -1]) svgEl('line', { x1: cx - k, y1: cy - k * s, x2: cx + k, y2: cy + k * s, stroke: color, 'stroke-width': weight + 1, 'stroke-linecap': 'round' }, svg);
|
|
277
|
+
return [cx - Math.cos(a) * 16, cy - Math.sin(a) * 16];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function drawArrows() {
|
|
281
|
+
const R = root(), W = R.offsetWidth, H = R.offsetHeight;
|
|
282
|
+
R.querySelectorAll('svg.fig-wires, .fig-wire-label').forEach((e) => e.remove());
|
|
283
|
+
const svg = svgEl('svg', { class: 'fig-wires', viewBox: `0 0 ${W} ${H}` });
|
|
284
|
+
R.appendChild(svg);
|
|
285
|
+
const obs = obstacles();
|
|
286
|
+
const used = [];
|
|
287
|
+
const labels = [];
|
|
288
|
+
fig.wires = [];
|
|
289
|
+
const sides = (v) => (v ? v.split(/[\s,]+/).filter((s) => DIRS[s]) : Object.keys(DIRS));
|
|
290
|
+
const num = (v) => (v == null || v === '' ? null : Number(v));
|
|
291
|
+
for (const tag of document.querySelectorAll('fig-arrow')) {
|
|
292
|
+
const from = tag.getAttribute('from'), to = tag.getAttribute('to');
|
|
293
|
+
const a = document.getElementById(from), b = document.getElementById(to);
|
|
294
|
+
if (!a || !b) { fig.problems.push(`Arrow ${from} → ${to}: there is no element with id "${!a ? from : to}".`); continue; }
|
|
295
|
+
const tone = TONES.find((t) => tag.hasAttribute(t) || tag.getAttribute('tone') === t) ?? (tag.getAttribute('tone') === 'ink' ? 'ink' : 'line');
|
|
296
|
+
const color = css('--' + tone);
|
|
297
|
+
const arrow = {
|
|
298
|
+
from, to, tone, fromSides: sides(tag.getAttribute('from-side')), toSides: sides(tag.getAttribute('to-side')),
|
|
299
|
+
fromAt: num(tag.getAttribute('from-at')), toAt: num(tag.getAttribute('to-at')),
|
|
300
|
+
};
|
|
301
|
+
const A = { el: a, r: rectOf(a) }, B = { el: b, r: rectOf(b) };
|
|
302
|
+
const mine = obs.filter((o) => o.el === a || o.el === b || !(o.el.contains(a) || o.el.contains(b) || a.contains(o.el) || b.contains(o.el)));
|
|
303
|
+
for (const end of [A, B]) if (!mine.some((o) => o.el === end.el)) mine.push(end);
|
|
304
|
+
const mode = tag.getAttribute('route') ?? 'ortho';
|
|
305
|
+
const weight = Number(tag.getAttribute('weight') ?? (tone === 'line' ? 2 : 2.5));
|
|
306
|
+
const size = 9 + weight * 2;
|
|
307
|
+
const dash = tag.hasAttribute('dashed') ? '7 7' : null;
|
|
308
|
+
const heads = tag.getAttribute('head') ?? (tag.hasAttribute('blocked') ? 'blocked' : tag.hasAttribute('both') ? 'both' : 'end');
|
|
309
|
+
let pts = null, pathD = null, labelAt = null;
|
|
310
|
+
if (mode === 'ortho') {
|
|
311
|
+
pts = routeOrtho(A, B, mine, arrow, used, W, H);
|
|
312
|
+
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 });
|
|
314
|
+
} else {
|
|
315
|
+
const { p, q } = routeLoose(A, B, arrow);
|
|
316
|
+
if (mode === 'straight') {
|
|
317
|
+
const ca = [A.r.x + A.r.w / 2, A.r.y + A.r.h / 2], cb = [B.r.x + B.r.w / 2, B.r.y + B.r.h / 2];
|
|
318
|
+
const clip = (r, c, o) => { const dx = o[0] - c[0], dy = o[1] - c[1]; const t = Math.min(dx ? r.w / 2 / Math.abs(dx) : Infinity, dy ? r.h / 2 / Math.abs(dy) : Infinity); return [c[0] + dx * t, c[1] + dy * t]; };
|
|
319
|
+
pts = [clip(A.r, ca, cb), clip(B.r, cb, ca)];
|
|
320
|
+
} else {
|
|
321
|
+
const k = Math.max(40, Math.hypot(q.x - p.x, q.y - p.y) * 0.42);
|
|
322
|
+
const c1 = [p.x + p.d[0] * k, p.y + p.d[1] * k], c2 = [q.x + q.d[0] * k, q.y + q.d[1] * k];
|
|
323
|
+
pts = [[p.x, p.y], [q.x, q.y]];
|
|
324
|
+
pts.curve = [c1, c2];
|
|
325
|
+
labelAt = [(p.x + 3 * c1[0] + 3 * c2[0] + q.x) / 8, (p.y + 3 * c1[1] + 3 * c2[1] + q.y) / 8];
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// Heads first, so the line stops at the base of each head.
|
|
329
|
+
let start = pts[0], end = pts[pts.length - 1];
|
|
330
|
+
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);
|
|
334
|
+
const line = [start, ...pts.slice(1, -1), end];
|
|
335
|
+
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
|
+
const attrs = { d: pathD, fill: 'none', stroke: color, 'stroke-width': weight, 'stroke-linecap': 'round' };
|
|
337
|
+
if (dash) attrs['stroke-dasharray'] = dash;
|
|
338
|
+
svgEl('path', attrs, svg);
|
|
339
|
+
fig.wires.push({ from, to, pts, mode });
|
|
340
|
+
|
|
341
|
+
const text = tag.getAttribute('label') ?? tag.textContent.trim();
|
|
342
|
+
if (text) labels.push({ text, tone, color, pts, at: labelAt, want: tag.getAttribute('label-at'), from, to });
|
|
343
|
+
}
|
|
344
|
+
placeLabels(labels, obs);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Put each arrow label where it touches nothing: on its line if the line is long enough, otherwise beside it. */
|
|
348
|
+
function placeLabels(labels, obs) {
|
|
349
|
+
const R = root(), W = R.offsetWidth, H = R.offsetHeight;
|
|
350
|
+
const placed = [];
|
|
351
|
+
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
|
+
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
|
+
&& 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)));
|
|
354
|
+
for (const L of labels) {
|
|
355
|
+
const lab = document.createElement('div');
|
|
356
|
+
lab.className = 'fig-wire-label';
|
|
357
|
+
lab.textContent = L.text;
|
|
358
|
+
if (L.tone !== 'line') lab.style.color = L.color;
|
|
359
|
+
lab.dataset.arrow = `${L.from} → ${L.to}`;
|
|
360
|
+
R.appendChild(lab);
|
|
361
|
+
const w = lab.offsetWidth, h = lab.offsetHeight;
|
|
362
|
+
const cands = [];
|
|
363
|
+
if (L.at) cands.push({ c: L.at });
|
|
364
|
+
else {
|
|
365
|
+
let segs = segsOf(L.pts);
|
|
366
|
+
const mine = (fig.wires ?? []).find((x) => x.pts === L.pts);
|
|
367
|
+
if (L.want === 'start') segs = [segs[0]]; else if (L.want === 'end') segs = [segs[segs.length - 1]];
|
|
368
|
+
else {
|
|
369
|
+
const shared = (g) => (fig.wires ?? []).some((x) => x.pts !== L.pts && segsOf(x.pts).some((u) => {
|
|
370
|
+
const gh = g.a[1] === g.b[1], uh = u.a[1] === u.b[1];
|
|
371
|
+
if (gh !== uh || Math.abs(gh ? g.a[1] - u.a[1] : g.a[0] - u.a[0]) > 0.5) return false;
|
|
372
|
+
const k = gh ? 0 : 1;
|
|
373
|
+
return Math.min(Math.max(g.a[k], g.b[k]), Math.max(u.a[k], u.b[k])) - Math.max(Math.min(g.a[k], g.b[k]), Math.min(u.a[k], u.b[k])) > 1;
|
|
374
|
+
}));
|
|
375
|
+
const own = segs.filter((g) => !shared(g));
|
|
376
|
+
segs = [...(own.length ? own : segs)].sort((p, q) => q.len - p.len);
|
|
377
|
+
}
|
|
378
|
+
for (const seg of segs) {
|
|
379
|
+
const horiz = seg.a[1] === seg.b[1], mid = [(seg.a[0] + seg.b[0]) / 2, (seg.a[1] + seg.b[1]) / 2];
|
|
380
|
+
const real = segsOf(mine ? mine.pts : L.pts).find((s) => s.a[0] === seg.a[0] && s.a[1] === seg.a[1] && s.b[0] === seg.b[0] && s.b[1] === seg.b[1]);
|
|
381
|
+
for (const t of [0.5, 0.3, 0.7]) {
|
|
382
|
+
const c = [seg.a[0] + (seg.b[0] - seg.a[0]) * t, seg.a[1] + (seg.b[1] - seg.a[1]) * t];
|
|
383
|
+
if (seg.len >= (horiz ? w : h) + 36) cands.push({ c, on: real });
|
|
384
|
+
if (horiz) cands.push({ c: [c[0], c[1] - h / 2 - 7] }, { c: [c[0], c[1] + h / 2 + 7] });
|
|
385
|
+
else cands.push({ c: [c[0] + w / 2 + 9, c[1]] }, { c: [c[0] - w / 2 - 9, c[1]] });
|
|
386
|
+
}
|
|
387
|
+
void mid;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const rectAt = (c) => ({ x: c[0] - w / 2, y: c[1] - h / 2, w, h });
|
|
391
|
+
const free = (cand) => {
|
|
392
|
+
const r = rectAt(cand.c);
|
|
393
|
+
if (r.x < 8 || r.y < 8 || r.x + r.w > W - 8 || r.y + r.h > H - 8) return false;
|
|
394
|
+
const within = (g) => r.x > g.x + 4 && r.y > g.y + 4 && r.x + r.w < g.x + g.w - 4 && r.y + r.h < g.y + g.h - 4;
|
|
395
|
+
if (obs.some((o) => (o.el.classList.contains('group') ? overlap(grow(r, 3), o.r) && !within(o.r) : hits({ r: grow(r, 3) }, o)))) return false;
|
|
396
|
+
if (placed.some((p) => overlap(grow(r, 4), p))) return false;
|
|
397
|
+
return !wireHit(grow(r, 2), cand.on);
|
|
398
|
+
};
|
|
399
|
+
const pick = cands.find(free) ?? cands[0];
|
|
400
|
+
lab.style.left = pick.c[0] + 'px';
|
|
401
|
+
lab.style.top = pick.c[1] + 'px';
|
|
402
|
+
placed.push(rectAt(pick.c));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---------- split bar and bars (drawn by viz/draw.js, loaded as window.figkitViz) ----------
|
|
407
|
+
const toneOf = (el) => TONES.find((t) => el.hasAttribute(t) || el.getAttribute('tone') === t);
|
|
408
|
+
const item = (el, note) => ({ value: Number(el.getAttribute('value')), text: el.getAttribute('value').trim(), show: el.getAttribute('show') ?? undefined, note: note ? el.getAttribute('note') ?? undefined : undefined, label: el.innerHTML.trim(), tone: toneOf(el) });
|
|
409
|
+
const unitOf = (host) => host.getAttribute('unit') ?? '';
|
|
410
|
+
const withPrefix = (host, items) => items.map((x) => (x.show === undefined && host.getAttribute('prefix') ? { ...x, show: host.getAttribute('prefix') + x.text + unitOf(host) } : x));
|
|
411
|
+
|
|
412
|
+
function buildSplits() {
|
|
413
|
+
for (const host of document.querySelectorAll('fig-split')) {
|
|
414
|
+
const parts = [...host.querySelectorAll(':scope > part')].map((p) => item(p));
|
|
415
|
+
if (parts.length) window.figkitViz.drawSplit(host, withPrefix(host, parts), unitOf(host));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function buildBars() {
|
|
420
|
+
for (const host of document.querySelectorAll('fig-bars')) {
|
|
421
|
+
const bars = [...host.querySelectorAll(':scope > bar')].map((b) => item(b, true));
|
|
422
|
+
const max = host.hasAttribute('max') ? Number(host.getAttribute('max')) : undefined;
|
|
423
|
+
if (bars.length) window.figkitViz.drawBars(host, withPrefix(host, bars), unitOf(host), max);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------- Plot ----------
|
|
428
|
+
/** fig.plot('#id', ({ Plot, d3, width, theme }) => plotOptions): an Observable Plot chart in the figure's type and colours. */
|
|
429
|
+
fig.plot = (target, build) => fig.queue.push(() => {
|
|
430
|
+
const el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
431
|
+
if (!el) { fig.problems.push(`fig.plot: nothing matches ${target}.`); return; }
|
|
432
|
+
el.classList.add('chart');
|
|
433
|
+
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') };
|
|
435
|
+
try {
|
|
436
|
+
const opts = build({ Plot: window.Plot, d3: window.d3, width, theme: t });
|
|
437
|
+
const size = parseFloat(css('--small')) || 15;
|
|
438
|
+
el.appendChild(window.Plot.plot({ width, ...opts, style: { background: 'transparent', color: t.muted, fontFamily: css('--sans'), fontSize: size + 'px', overflow: 'visible', ...(opts.style ?? {}) } }));
|
|
439
|
+
} catch (e) { fig.problems.push(`fig.plot ${target}: ${e.message}`); }
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// ---------- checks ----------
|
|
443
|
+
function check() {
|
|
444
|
+
const R = root(), P = fig.problems;
|
|
445
|
+
const W = R.offsetWidth, H = R.offsetHeight;
|
|
446
|
+
const detail = R.classList.contains('detail');
|
|
447
|
+
const minText = detail ? 13 : 15;
|
|
448
|
+
const obs = obstacles();
|
|
449
|
+
|
|
450
|
+
// Chart labels (SVG text, which the text checks below skip): inside the frame and clear of each other.
|
|
451
|
+
const marks = [...R.querySelectorAll('.chart svg text')].filter((e) => e.textContent.trim() && visible(e)).map((e) => ({ e, r: rectOf(e) }));
|
|
452
|
+
for (const [i, a] of marks.entries()) {
|
|
453
|
+
if (a.r.x < -1 || a.r.y < -1 || a.r.x + a.r.w > W + 1 || a.r.y + a.r.h > H + 1) P.push(`Chart label leaves the frame: "${a.e.textContent.trim()}".`);
|
|
454
|
+
for (const b of marks.slice(i + 1)) if (overlap(a.r, b.r) && !a.e.contains(b.e) && !b.e.contains(a.e)) P.push(`Chart labels overlap: "${a.e.textContent.trim()}" and "${b.e.textContent.trim()}".`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Text: too small, cut off, or past its box.
|
|
458
|
+
const small = [];
|
|
459
|
+
for (const o of obs.flatMap((b) => (b.text ? [b.el] : [...b.el.querySelectorAll('*')].filter((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()))))) {
|
|
460
|
+
if (o.closest('svg')) continue;
|
|
461
|
+
const size = parseFloat(getComputedStyle(o).fontSize);
|
|
462
|
+
if (size < minText) small.push(`${name(o)} at ${size}px`);
|
|
463
|
+
const box = o.closest('.node, .decision, fig-split, fig-bars, fig-change') ?? R;
|
|
464
|
+
const t = textRect(o), b = rectOf(box);
|
|
465
|
+
if (t.w && (t.x < b.x - 1 || t.x + t.w > b.x + b.w + 1 || t.y < b.y - 1 || t.y + t.h > b.y + b.h + 1)) P.push(`Text runs past its box: ${name(o)}${box === R ? ' leaves the frame' : ' in ' + name(box)}.`);
|
|
466
|
+
}
|
|
467
|
+
for (const svgText of R.querySelectorAll('.chart text')) {
|
|
468
|
+
const size = parseFloat(getComputedStyle(svgText).fontSize);
|
|
469
|
+
if (svgText.textContent.trim() && size < minText) small.push(`chart label "${svgText.textContent.trim()}" at ${size}px`);
|
|
470
|
+
}
|
|
471
|
+
if (small.length) P.push(`Text under ${minText}px will not be readable: ${small.join(', ')}.`);
|
|
472
|
+
for (const d of R.querySelectorAll('.decision')) {
|
|
473
|
+
const kids = [...d.children].map(rectOf);
|
|
474
|
+
const b = rectOf(d);
|
|
475
|
+
const t = kids.length ? { x: Math.min(...kids.map((k) => k.x)), y: Math.min(...kids.map((k) => k.y)), w: 0, h: 0 } : textRect(d);
|
|
476
|
+
if (kids.length) { t.w = Math.max(...kids.map((k) => k.x + k.w)) - t.x; t.h = Math.max(...kids.map((k) => k.y + k.h)) - t.y; }
|
|
477
|
+
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
|
|
478
|
+
const reach = Math.max(Math.abs(t.x - cx), Math.abs(t.x + t.w - cx)) / (b.w / 2) + Math.max(Math.abs(t.y - cy), Math.abs(t.y + t.h - cy)) / (b.h / 2);
|
|
479
|
+
if (reach > 1.02) P.push(`The text of decision ${name(d)} does not fit inside its diamond: shorten it or give the diamond a min-width.`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Boxes and labels that collide.
|
|
483
|
+
for (let i = 0; i < obs.length; i++) for (let j = i + 1; j < obs.length; j++) {
|
|
484
|
+
const a = obs[i], b = obs[j];
|
|
485
|
+
if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
|
|
486
|
+
if (a.el.closest('.step') || b.el.closest('.step') || a.el.closest('.mark, .shot') || b.el.closest('.mark, .shot')) continue;
|
|
487
|
+
if (a.el.closest('.group > .label') || b.el.closest('.group > .label')) continue;
|
|
488
|
+
if ((a.el.classList.contains('group') || b.el.classList.contains('group')) && (a.el.dataset.arrow || b.el.dataset.arrow || a.text || b.text) ? false : hits(a, b)) P.push(`${a.el.dataset.arrow ? 'The label of arrow ' + a.el.dataset.arrow : name(a.el)} overlaps ${b.el.dataset.arrow ? 'the label of arrow ' + b.el.dataset.arrow : name(b.el)}.`);
|
|
489
|
+
}
|
|
490
|
+
for (const o of obs) if (o.r.x < -1 || o.r.y < -1 || o.r.x + o.r.w > W + 1 || o.r.y + o.r.h > H + 1) P.push(`${name(o.el)} sticks out of the frame.`);
|
|
491
|
+
|
|
492
|
+
// Arrows through boxes, and arrows crossing each other.
|
|
493
|
+
const segs = [];
|
|
494
|
+
for (const w of fig.wires ?? []) {
|
|
495
|
+
const a = document.getElementById(w.from), b = document.getElementById(w.to);
|
|
496
|
+
for (let i = 1; i < w.pts.length; i++) segs.push({ w, a: w.pts[i - 1], b: w.pts[i] });
|
|
497
|
+
if (w.mode === 'curve') continue;
|
|
498
|
+
for (const o of obs) {
|
|
499
|
+
if (o.el === a || o.el === b || o.el.contains(a) || o.el.contains(b) || a.contains(o.el) || b.contains(o.el) || o.el.dataset.arrow === `${w.from} → ${w.to}`) continue;
|
|
500
|
+
const r = grow(o.r, -2);
|
|
501
|
+
const hit = w.pts.some((p, i) => i > 0 && Math.min(Math.max(p[0], w.pts[i - 1][0]), r.x + r.w) > Math.max(Math.min(p[0], w.pts[i - 1][0]), r.x) - 0.01 && Math.min(Math.max(p[1], w.pts[i - 1][1]), r.y + r.h) > Math.max(Math.min(p[1], w.pts[i - 1][1]), r.y) - 0.01
|
|
502
|
+
&& (p[0] === w.pts[i - 1][0] || p[1] === w.pts[i - 1][1]));
|
|
503
|
+
if (hit) P.push(`Arrow ${w.from} → ${w.to} runs through ${name(o.el)}.`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
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.`);
|
|
517
|
+
|
|
518
|
+
// Neighbours that almost line up.
|
|
519
|
+
const boxes = obs.filter((o) => !o.text && !o.el.classList.contains('fig-wire-label') && !o.el.classList.contains('decision') && !o.el.parentElement.closest('.node'));
|
|
520
|
+
const near = [];
|
|
521
|
+
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
|
|
522
|
+
const a = boxes[i], b = boxes[j];
|
|
523
|
+
if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
|
|
524
|
+
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
|
+
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
|
+
}
|
|
527
|
+
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
|
+
|
|
529
|
+
// Too much on the page.
|
|
530
|
+
const words = R.innerText.split(/\s+/).filter(Boolean).length;
|
|
531
|
+
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 };
|
|
533
|
+
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.`);
|
|
535
|
+
if (arrows > budget.arrows) P.push(`${arrows} arrows; more than ${budget.arrows} tangles. Show the main path and drop the rest.`);
|
|
536
|
+
const tones = TONES.filter((t) => R.querySelector(`.${t}, [${t}], .t-${t}`));
|
|
537
|
+
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
|
+
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.`); }
|
|
539
|
+
const h1 = R.querySelector('h1');
|
|
540
|
+
if (!h1) P.push('No <h1>: give the figure a title that states its point.');
|
|
541
|
+
else if (h1.innerText.split(/\s+/).length > 14) P.push('The title is over 14 words; state the point more shortly.');
|
|
542
|
+
|
|
543
|
+
const scale = 358 / W;
|
|
544
|
+
const px = (sel) => { const el = R.querySelector(sel); return el ? (parseFloat(getComputedStyle(el).fontSize) * scale).toFixed(1) : null; };
|
|
545
|
+
// Columns of boxes side by side with no arrow between them, not marked as sides: on a phone they shrink instead of stacking.
|
|
546
|
+
const side = (g, id) => [...g.children].findIndex((c) => c.contains(document.getElementById(id)));
|
|
547
|
+
const unmarked = !R.querySelector('.sides') && [...R.querySelectorAll('.row, .grid')].some((g) => !g.parentElement.closest('.row, .grid, .node')
|
|
548
|
+
&& [...g.children].filter((c) => c.querySelector('.node')).length >= 2 && [...g.children].every((c) => c.matches('.col, .group') || c.querySelector('.node'))
|
|
549
|
+
&& (fig.wires ?? []).every((w) => side(g, w.from) < 0 || side(g, w.to) < 0 || side(g, w.from) === side(g, w.to)));
|
|
550
|
+
fig.info = { width: W, height: H, words, nodes, arrows, unmarked, phone: { title: px('h1'), heading: px('.node h3, h3'), body: px('.node p, .note, p') } };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function run() {
|
|
554
|
+
if (!root()) { fig.problems.push('No element with class "fig".'); window.__figDone = true; return; }
|
|
555
|
+
try {
|
|
556
|
+
if (window.__figPhone) { root().classList.add('phone'); root().style.removeProperty('--w'); }
|
|
557
|
+
// Load every theme face up front: labels are measured to place them, and a face first used mid-layout measures as the fallback.
|
|
558
|
+
await Promise.all(['400 1em ' + css('--serif'), '400 1em ' + css('--sans'), '500 1em ' + css('--sans'), '600 1em ' + css('--sans')].map((f) => document.fonts.load(f)));
|
|
559
|
+
await document.fonts.ready;
|
|
560
|
+
await Promise.all([...document.images].map((img) => (img.complete ? null : new Promise((res) => { img.onload = img.onerror = res; }))));
|
|
561
|
+
for (const d of root().querySelectorAll('.decision')) { const h = Math.max(...[...d.children].map((k) => k.offsetHeight), 20); d.style.paddingBlock = Math.max(30, Math.round(h * 0.7)) + 'px'; }
|
|
562
|
+
const spec = document.getElementById('figkit-spec');
|
|
563
|
+
if (spec) window.figkitViz.drawSpec(JSON.parse(spec.textContent), root());
|
|
564
|
+
else { buildSplits(); buildBars(); }
|
|
565
|
+
for (const job of fig.queue) job();
|
|
566
|
+
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
567
|
+
drawArrows();
|
|
568
|
+
check();
|
|
569
|
+
} catch (e) { fig.problems.push('figkit failed: ' + (e.stack || e.message)); }
|
|
570
|
+
window.__figDone = true;
|
|
571
|
+
}
|
|
572
|
+
if (document.readyState === 'complete') run(); else window.addEventListener('load', run);
|
|
573
|
+
})();
|
package/viz/build.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* Bundles viz/ into dist/viz.iife.js (window.figkitViz), which figure pages load to draw split bars, bars and chart specs. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const HOME = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
7
|
+
const SOURCES = ['viz/draw.js', 'package.json'].map((f) => path.join(HOME, f));
|
|
8
|
+
const OUT = path.join(HOME, 'dist/viz.iife.js');
|
|
9
|
+
|
|
10
|
+
const mtime = (f) => (fs.existsSync(f) ? fs.statSync(f).mtimeMs : 0);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builds dist/ when a source is newer than it, so `fig` never runs a stale bundle. An installed package ships dist/
|
|
14
|
+
* without esbuild, and npm gives every file the same date, so there the shipped bundle is used as it is.
|
|
15
|
+
*/
|
|
16
|
+
export async function ensureBuilt({ force = false } = {}) {
|
|
17
|
+
const newest = Math.max(...SOURCES.map(mtime));
|
|
18
|
+
if (!force && mtime(OUT) >= newest) return;
|
|
19
|
+
const esbuild = await import('esbuild').catch(() => null);
|
|
20
|
+
if (!esbuild) {
|
|
21
|
+
if (fs.existsSync(OUT)) return;
|
|
22
|
+
throw new Error('dist/viz.iife.js is missing and esbuild is not installed: run npm install in the figkit folder.');
|
|
23
|
+
}
|
|
24
|
+
await esbuild.build({ entryPoints: [path.join(HOME, 'viz/draw.js')], bundle: true, minify: true, format: 'iife', globalName: 'figkitViz', outfile: OUT, logLevel: 'warning' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) await ensureBuilt({ force: true });
|