@liggi/figkit 0.0.0-stage → 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 +303 -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/entities.json +17 -0
- package/examples/charts/series.json +46 -0
- package/examples/charts/split.json +10 -0
- package/examples/consolidate.html +33 -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 +188 -0
- package/runtime/fig.js +704 -0
- package/viz/build.mjs +27 -0
- package/viz/draw.js +315 -0
- package/viz/index.js +2 -0
- package/viz/spec.js +177 -0
package/runtime/fig.js
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
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
|
+
// 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) }; })();
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const s of allowed) {
|
|
71
|
+
const d = DIRS[s];
|
|
72
|
+
let x, y;
|
|
73
|
+
if (d[0] === 0) {
|
|
74
|
+
const lo = Math.max(r.x, other.x), hi = Math.min(r.x + r.w, other.x + other.w);
|
|
75
|
+
x = !diamond && hi - lo >= 24 ? (lo + hi) / 2 : r.x + r.w / 2;
|
|
76
|
+
if (at != null && !diamond) x = r.x + r.w * at;
|
|
77
|
+
y = s === 'top' ? r.y : r.y + r.h;
|
|
78
|
+
} else {
|
|
79
|
+
const lo = Math.max(r.y, other.y), hi = Math.min(r.y + r.h, other.y + other.h);
|
|
80
|
+
y = !diamond && hi - lo >= 24 ? (lo + hi) / 2 : r.y + r.h / 2;
|
|
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
|
|
83
|
+
x = s === 'left' ? r.x : r.x + r.w;
|
|
84
|
+
}
|
|
85
|
+
x = Math.round(x); y = Math.round(y);
|
|
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
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
class Heap {
|
|
101
|
+
constructor() { this.a = []; }
|
|
102
|
+
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; } }
|
|
103
|
+
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; }
|
|
104
|
+
get size() { return this.a.length; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function routeOrtho(A, B, obs, arrow, used, W, H) {
|
|
108
|
+
const hard = obs.map((o) => grow(o.r, 3)), soft = obs.map((o) => grow(o.r, M - 1));
|
|
109
|
+
const others = obs.filter((o) => o.el !== A.el && o.el !== B.el).map((o) => grow(o.r, 3));
|
|
110
|
+
const clear = (x1, y1, x2, y2) => {
|
|
111
|
+
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 };
|
|
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));
|
|
113
|
+
};
|
|
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);
|
|
119
|
+
|
|
120
|
+
// Fast path: the two boxes face each other, so a straight line is the whole route.
|
|
121
|
+
const facing = [['right', 'left'], ['left', 'right'], ['bottom', 'top'], ['top', 'bottom']];
|
|
122
|
+
for (const [sa, sb] of facing) {
|
|
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;
|
|
125
|
+
const ahead = (q.x - p.x) * p.d[0] + (q.y - p.y) * p.d[1];
|
|
126
|
+
const aligned = p.d[0] === 0 ? Math.abs(p.x - q.x) < 1 : Math.abs(p.y - q.y) < 1;
|
|
127
|
+
if (ahead > 6 && aligned && clear(p.x, p.y, q.x, q.y)) return [[p.x, p.y], [q.x, q.y]];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Grid lines: box edges plus clearance, the middle of each gap, and the ports.
|
|
131
|
+
const xs = new Map(), ys = new Map();
|
|
132
|
+
const add = (m, v, kind) => { v = Math.round(v); if (!m.has(v) || kind === 'mid') m.set(v, kind); };
|
|
133
|
+
for (const o of obs) {
|
|
134
|
+
const g = grow(o.r, M);
|
|
135
|
+
add(xs, g.x, 'edge'); add(xs, g.x + g.w, 'edge'); add(ys, g.y, 'edge'); add(ys, g.y + g.h, 'edge');
|
|
136
|
+
let nx = null, ny = null;
|
|
137
|
+
for (const p of obs) {
|
|
138
|
+
if (p === o) continue;
|
|
139
|
+
if (p.r.x >= o.r.x + o.r.w && (nx === null || p.r.x < nx)) nx = p.r.x;
|
|
140
|
+
if (p.r.y >= o.r.y + o.r.h && (ny === null || p.r.y < ny)) ny = p.r.y;
|
|
141
|
+
}
|
|
142
|
+
if (nx !== null && nx - (o.r.x + o.r.w) >= 2 * M) add(xs, (nx + o.r.x + o.r.w) / 2, 'mid');
|
|
143
|
+
if (ny !== null && ny - (o.r.y + o.r.h) >= 2 * M) add(ys, (ny + o.r.y + o.r.h) / 2, 'mid');
|
|
144
|
+
}
|
|
145
|
+
add(xs, 14, 'edge'); add(xs, W - 14, 'edge'); add(ys, 14, 'edge'); add(ys, H - 14, 'edge');
|
|
146
|
+
const pad = parseFloat(getComputedStyle(root()).paddingLeft) || 0;
|
|
147
|
+
if (pad > 14) { add(xs, pad, 'mid'); add(xs, W - pad, 'mid'); }
|
|
148
|
+
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'); }
|
|
149
|
+
const X = [...xs.keys()].sort((a, b) => a - b), Y = [...ys.keys()].sort((a, b) => a - b);
|
|
150
|
+
const NX = X.length, NY = Y.length;
|
|
151
|
+
const xi = new Map(X.map((v, i) => [v, i])), yi = new Map(Y.map((v, i) => [v, i]));
|
|
152
|
+
const kx = X.map((v) => (xs.get(v) === 'edge' ? 1.12 : 1)), ky = Y.map((v) => (ys.get(v) === 'edge' ? 1.12 : 1));
|
|
153
|
+
|
|
154
|
+
const mult = (x, y) => {
|
|
155
|
+
for (const r of hard) if (inside(r, x, y)) return Infinity;
|
|
156
|
+
for (const r of soft) if (inside(r, x, y)) return 5;
|
|
157
|
+
return 1;
|
|
158
|
+
};
|
|
159
|
+
const usedCost = (x1, y1, x2, y2) => {
|
|
160
|
+
let k = 1, plus = 0;
|
|
161
|
+
const horiz = y1 === y2;
|
|
162
|
+
for (const u of used) {
|
|
163
|
+
const uh = u.y1 === u.y2;
|
|
164
|
+
if (uh === horiz) {
|
|
165
|
+
const off = horiz ? Math.abs(u.y1 - y1) : Math.abs(u.x1 - x1);
|
|
166
|
+
if (off >= 10) continue;
|
|
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));
|
|
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));
|
|
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);
|
|
171
|
+
} else if (!bus(u)) {
|
|
172
|
+
const cx = horiz ? u.x1 : x1, cy = horiz ? y1 : u.y1;
|
|
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;
|
|
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);
|
|
176
|
+
if (onMe && onU) plus += arrow.ref ? 1500 : 70;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return [k, plus];
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Dijkstra over (column, row, heading).
|
|
183
|
+
const BEND = 64;
|
|
184
|
+
const id = (ix, iy, d) => (iy * NX + ix) * 4 + d;
|
|
185
|
+
const VEC = [[0, -1], [1, 0], [0, 1], [-1, 0]];
|
|
186
|
+
const dirIndex = (d) => VEC.findIndex((v) => v[0] === d[0] && v[1] === d[1]);
|
|
187
|
+
const dist = new Map(), prev = new Map(), heap = new Heap();
|
|
188
|
+
for (const p of pa) {
|
|
189
|
+
if (mult(p.sx, p.sy) === Infinity) continue;
|
|
190
|
+
const s = id(xi.get(p.sx), yi.get(p.sy), dirIndex(p.d));
|
|
191
|
+
const c0 = portCost(p);
|
|
192
|
+
dist.set(s, c0); prev.set(s, { port: p }); heap.push(c0, s);
|
|
193
|
+
}
|
|
194
|
+
const goals = new Map();
|
|
195
|
+
for (const p of pb) if (mult(p.sx, p.sy) !== Infinity) goals.set(xi.get(p.sx) + ',' + yi.get(p.sy), p);
|
|
196
|
+
let best = null, bestCost = Infinity;
|
|
197
|
+
while (heap.size) {
|
|
198
|
+
const [c, s] = heap.pop();
|
|
199
|
+
if (c > (dist.get(s) ?? Infinity)) continue;
|
|
200
|
+
if (c >= bestCost) break;
|
|
201
|
+
const d = s % 4, cell = (s - d) / 4, ix = cell % NX, iy = (cell - ix) / NX;
|
|
202
|
+
const goal = goals.get(ix + ',' + iy);
|
|
203
|
+
if (goal) {
|
|
204
|
+
const inward = dirIndex([-goal.d[0], -goal.d[1]]);
|
|
205
|
+
const total = c + (d === inward ? 0 : BEND) + portCost(goal);
|
|
206
|
+
if (total < bestCost) { bestCost = total; best = { s, port: goal }; }
|
|
207
|
+
}
|
|
208
|
+
for (let nd = 0; nd < 4; nd++) {
|
|
209
|
+
if ((nd + 2) % 4 === d) continue;
|
|
210
|
+
const jx = ix + VEC[nd][0], jy = iy + VEC[nd][1];
|
|
211
|
+
if (jx < 0 || jy < 0 || jx >= NX || jy >= NY) continue;
|
|
212
|
+
const x1 = X[ix], y1 = Y[iy], x2 = X[jx], y2 = Y[jy];
|
|
213
|
+
const m = mult((x1 + x2) / 2, (y1 + y2) / 2);
|
|
214
|
+
if (m === Infinity) continue;
|
|
215
|
+
const [k, plus] = usedCost(x1, y1, x2, y2);
|
|
216
|
+
const len = Math.abs(x2 - x1) + Math.abs(y2 - y1);
|
|
217
|
+
const cost = c + len * m * k * (VEC[nd][0] === 0 ? kx[ix] : ky[iy]) + plus + (nd === d ? 0 : BEND);
|
|
218
|
+
const t = id(jx, jy, nd);
|
|
219
|
+
if (cost < (dist.get(t) ?? Infinity)) { dist.set(t, cost); prev.set(t, { from: s }); heap.push(cost, t); }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!best) return null;
|
|
223
|
+
const pts = [[best.port.x, best.port.y]];
|
|
224
|
+
let s = best.s;
|
|
225
|
+
for (;;) {
|
|
226
|
+
const d = s % 4, cell = (s - d) / 4, ix = cell % NX, iy = (cell - ix) / NX;
|
|
227
|
+
pts.push([X[ix], Y[iy]]);
|
|
228
|
+
const p = prev.get(s);
|
|
229
|
+
if (p.port) { pts.push([p.port.x, p.port.y]); break; }
|
|
230
|
+
s = p.from;
|
|
231
|
+
}
|
|
232
|
+
pts.reverse();
|
|
233
|
+
return simplify(pts);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function simplify(pts) {
|
|
237
|
+
const out = [pts[0]];
|
|
238
|
+
for (let i = 1; i < pts.length; i++) {
|
|
239
|
+
const a = out[out.length - 1], b = pts[i], c = pts[i + 1];
|
|
240
|
+
if (a[0] === b[0] && a[1] === b[1]) continue;
|
|
241
|
+
if (c && ((a[0] === b[0] && b[0] === c[0]) || (a[1] === b[1] && b[1] === c[1]))) continue;
|
|
242
|
+
out.push(b);
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function sideToward(r, o) {
|
|
248
|
+
const dx = o.x + o.w / 2 - (r.x + r.w / 2), dy = o.y + o.h / 2 - (r.y + r.h / 2);
|
|
249
|
+
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));
|
|
250
|
+
if (gapX >= gapY) return dx > 0 ? 'right' : 'left';
|
|
251
|
+
return dy > 0 ? 'bottom' : 'top';
|
|
252
|
+
}
|
|
253
|
+
function routeLoose(A, B, arrow) {
|
|
254
|
+
const sa = arrow.fromSides.length === 1 ? arrow.fromSides[0] : sideToward(A.r, B.r);
|
|
255
|
+
const sb = arrow.toSides.length === 1 ? arrow.toSides[0] : sideToward(B.r, A.r);
|
|
256
|
+
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];
|
|
257
|
+
return { p, q };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------- drawing ----------
|
|
261
|
+
function svgEl(tag, attrs, parent) {
|
|
262
|
+
const el = document.createElementNS(NS, tag);
|
|
263
|
+
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
|
264
|
+
if (parent) parent.appendChild(el);
|
|
265
|
+
return el;
|
|
266
|
+
}
|
|
267
|
+
function roundedPath(pts, r = 10) {
|
|
268
|
+
let d = `M${pts[0][0]},${pts[0][1]}`;
|
|
269
|
+
for (let i = 1; i < pts.length - 1; i++) {
|
|
270
|
+
const [ax, ay] = pts[i - 1], [bx, by] = pts[i], [cx, cy] = pts[i + 1];
|
|
271
|
+
const l1 = Math.hypot(bx - ax, by - ay), l2 = Math.hypot(cx - bx, cy - by);
|
|
272
|
+
const k = Math.min(r, l1 / 2, l2 / 2);
|
|
273
|
+
const p1 = [bx - ((bx - ax) / l1) * k, by - ((by - ay) / l1) * k], p2 = [bx + ((cx - bx) / l2) * k, by + ((cy - by) / l2) * k];
|
|
274
|
+
d += ` L${p1[0]},${p1[1]} Q${bx},${by} ${p2[0]},${p2[1]}`;
|
|
275
|
+
}
|
|
276
|
+
const last = pts[pts.length - 1];
|
|
277
|
+
return d + ` L${last[0]},${last[1]}`;
|
|
278
|
+
}
|
|
279
|
+
function head(svg, tip, from, color, size) {
|
|
280
|
+
const a = Math.atan2(tip[1] - from[1], tip[0] - from[0]);
|
|
281
|
+
const L = size, Wd = size * 0.42;
|
|
282
|
+
const bx = tip[0] - Math.cos(a) * L, by = tip[1] - Math.sin(a) * L;
|
|
283
|
+
const pts = [tip, [bx + Math.sin(a) * Wd, by - Math.cos(a) * Wd], [bx - Math.sin(a) * Wd, by + Math.cos(a) * Wd]];
|
|
284
|
+
svgEl('polygon', { points: pts.map((p) => p.join(',')).join(' '), fill: color }, svg);
|
|
285
|
+
return [tip[0] - Math.cos(a) * (L - 1), tip[1] - Math.sin(a) * (L - 1)];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** A cross a little short of the target: the route that does not exist. Returns where the line should stop. */
|
|
289
|
+
function blockedEnd(svg, tip, from, color, weight) {
|
|
290
|
+
const a = Math.atan2(tip[1] - from[1], tip[0] - from[0]);
|
|
291
|
+
const cx = tip[0] - Math.cos(a) * 26, cy = tip[1] - Math.sin(a) * 26, k = 8;
|
|
292
|
+
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);
|
|
293
|
+
return [cx - Math.cos(a) * 16, cy - Math.sin(a) * 16];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function drawArrows() {
|
|
297
|
+
const R = root(), W = R.offsetWidth, H = R.offsetHeight;
|
|
298
|
+
R.querySelectorAll('svg.fig-wires, .fig-wire-label').forEach((e) => e.remove());
|
|
299
|
+
const svg = svgEl('svg', { class: 'fig-wires', viewBox: `0 0 ${W} ${H}` });
|
|
300
|
+
R.appendChild(svg);
|
|
301
|
+
const obs = obstacles();
|
|
302
|
+
const used = [];
|
|
303
|
+
const labels = [];
|
|
304
|
+
fig.wires = [];
|
|
305
|
+
const sides = (v) => (v ? v.split(/[\s,]+/).filter((s) => DIRS[s]) : Object.keys(DIRS));
|
|
306
|
+
const num = (v) => (v == null || v === '' ? null : Number(v));
|
|
307
|
+
for (const tag of document.querySelectorAll('fig-arrow')) {
|
|
308
|
+
const from = tag.getAttribute('from'), to = tag.getAttribute('to');
|
|
309
|
+
const a = document.getElementById(from), b = document.getElementById(to);
|
|
310
|
+
if (!a || !b) { fig.problems.push(`Arrow ${from} → ${to}: there is no element with id "${!a ? from : to}".`); continue; }
|
|
311
|
+
const tone = TONES.find((t) => tag.hasAttribute(t) || tag.getAttribute('tone') === t) ?? (tag.getAttribute('tone') === 'ink' ? 'ink' : 'line');
|
|
312
|
+
const color = css('--' + tone);
|
|
313
|
+
const ref = tag.getAttribute('ref');
|
|
314
|
+
const arrow = {
|
|
315
|
+
from, to, tone, ref, fromSides: sides(tag.getAttribute('from-side')), toSides: sides(tag.getAttribute('to-side')),
|
|
316
|
+
fromAt: num(tag.getAttribute('from-at')), toAt: num(tag.getAttribute('to-at')),
|
|
317
|
+
};
|
|
318
|
+
const A = { el: a, r: rectOf(a) }, B = { el: b, r: rectOf(b) };
|
|
319
|
+
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)));
|
|
320
|
+
for (const end of [A, B]) if (!mine.some((o) => o.el === end.el)) mine.push(end);
|
|
321
|
+
const mode = tag.getAttribute('route') ?? 'ortho';
|
|
322
|
+
const weight = Number(tag.getAttribute('weight') ?? (tone === 'line' ? 2 : 2.5));
|
|
323
|
+
const size = 9 + weight * 2;
|
|
324
|
+
const dash = tag.hasAttribute('dashed') ? '7 7' : null;
|
|
325
|
+
const heads = tag.getAttribute('head') ?? (tag.hasAttribute('blocked') ? 'blocked' : tag.hasAttribute('both') ? 'both' : 'end');
|
|
326
|
+
let pts = null, pathD = null, labelAt = null;
|
|
327
|
+
if (mode === 'ortho') {
|
|
328
|
+
pts = routeOrtho(A, B, mine, arrow, used, W, H);
|
|
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; }
|
|
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 });
|
|
331
|
+
} else {
|
|
332
|
+
const { p, q } = routeLoose(A, B, arrow);
|
|
333
|
+
if (mode === 'straight') {
|
|
334
|
+
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];
|
|
335
|
+
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]; };
|
|
336
|
+
pts = [clip(A.r, ca, cb), clip(B.r, cb, ca)];
|
|
337
|
+
} else {
|
|
338
|
+
const k = Math.max(40, Math.hypot(q.x - p.x, q.y - p.y) * 0.42);
|
|
339
|
+
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];
|
|
340
|
+
pts = [[p.x, p.y], [q.x, q.y]];
|
|
341
|
+
pts.curve = [c1, c2];
|
|
342
|
+
labelAt = [(p.x + 3 * c1[0] + 3 * c2[0] + q.x) / 8, (p.y + 3 * c1[1] + 3 * c2[1] + q.y) / 8];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Heads first, so the line stops at the base of each head.
|
|
346
|
+
let start = pts[0], end = pts[pts.length - 1];
|
|
347
|
+
const beforeEnd = pts.curve ? pts.curve[1] : pts[pts.length - 2], afterStart = pts.curve ? pts.curve[0] : pts[1];
|
|
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);
|
|
365
|
+
const line = [start, ...pts.slice(1, -1), end];
|
|
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);
|
|
367
|
+
const attrs = { d: pathD, fill: 'none', stroke: color, 'stroke-width': weight, 'stroke-linecap': 'round' };
|
|
368
|
+
if (dash) attrs['stroke-dasharray'] = dash;
|
|
369
|
+
svgEl('path', attrs, svg);
|
|
370
|
+
fig.wires.push({ from, to, pts, mode, ref });
|
|
371
|
+
|
|
372
|
+
const text = ref ? '' : tag.getAttribute('label') ?? tag.textContent.trim();
|
|
373
|
+
if (text) labels.push({ text, tone, color, pts, at: labelAt, want: tag.getAttribute('label-at'), from, to });
|
|
374
|
+
}
|
|
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;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Put each arrow label where it touches nothing: on its line if the line is long enough, otherwise beside it. */
|
|
460
|
+
function placeLabels(labels, obs) {
|
|
461
|
+
const R = root(), W = R.offsetWidth, H = R.offsetHeight;
|
|
462
|
+
const placed = [], fails = [];
|
|
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]) }));
|
|
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)
|
|
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)));
|
|
466
|
+
for (const L of labels) {
|
|
467
|
+
const lab = document.createElement('div');
|
|
468
|
+
lab.className = 'fig-wire-label';
|
|
469
|
+
lab.textContent = L.text;
|
|
470
|
+
if (L.tone !== 'line') lab.style.color = L.color;
|
|
471
|
+
lab.dataset.arrow = `${L.from} → ${L.to}`;
|
|
472
|
+
R.appendChild(lab);
|
|
473
|
+
const w = lab.offsetWidth, h = lab.offsetHeight;
|
|
474
|
+
const cands = [];
|
|
475
|
+
if (L.at) cands.push({ c: L.at });
|
|
476
|
+
else {
|
|
477
|
+
let segs = segsOf(L.pts);
|
|
478
|
+
const mine = (fig.wires ?? []).find((x) => x.pts === L.pts);
|
|
479
|
+
if (L.want === 'start') segs = [segs[0]]; else if (L.want === 'end') segs = [segs[segs.length - 1]];
|
|
480
|
+
else {
|
|
481
|
+
const shared = (g) => (fig.wires ?? []).some((x) => x.pts !== L.pts && segsOf(x.pts).some((u) => {
|
|
482
|
+
const gh = g.a[1] === g.b[1], uh = u.a[1] === u.b[1];
|
|
483
|
+
if (gh !== uh || Math.abs(gh ? g.a[1] - u.a[1] : g.a[0] - u.a[0]) > 0.5) return false;
|
|
484
|
+
const k = gh ? 0 : 1;
|
|
485
|
+
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;
|
|
486
|
+
}));
|
|
487
|
+
const own = segs.filter((g) => !shared(g));
|
|
488
|
+
segs = [...(own.length ? own : segs)].sort((p, q) => q.len - p.len);
|
|
489
|
+
}
|
|
490
|
+
for (const seg of segs) {
|
|
491
|
+
const horiz = seg.a[1] === seg.b[1], mid = [(seg.a[0] + seg.b[0]) / 2, (seg.a[1] + seg.b[1]) / 2];
|
|
492
|
+
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]);
|
|
493
|
+
for (const t of [0.5, 0.3, 0.7]) {
|
|
494
|
+
const c = [seg.a[0] + (seg.b[0] - seg.a[0]) * t, seg.a[1] + (seg.b[1] - seg.a[1]) * t];
|
|
495
|
+
if (seg.len >= (horiz ? w : h) + 36) cands.push({ c, on: real });
|
|
496
|
+
if (horiz) cands.push({ c: [c[0], c[1] - h / 2 - 7] }, { c: [c[0], c[1] + h / 2 + 7] });
|
|
497
|
+
else cands.push({ c: [c[0] + w / 2 + 9, c[1]] }, { c: [c[0] - w / 2 - 9, c[1]] });
|
|
498
|
+
}
|
|
499
|
+
void mid;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const rectAt = (c) => ({ x: c[0] - w / 2, y: c[1] - h / 2, w, h });
|
|
503
|
+
const free = (cand) => {
|
|
504
|
+
const r = rectAt(cand.c);
|
|
505
|
+
if (r.x < 8 || r.y < 8 || r.x + r.w > W - 8 || r.y + r.h > H - 8) return false;
|
|
506
|
+
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;
|
|
507
|
+
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;
|
|
508
|
+
if (placed.some((p) => overlap(grow(r, 4), p))) return false;
|
|
509
|
+
return !wireHit(grow(r, 2), cand.on);
|
|
510
|
+
};
|
|
511
|
+
const pick = cands.find(free) ?? (fails.push({ from: L.from, to: L.to, w, h }), cands[0]);
|
|
512
|
+
lab.style.left = pick.c[0] + 'px';
|
|
513
|
+
lab.style.top = pick.c[1] + 'px';
|
|
514
|
+
placed.push(rectAt(pick.c));
|
|
515
|
+
}
|
|
516
|
+
return fails;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ---------- split bar and bars (drawn by viz/draw.js, loaded as window.figkitViz) ----------
|
|
520
|
+
const toneOf = (el) => TONES.find((t) => el.hasAttribute(t) || el.getAttribute('tone') === t);
|
|
521
|
+
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) });
|
|
522
|
+
const unitOf = (host) => host.getAttribute('unit') ?? '';
|
|
523
|
+
const withPrefix = (host, items) => items.map((x) => (x.show === undefined && host.getAttribute('prefix') ? { ...x, show: host.getAttribute('prefix') + x.text + unitOf(host) } : x));
|
|
524
|
+
|
|
525
|
+
function buildSplits() {
|
|
526
|
+
for (const host of document.querySelectorAll('fig-split')) {
|
|
527
|
+
const parts = [...host.querySelectorAll(':scope > part')].map((p) => item(p));
|
|
528
|
+
if (parts.length) window.figkitViz.drawSplit(host, withPrefix(host, parts), unitOf(host));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function buildBars() {
|
|
533
|
+
for (const host of document.querySelectorAll('fig-bars')) {
|
|
534
|
+
const bars = [...host.querySelectorAll(':scope > bar')].map((b) => item(b, true));
|
|
535
|
+
const max = host.hasAttribute('max') ? Number(host.getAttribute('max')) : undefined;
|
|
536
|
+
if (bars.length) window.figkitViz.drawBars(host, withPrefix(host, bars), unitOf(host), max);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ---------- Plot ----------
|
|
541
|
+
/** fig.plot('#id', ({ Plot, d3, width, theme }) => plotOptions): an Observable Plot chart in the figure's type and colours. */
|
|
542
|
+
fig.plot = (target, build) => fig.queue.push(() => {
|
|
543
|
+
const el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
544
|
+
if (!el) { fig.problems.push(`fig.plot: nothing matches ${target}.`); return; }
|
|
545
|
+
el.classList.add('chart');
|
|
546
|
+
const width = Math.floor(el.clientWidth) || 640;
|
|
547
|
+
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') };
|
|
548
|
+
try {
|
|
549
|
+
const opts = build({ Plot: window.Plot, d3: window.d3, width, theme: t });
|
|
550
|
+
const size = parseFloat(css('--small')) || 15;
|
|
551
|
+
el.appendChild(window.Plot.plot({ width, ...opts, style: { background: 'transparent', color: t.muted, fontFamily: css('--sans'), fontSize: size + 'px', overflow: 'visible', ...(opts.style ?? {}) } }));
|
|
552
|
+
} catch (e) { fig.problems.push(`fig.plot ${target}: ${e.message}`); }
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// ---------- checks ----------
|
|
556
|
+
function check() {
|
|
557
|
+
const R = root(), P = fig.problems;
|
|
558
|
+
const W = R.offsetWidth, H = R.offsetHeight;
|
|
559
|
+
const detail = R.classList.contains('detail');
|
|
560
|
+
const minText = detail ? 13 : 15;
|
|
561
|
+
const obs = obstacles();
|
|
562
|
+
|
|
563
|
+
// Chart labels (SVG text, which the text checks below skip): inside the frame and clear of each other.
|
|
564
|
+
const marks = [...R.querySelectorAll('.chart svg text')].filter((e) => e.textContent.trim() && visible(e)).map((e) => ({ e, r: rectOf(e) }));
|
|
565
|
+
for (const [i, a] of marks.entries()) {
|
|
566
|
+
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()}".`);
|
|
567
|
+
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()}".`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Text: too small, cut off, or past its box.
|
|
571
|
+
const small = [];
|
|
572
|
+
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()))))) {
|
|
573
|
+
if (o.closest('svg')) continue;
|
|
574
|
+
const size = parseFloat(getComputedStyle(o).fontSize);
|
|
575
|
+
if (size < minText) small.push(`${name(o)} at ${size}px`);
|
|
576
|
+
const box = o.closest('.node, .decision, fig-split, fig-bars, fig-change') ?? R;
|
|
577
|
+
const t = textRect(o), b = rectOf(box);
|
|
578
|
+
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)}.`);
|
|
579
|
+
}
|
|
580
|
+
for (const svgText of R.querySelectorAll('.chart text')) {
|
|
581
|
+
const size = parseFloat(getComputedStyle(svgText).fontSize);
|
|
582
|
+
if (svgText.textContent.trim() && size < minText) small.push(`chart label "${svgText.textContent.trim()}" at ${size}px`);
|
|
583
|
+
}
|
|
584
|
+
if (small.length) P.push(`Text under ${minText}px will not be readable: ${small.join(', ')}.`);
|
|
585
|
+
for (const d of R.querySelectorAll('.decision')) {
|
|
586
|
+
const kids = [...d.children].map(rectOf);
|
|
587
|
+
const b = rectOf(d);
|
|
588
|
+
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);
|
|
589
|
+
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; }
|
|
590
|
+
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
|
|
591
|
+
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);
|
|
592
|
+
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.`);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Boxes and labels that collide.
|
|
596
|
+
for (let i = 0; i < obs.length; i++) for (let j = i + 1; j < obs.length; j++) {
|
|
597
|
+
const a = obs[i], b = obs[j];
|
|
598
|
+
if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
|
|
599
|
+
if (a.el.closest('.step') || b.el.closest('.step') || a.el.closest('.mark, .shot') || b.el.closest('.mark, .shot')) continue;
|
|
600
|
+
if (a.el.closest('.group > .label') || b.el.closest('.group > .label')) continue;
|
|
601
|
+
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)}.`);
|
|
602
|
+
}
|
|
603
|
+
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.`);
|
|
604
|
+
|
|
605
|
+
// Arrows through boxes, and arrows crossing each other.
|
|
606
|
+
const segs = [];
|
|
607
|
+
for (const w of fig.wires ?? []) {
|
|
608
|
+
const a = document.getElementById(w.from), b = document.getElementById(w.to);
|
|
609
|
+
for (let i = 1; i < w.pts.length; i++) segs.push({ w, a: w.pts[i - 1], b: w.pts[i] });
|
|
610
|
+
if (w.mode === 'curve') continue;
|
|
611
|
+
for (const o of obs) {
|
|
612
|
+
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;
|
|
613
|
+
const r = grow(o.r, -2);
|
|
614
|
+
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
|
|
615
|
+
&& (p[0] === w.pts[i - 1][0] || p[1] === w.pts[i - 1][1]));
|
|
616
|
+
if (hit) P.push(`Arrow ${w.from} → ${w.to} runs through ${name(o.el)}.`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
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.`);
|
|
623
|
+
|
|
624
|
+
// Neighbours that almost line up.
|
|
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'));
|
|
626
|
+
const near = [];
|
|
627
|
+
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
|
|
628
|
+
const a = boxes[i], b = boxes[j];
|
|
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;
|
|
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]];
|
|
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)`); }
|
|
634
|
+
}
|
|
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.`);
|
|
636
|
+
|
|
637
|
+
// Too much on the page.
|
|
638
|
+
const words = R.innerText.split(/\s+/).filter(Boolean).length;
|
|
639
|
+
const nodes = R.querySelectorAll('.node:not(.bare), .decision').length, arrows = (fig.wires ?? []).length;
|
|
640
|
+
const budget = detail ? { words: 190, nodes: 18, arrows: 18 } : { words: 70, nodes: 5, arrows: 8 };
|
|
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.`);
|
|
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.'}`);
|
|
643
|
+
if (arrows > budget.arrows) P.push(`${arrows} arrows; more than ${budget.arrows} tangles. Show the main path and drop the rest.`);
|
|
644
|
+
const tones = TONES.filter((t) => R.querySelector(`.${t}, [${t}], .t-${t}`));
|
|
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.`);
|
|
646
|
+
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.`); }
|
|
647
|
+
const h1 = R.querySelector('h1');
|
|
648
|
+
if (!h1) P.push('No <h1>: give the figure a title that states its point.');
|
|
649
|
+
else if (h1.innerText.split(/\s+/).length > 14) P.push('The title is over 14 words; state the point more shortly.');
|
|
650
|
+
|
|
651
|
+
const scale = 358 / W;
|
|
652
|
+
const px = (sel) => { const el = R.querySelector(sel); return el ? (parseFloat(getComputedStyle(el).fontSize) * scale).toFixed(1) : null; };
|
|
653
|
+
// Columns of boxes side by side with no arrow between them, not marked as sides: on a phone they shrink instead of stacking.
|
|
654
|
+
const side = (g, id) => [...g.children].findIndex((c) => c.contains(document.getElementById(id)));
|
|
655
|
+
const unmarked = !R.querySelector('.sides') && [...R.querySelectorAll('.row, .grid')].some((g) => !g.parentElement.closest('.row, .grid, .node')
|
|
656
|
+
&& [...g.children].filter((c) => c.querySelector('.node')).length >= 2 && [...g.children].every((c) => c.matches('.col, .group') || c.querySelector('.node'))
|
|
657
|
+
&& (fig.wires ?? []).every((w) => side(g, w.from) < 0 || side(g, w.to) < 0 || side(g, w.from) === side(g, w.to)));
|
|
658
|
+
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') } };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function run() {
|
|
662
|
+
if (!root()) { fig.problems.push('No element with class "fig".'); window.__figDone = true; return; }
|
|
663
|
+
try {
|
|
664
|
+
if (window.__figPhone) { root().classList.add('phone'); root().style.removeProperty('--w'); }
|
|
665
|
+
// Load every theme face up front: labels are measured to place them, and a face first used mid-layout measures as the fallback.
|
|
666
|
+
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)));
|
|
667
|
+
await document.fonts.ready;
|
|
668
|
+
await Promise.all([...document.images].map((img) => (img.complete ? null : new Promise((res) => { img.onload = img.onerror = res; }))));
|
|
669
|
+
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'; }
|
|
670
|
+
const spec = document.getElementById('figkit-spec');
|
|
671
|
+
if (spec) window.figkitViz.drawSpec(JSON.parse(spec.textContent), root());
|
|
672
|
+
else { buildSplits(); buildBars(); }
|
|
673
|
+
for (const job of fig.queue) job();
|
|
674
|
+
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
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
|
+
}
|
|
699
|
+
check();
|
|
700
|
+
} catch (e) { fig.problems.push('figkit failed: ' + (e.stack || e.message)); }
|
|
701
|
+
window.__figDone = true;
|
|
702
|
+
}
|
|
703
|
+
if (document.readyState === 'complete') run(); else window.addEventListener('load', run);
|
|
704
|
+
})();
|