@liggi/figkit 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GUIDE.md +96 -7
- package/README.md +3 -2
- package/bin/fig.mjs +14 -6
- package/dist/viz.iife.js +12 -12
- package/examples/charts/entities.json +17 -0
- package/examples/charts/groups.json +40 -0
- package/examples/charts/heatmap.json +231 -0
- package/examples/charts/lines.json +234 -0
- package/examples/charts/panels.json +62 -0
- package/examples/consolidate.html +10 -6
- package/package.json +1 -1
- package/runtime/fig.css +43 -5
- package/runtime/fig.js +174 -35
- package/viz/draw.js +253 -4
- package/viz/spec.js +167 -3
package/runtime/fig.css
CHANGED
|
@@ -8,6 +8,8 @@ html, body { margin: 0; padding: 0; background: transparent; }
|
|
|
8
8
|
--paper: #2d3142; --panel: #252938; --ink: #f5f5f5; --muted: #c3c6cf; --soft: #949eb2; --line: #8d95ad; --hair: rgba(245,245,245,.16);
|
|
9
9
|
--accent: #f08a59; --second: #739fdf; --good: #6fc392; --bad: #ef6b73;
|
|
10
10
|
--on-fill: #1d2030;
|
|
11
|
+
/* grey marks: the ones not to notice; fill2 a darker grey for a second series */
|
|
12
|
+
--fill: var(--soft); --fill2: var(--line);
|
|
11
13
|
--sans: 'Geist Sans', 'Geist', system-ui, sans-serif;
|
|
12
14
|
--serif: 'Newsreader', Georgia, serif;
|
|
13
15
|
--mono: 'Geist Mono', ui-monospace, monospace;
|
|
@@ -57,7 +59,7 @@ html, body { margin: 0; padding: 0; background: transparent; }
|
|
|
57
59
|
.fig p { margin: 0; }
|
|
58
60
|
.fig .note { font-size: var(--small); color: var(--muted); max-width: 34ch; }
|
|
59
61
|
.fig .num { font-family: var(--serif); font-size: var(--num); line-height: 1; letter-spacing: -.01em; }
|
|
60
|
-
.fig .tag { font-family: var(--mono); font-size: var(--small); color: var(--soft); }
|
|
62
|
+
.fig .tag { font-family: var(--mono); font-size: var(--small); font-weight: 400; color: var(--soft); }
|
|
61
63
|
.fig code { font-family: var(--mono); font-size: .92em; }
|
|
62
64
|
.fig .t-accent { color: var(--accent); } .fig .t-second { color: var(--second); }
|
|
63
65
|
.fig .t-good { color: var(--good); } .fig .t-bad { color: var(--bad); }
|
|
@@ -118,6 +120,12 @@ html, body { margin: 0; padding: 0; background: transparent; }
|
|
|
118
120
|
.fig hr { border: 0; border-top: 1px solid var(--hair); margin: 0; width: 100%; }
|
|
119
121
|
.fig .divide { border-left: 1px solid var(--hair); align-self: stretch; }
|
|
120
122
|
|
|
123
|
+
/* ---------- entities: records and the references between them (a spec of type "entities") ---------- */
|
|
124
|
+
.fig .entities { display: grid; column-gap: 110px; row-gap: 96px; align-items: start; }
|
|
125
|
+
.fig.phone .entities { column-gap: 72px; row-gap: 84px; }
|
|
126
|
+
.fig .record > ul { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
127
|
+
.fig .record > ul li { font-size: var(--small); color: var(--muted); }
|
|
128
|
+
|
|
121
129
|
/* ---------- arrows (drawn by fig.js) ---------- */
|
|
122
130
|
fig-arrow { display: none; }
|
|
123
131
|
.fig > svg.fig-wires { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
|
|
@@ -129,7 +137,7 @@ fig-arrow { display: none; }
|
|
|
129
137
|
/* ---------- split bar: parts of a whole ---------- */
|
|
130
138
|
fig-split { display: flex; flex-direction: column; gap: 10px; }
|
|
131
139
|
fig-split .fs-bar { display: flex; gap: 3px; height: 58px; }
|
|
132
|
-
fig-split .fs-seg { --tone: var(--
|
|
140
|
+
fig-split .fs-seg { --tone: var(--fill); background: var(--tone); color: var(--on-fill); min-width: 4px; display: flex; align-items: center; gap: 10px; padding: 0 14px; overflow: hidden; white-space: nowrap; }
|
|
133
141
|
fig-split .fs-seg:first-child { border-radius: 8px 0 0 8px; } fig-split .fs-seg:last-child { border-radius: 0 8px 8px 0; }
|
|
134
142
|
fig-split .fs-seg.fs-out { padding: 0; }
|
|
135
143
|
fig-split .fs-seg.fs-out > * { display: none; }
|
|
@@ -145,7 +153,7 @@ fig-change { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0,
|
|
|
145
153
|
fig-change .fc-side { display: flex; flex-direction: column; gap: 10px; }
|
|
146
154
|
fig-change .fc-when { font-size: var(--small); font-weight: 600; color: var(--muted); }
|
|
147
155
|
fig-change .fc-num { font-family: var(--serif); font-weight: 400; font-size: calc(var(--num) * 1.5); line-height: 1; letter-spacing: -.01em; color: var(--ink); }
|
|
148
|
-
fig-change .fc-bar { height: 14px; min-width: 3px; background: var(--
|
|
156
|
+
fig-change .fc-bar { height: 14px; min-width: 3px; background: var(--fill); border-radius: 3px; }
|
|
149
157
|
fig-change .before .fc-num { color: var(--muted); }
|
|
150
158
|
fig-change .after .fc-num { color: var(--tone); }
|
|
151
159
|
fig-change .after .fc-bar { background: var(--tone); }
|
|
@@ -155,7 +163,7 @@ fig-change .fc-arrow { color: var(--soft); display: flex; }
|
|
|
155
163
|
fig-bars { display: grid; grid-template-columns: max-content minmax(0, 1fr); column-gap: 16px; row-gap: 10px; align-items: center; }
|
|
156
164
|
fig-bars .fb-label { font-size: var(--body); }
|
|
157
165
|
fig-bars .fb-track { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
158
|
-
fig-bars .fb-bar { --tone: var(--
|
|
166
|
+
fig-bars .fb-bar { --tone: var(--fill); height: 26px; background: var(--tone); border-radius: 3px; min-width: 3px; flex: 0 0 auto; }
|
|
159
167
|
fig-bars .fb-val { font-family: var(--serif); font-size: 1.3em; font-weight: 400; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
160
168
|
fig-bars .fb-val.lead { color: var(--tone); }
|
|
161
169
|
fig-bars .fb-sub { font-weight: 400; color: var(--soft); font-size: var(--small); margin-left: 6px; }
|
|
@@ -164,7 +172,7 @@ fig-bars .fb-sub { font-weight: 400; color: var(--soft); font-size: var(--small)
|
|
|
164
172
|
.fig .chart { min-width: 0; }
|
|
165
173
|
.fig .fd-key { display: flex; gap: 28px; margin: 0 0 18px; font-size: var(--small); font-weight: 600; color: var(--soft); }
|
|
166
174
|
.fig .fd-key span { display: inline-flex; align-items: center; gap: 8px; }
|
|
167
|
-
.fig .fd-key i { display: inline-block; width: 13px; height: 13px; border-radius: 50%; background: var(--
|
|
175
|
+
.fig .fd-key i { display: inline-block; width: 13px; height: 13px; border-radius: 50%; background: var(--fill); }
|
|
168
176
|
.fig .fd-key .fd-to i { width: 0; height: 0; border-radius: 0; background: none; border-left: 13px solid currentColor; border-top: 7.5px solid transparent; border-bottom: 7.5px solid transparent; }
|
|
169
177
|
.fig .fd-key .fd-to.back i { transform: scaleX(-1); }
|
|
170
178
|
.fig.chart > footer { border-top: 0; padding-top: 0; }
|
|
@@ -180,3 +188,33 @@ fig-bars .fb-sub { font-weight: 400; color: var(--soft); font-size: var(--small)
|
|
|
180
188
|
.fig .t-second.t-second { color: var(--second); }
|
|
181
189
|
.fig .t-good.t-good { color: var(--good); } .fig .t-bad.t-bad { color: var(--bad); }
|
|
182
190
|
.fig .decision:not(.accent):not(.second):not(.good):not(.bad) { --tone: var(--line); }
|
|
191
|
+
|
|
192
|
+
/* ---------- report: a light chart that sits in a page (a research report, a doc), set in one sans ---------- */
|
|
193
|
+
.fig.report {
|
|
194
|
+
--paper: #ffffff; --panel: #ffffff; --ink: #1d2130; --muted: #5b6170; --soft: #7a8092; --line: #9aa0ad; --hair: rgba(29,33,48,.12);
|
|
195
|
+
--fill: #b4b8c2; --fill2: #7d8391;
|
|
196
|
+
--accent: #cf5a23; --second: #2f6bc4; --good: #2f8f5b; --bad: #c9414a; --on-fill: #ffffff;
|
|
197
|
+
--serif: var(--sans);
|
|
198
|
+
--w: 720px; --pad: 32px; --gap: 14px;
|
|
199
|
+
--title: 26px; --read: 18px; --h: 18px; --body: 17px; --small: 16px; --num: 30px;
|
|
200
|
+
border: 1px solid #e6e2d8; border-radius: 10px; gap: 20px;
|
|
201
|
+
}
|
|
202
|
+
.fig.panels-fig { --w: 1000px; }
|
|
203
|
+
.fig.panels-fig.phone { --w: 560px; }
|
|
204
|
+
.fig.report h1 { font-weight: 600; letter-spacing: -.01em; line-height: 1.2; }
|
|
205
|
+
.fig.report > header { gap: 6px; }
|
|
206
|
+
.fig.report > footer { border-top: 0; padding-top: 0; }
|
|
207
|
+
.fig.report.phone { --w: 400px; --pad: 20px; --title: 22px; --read: 16px; --body: 16px; --small: 15px; }
|
|
208
|
+
.fig.report fig-bars .fb-val, .fig.report fig-split .fs-seg b, .fig.report fig-change .fc-num { font-weight: 500; letter-spacing: 0; }
|
|
209
|
+
.fig.report fig-bars .fb-val { font-size: 1.05em; }
|
|
210
|
+
.fig.report fig-bars .fb-val.lead { font-weight: 600; }
|
|
211
|
+
|
|
212
|
+
/* panels: the same chart drawn for each part, side by side, one above the other on a phone */
|
|
213
|
+
.fig .panels { display: grid; grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr)); column-gap: 36px; row-gap: 28px; }
|
|
214
|
+
.fig.phone .panels { grid-template-columns: minmax(0, 1fr); }
|
|
215
|
+
.fig .panel > h2 { font-size: calc(var(--h) * 1.15); margin: 0 0 4px; }
|
|
216
|
+
|
|
217
|
+
/* heat map scale: what the colour means, at readable size */
|
|
218
|
+
.fig .hm-key { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: var(--small); color: var(--muted); margin: 0 0 12px; }
|
|
219
|
+
.fig .hm-key i { display: inline-block; width: 160px; height: 14px; border-radius: 3px; }
|
|
220
|
+
.fig.phone .hm-key i { width: 84px; }
|
package/runtime/fig.js
CHANGED
|
@@ -63,6 +63,9 @@
|
|
|
63
63
|
const DIRS = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] };
|
|
64
64
|
function ports(el, r, other, allowed, at) {
|
|
65
65
|
const diamond = el.classList.contains('decision');
|
|
66
|
+
// A record is joined at the side of its name, never beside a field, which a line there would seem to be.
|
|
67
|
+
const h3 = el.classList.contains('record') && el.querySelector(':scope > h3');
|
|
68
|
+
const head = h3 && (() => { const t = rectOf(h3), line = Math.min(t.h, parseFloat(getComputedStyle(h3).lineHeight) || t.h); return { mid: t.y + line / 2, lo: r.y + 12, hi: Math.max(t.y + t.h + 6, r.y + 12) }; })();
|
|
66
69
|
const out = [];
|
|
67
70
|
for (const s of allowed) {
|
|
68
71
|
const d = DIRS[s];
|
|
@@ -76,10 +79,20 @@
|
|
|
76
79
|
const lo = Math.max(r.y, other.y), hi = Math.min(r.y + r.h, other.y + other.h);
|
|
77
80
|
y = !diamond && hi - lo >= 24 ? (lo + hi) / 2 : r.y + r.h / 2;
|
|
78
81
|
if (at != null && !diamond) y = r.y + r.h * at;
|
|
82
|
+
if (head) y = Math.min((head.lo + head.hi) / 2, head.mid + 4); // the first line of the name, so a wrapped name joins level with its row
|
|
79
83
|
x = s === 'left' ? r.x : r.x + r.w;
|
|
80
84
|
}
|
|
81
85
|
x = Math.round(x); y = Math.round(y);
|
|
82
|
-
out.push({ s, d, x, y, sx: x + d[0] * M, sy: y + d[1] * M });
|
|
86
|
+
out.push({ s, d, x, y, sx: x + d[0] * M, sy: y + d[1] * M, off: 0 });
|
|
87
|
+
// Spares beside the chosen point, for when another line already ends there.
|
|
88
|
+
if (at != null || diamond) continue;
|
|
89
|
+
for (const k of [-1, 1, -2, 2]) {
|
|
90
|
+
const band = head && d[0] !== 0;
|
|
91
|
+
const v = (d[0] === 0 ? x : y) + k * (band ? 18 : 26), lo = band ? head.lo : (d[0] === 0 ? r.x : r.y) + 14, hi = band ? head.hi : (d[0] === 0 ? r.x + r.w : r.y + r.h) - 14;
|
|
92
|
+
if (v < lo || v > hi) continue;
|
|
93
|
+
const px = d[0] === 0 ? v : x, py = d[0] === 0 ? y : v;
|
|
94
|
+
out.push({ s, d, x: px, y: py, sx: px + d[0] * M, sy: py + d[1] * M, off: Math.abs(k) });
|
|
95
|
+
}
|
|
83
96
|
}
|
|
84
97
|
return out;
|
|
85
98
|
}
|
|
@@ -99,12 +112,16 @@
|
|
|
99
112
|
return !others.some((r) => Math.min(seg.x + seg.w, r.x + r.w) > Math.max(seg.x, r.x) && Math.min(seg.y + seg.h, r.y + r.h) > Math.max(seg.y, r.y));
|
|
100
113
|
};
|
|
101
114
|
const pa = ports(A.el, A.r, B.r, arrow.fromSides, arrow.fromAt), pb = ports(B.el, B.r, A.r, arrow.toSides, arrow.toAt);
|
|
115
|
+
// Arrows from one box share a trunk, and so do arrows into one box; references only share the end they land on.
|
|
116
|
+
const bus = (u) => ((u.from === arrow.from && !arrow.ref) || u.to === arrow.to) && u.tone === arrow.tone;
|
|
117
|
+
// A point where another line already starts or ends costs a lot; a spare beside it costs a little.
|
|
118
|
+
const portCost = (p) => p.off * 18 + (used.some((u) => !bus(u) && ((Math.abs(u.x1 - p.x) < 12 && Math.abs(u.y1 - p.y) < 12 && u.first) || (Math.abs(u.x2 - p.x) < 12 && Math.abs(u.y2 - p.y) < 12 && u.last))) ? 140 : 0);
|
|
102
119
|
|
|
103
120
|
// Fast path: the two boxes face each other, so a straight line is the whole route.
|
|
104
121
|
const facing = [['right', 'left'], ['left', 'right'], ['bottom', 'top'], ['top', 'bottom']];
|
|
105
122
|
for (const [sa, sb] of facing) {
|
|
106
|
-
const p = pa.find((q) => q.s === sa), q = pb.find((t) => t.s === sb);
|
|
107
|
-
if (!p || !q) continue;
|
|
123
|
+
const p = pa.find((q) => q.s === sa && !q.off), q = pb.find((t) => t.s === sb && !t.off);
|
|
124
|
+
if (!p || !q || portCost(p) || portCost(q)) continue;
|
|
108
125
|
const ahead = (q.x - p.x) * p.d[0] + (q.y - p.y) * p.d[1];
|
|
109
126
|
const aligned = p.d[0] === 0 ? Math.abs(p.x - q.x) < 1 : Math.abs(p.y - q.y) < 1;
|
|
110
127
|
if (ahead > 6 && aligned && clear(p.x, p.y, q.x, q.y)) return [[p.x, p.y], [q.x, q.y]];
|
|
@@ -139,7 +156,6 @@
|
|
|
139
156
|
for (const r of soft) if (inside(r, x, y)) return 5;
|
|
140
157
|
return 1;
|
|
141
158
|
};
|
|
142
|
-
const bus = (u) => (u.from === arrow.from || u.to === arrow.to) && u.tone === arrow.tone;
|
|
143
159
|
const usedCost = (x1, y1, x2, y2) => {
|
|
144
160
|
let k = 1, plus = 0;
|
|
145
161
|
const horiz = y1 === y2;
|
|
@@ -150,19 +166,19 @@
|
|
|
150
166
|
if (off >= 10) continue;
|
|
151
167
|
const lo = horiz ? Math.max(Math.min(x1, x2), Math.min(u.x1, u.x2)) : Math.max(Math.min(y1, y2), Math.min(u.y1, u.y2));
|
|
152
168
|
const hi = horiz ? Math.min(Math.max(x1, x2), Math.max(u.x1, u.x2)) : Math.min(Math.max(y1, y2), Math.max(u.y1, u.y2));
|
|
153
|
-
if (hi - lo <= 0.5) continue;
|
|
154
|
-
if (off < 0.5 && bus(u)) k = Math.min(k, 0.85); else k = Math.max(k, 4);
|
|
169
|
+
if (hi - lo <= 0.5) { if (arrow.ref && off < 0.5 && hi - lo > -160 && !bus(u)) plus += 400; continue; }
|
|
170
|
+
if (off < 0.5 && bus(u)) k = Math.min(k, 0.85); else k = Math.max(k, arrow.ref ? 12 : 4);
|
|
155
171
|
} else if (!bus(u)) {
|
|
156
172
|
const cx = horiz ? u.x1 : x1, cy = horiz ? y1 : u.y1;
|
|
157
|
-
|
|
173
|
+
// Strictly inside this step, or at its far end: two lines that meet on a grid point cross there too.
|
|
174
|
+
const onMe = horiz ? (cx > Math.min(x1, x2) && cx < Math.max(x1, x2)) || cx === x2 : (cy > Math.min(y1, y2) && cy < Math.max(y1, y2)) || cy === y2;
|
|
158
175
|
const onU = horiz ? cy > Math.min(u.y1, u.y2) && cy < Math.max(u.y1, u.y2) : cx > Math.min(u.x1, u.x2) && cx < Math.max(u.x1, u.x2);
|
|
159
|
-
if (onMe && onU) plus += 70;
|
|
176
|
+
if (onMe && onU) plus += arrow.ref ? 1500 : 70;
|
|
160
177
|
}
|
|
161
178
|
}
|
|
162
179
|
return [k, plus];
|
|
163
180
|
};
|
|
164
181
|
|
|
165
|
-
const portCost = (p) => (used.some((u) => !bus(u) && ((Math.abs(u.x1 - p.x) < 12 && Math.abs(u.y1 - p.y) < 12 && u.first) || (Math.abs(u.x2 - p.x) < 12 && Math.abs(u.y2 - p.y) < 12 && u.last))) ? 140 : 0);
|
|
166
182
|
// Dijkstra over (column, row, heading).
|
|
167
183
|
const BEND = 64;
|
|
168
184
|
const id = (ix, iy, d) => (iy * NX + ix) * 4 + d;
|
|
@@ -294,8 +310,9 @@
|
|
|
294
310
|
if (!a || !b) { fig.problems.push(`Arrow ${from} → ${to}: there is no element with id "${!a ? from : to}".`); continue; }
|
|
295
311
|
const tone = TONES.find((t) => tag.hasAttribute(t) || tag.getAttribute('tone') === t) ?? (tag.getAttribute('tone') === 'ink' ? 'ink' : 'line');
|
|
296
312
|
const color = css('--' + tone);
|
|
313
|
+
const ref = tag.getAttribute('ref');
|
|
297
314
|
const arrow = {
|
|
298
|
-
from, to, tone, fromSides: sides(tag.getAttribute('from-side')), toSides: sides(tag.getAttribute('to-side')),
|
|
315
|
+
from, to, tone, ref, fromSides: sides(tag.getAttribute('from-side')), toSides: sides(tag.getAttribute('to-side')),
|
|
299
316
|
fromAt: num(tag.getAttribute('from-at')), toAt: num(tag.getAttribute('to-at')),
|
|
300
317
|
};
|
|
301
318
|
const A = { el: a, r: rectOf(a) }, B = { el: b, r: rectOf(b) };
|
|
@@ -310,7 +327,7 @@
|
|
|
310
327
|
if (mode === 'ortho') {
|
|
311
328
|
pts = routeOrtho(A, B, mine, arrow, used, W, H);
|
|
312
329
|
if (!pts) { fig.problems.push(`Arrow ${from} → ${to}: no route found. Leave a gap beside the boxes, or set from-side / to-side.`); continue; }
|
|
313
|
-
for (let i = 1; i < pts.length; i++) used.push({ x1: pts[i - 1][0], y1: pts[i - 1][1], x2: pts[i][0], y2: pts[i][1], from, to, tone, first: i === 1, last: i === pts.length - 1 });
|
|
330
|
+
for (let i = 1; i < pts.length; i++) used.push({ x1: pts[i - 1][0], y1: pts[i - 1][1], x2: pts[i][0], y2: pts[i][1], from, to, tone, ref, first: i === 1, last: i === pts.length - 1 });
|
|
314
331
|
} else {
|
|
315
332
|
const { p, q } = routeLoose(A, B, arrow);
|
|
316
333
|
if (mode === 'straight') {
|
|
@@ -328,26 +345,121 @@
|
|
|
328
345
|
// Heads first, so the line stops at the base of each head.
|
|
329
346
|
let start = pts[0], end = pts[pts.length - 1];
|
|
330
347
|
const beforeEnd = pts.curve ? pts.curve[1] : pts[pts.length - 2], afterStart = pts.curve ? pts.curve[0] : pts[1];
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (
|
|
348
|
+
// A reference has no head: a dot on the record it names, and on the holder a crow's foot when many can hold
|
|
349
|
+
// one, a short bar when only one can.
|
|
350
|
+
if (ref) svgEl('circle', { cx: end[0], cy: end[1], r: weight + 4, fill: color }, svg);
|
|
351
|
+
if (ref && !pts.curve) {
|
|
352
|
+
const [ax, ay] = pts[0], [bx, by] = pts[1], len = Math.hypot(bx - ax, by - ay) || 1, ux = (bx - ax) / len, uy = (by - ay) / len;
|
|
353
|
+
const mark = (x1, y1, x2, y2) => svgEl('line', { x1, y1, x2, y2, stroke: color, 'stroke-width': weight, 'stroke-linecap': 'round' }, svg);
|
|
354
|
+
if (ref === 'many') {
|
|
355
|
+
const tip = [ax + ux * 20, ay + uy * 20];
|
|
356
|
+
for (const k of [-1, 0, 1]) mark(tip[0], tip[1], ax - uy * k * 11, ay + ux * k * 11);
|
|
357
|
+
} else {
|
|
358
|
+
const at = [ax + ux * 12, ay + uy * 12];
|
|
359
|
+
mark(at[0] - uy * 7, at[1] + ux * 7, at[0] + uy * 7, at[1] - ux * 7);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
else if (heads === 'blocked') end = blockedEnd(svg, end, beforeEnd, color, weight);
|
|
363
|
+
if (!ref && (heads === 'end' || heads === 'both')) end = head(svg, end, beforeEnd, color, size);
|
|
364
|
+
if (!ref && heads === 'both') start = head(svg, start, afterStart, color, size);
|
|
334
365
|
const line = [start, ...pts.slice(1, -1), end];
|
|
335
366
|
pathD = pts.curve ? `M${start[0]},${start[1]} C${pts.curve[0][0]},${pts.curve[0][1]} ${pts.curve[1][0]},${pts.curve[1][1]} ${end[0]},${end[1]}` : roundedPath(line);
|
|
336
367
|
const attrs = { d: pathD, fill: 'none', stroke: color, 'stroke-width': weight, 'stroke-linecap': 'round' };
|
|
337
368
|
if (dash) attrs['stroke-dasharray'] = dash;
|
|
338
369
|
svgEl('path', attrs, svg);
|
|
339
|
-
fig.wires.push({ from, to, pts, mode });
|
|
370
|
+
fig.wires.push({ from, to, pts, mode, ref });
|
|
340
371
|
|
|
341
|
-
const text = tag.getAttribute('label') ?? tag.textContent.trim();
|
|
372
|
+
const text = ref ? '' : tag.getAttribute('label') ?? tag.textContent.trim();
|
|
342
373
|
if (text) labels.push({ text, tone, color, pts, at: labelAt, want: tag.getAttribute('label-at'), from, to });
|
|
343
374
|
}
|
|
344
|
-
placeLabels(labels, obs);
|
|
375
|
+
return placeLabels(labels, obs);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Pairs of drawn lines that cross. Arrows from one box, or into one box, share a trunk, so meeting is not crossing. */
|
|
379
|
+
function crossingPairs() {
|
|
380
|
+
const segs = (fig.wires ?? []).filter((w) => w.mode !== 'curve').flatMap((w) => w.pts.slice(1).map((b, i) => ({ w, a: w.pts[i], b })));
|
|
381
|
+
const out = [];
|
|
382
|
+
for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) {
|
|
383
|
+
const s = segs[i], t = segs[j];
|
|
384
|
+
if (s.w === t.w || (s.w.from === t.w.from && !(s.w.ref && t.w.ref)) || s.w.to === t.w.to) continue;
|
|
385
|
+
const sh = s.a[1] === s.b[1], th = t.a[1] === t.b[1];
|
|
386
|
+
if (sh === th) continue;
|
|
387
|
+
const h = sh ? s : t, v = sh ? t : s;
|
|
388
|
+
const x = v.a[0], y = h.a[1];
|
|
389
|
+
if (x > Math.min(h.a[0], h.b[0]) + 1 && x < Math.max(h.a[0], h.b[0]) - 1 && y > Math.min(v.a[1], v.b[1]) + 1 && y < Math.max(v.a[1], v.b[1]) - 1
|
|
390
|
+
&& !out.some(([u, w]) => u === s.w && w === t.w)) out.push([s.w, t.w]);
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* A label that found no free place: open the gap between its two boxes so it has one, and say whether anything
|
|
397
|
+
* changed. The gap grows on the row, column or grid that holds both boxes, between the parts holding each.
|
|
398
|
+
*/
|
|
399
|
+
function widen(fails, tried) {
|
|
400
|
+
let changed = false;
|
|
401
|
+
for (const f of fails) {
|
|
402
|
+
const key = f.from + '>' + f.to;
|
|
403
|
+
if (tried.has(key)) continue;
|
|
404
|
+
tried.set(key, null);
|
|
405
|
+
const a = document.getElementById(f.from), b = document.getElementById(f.to);
|
|
406
|
+
let lca = a.parentElement;
|
|
407
|
+
while (lca && !lca.contains(b)) lca = lca.parentElement;
|
|
408
|
+
if (!lca || !root().contains(lca)) continue;
|
|
409
|
+
const ca = [...lca.children].find((c) => c.contains(a)), cb = [...lca.children].find((c) => c.contains(b));
|
|
410
|
+
if (!ca || !cb || ca === cb) continue;
|
|
411
|
+
const cs = getComputedStyle(lca);
|
|
412
|
+
if (!/flex|grid/.test(cs.display)) continue;
|
|
413
|
+
const ra = ca.getBoundingClientRect(), rb = cb.getBoundingClientRect();
|
|
414
|
+
const across = ra.right <= rb.left + 1 || rb.right <= ra.left + 1;
|
|
415
|
+
const now = across ? Math.max(rb.left - ra.right, ra.left - rb.right) : Math.max(rb.top - ra.bottom, ra.top - rb.bottom);
|
|
416
|
+
const more = Math.max((across ? f.w : f.h) + 64 - now, 24);
|
|
417
|
+
const R = root();
|
|
418
|
+
const undo = { el: lca, prop: across ? 'columnGap' : 'rowGap', gap: lca.style[across ? 'columnGap' : 'rowGap'], w: R.style.width, v: R.style.getPropertyValue('--w') };
|
|
419
|
+
lca.style[undo.prop] = (parseFloat(cs[undo.prop]) || 0) + more + 'px';
|
|
420
|
+
// A wider gap must not squeeze a box until its text overflows: the figure grows instead, by at most a third.
|
|
421
|
+
if (across && squeezed()) {
|
|
422
|
+
const base = Number(R.dataset.baseWidth ||= String(R.offsetWidth));
|
|
423
|
+
const gaps = /grid/.test(cs.display) ? cs.gridTemplateColumns.split(' ').length - 1 : [...lca.children].filter((c) => c.offsetParent !== null).length - 1;
|
|
424
|
+
const wider = R.offsetWidth + more * Math.max(gaps, 1);
|
|
425
|
+
if (wider > base * 1.34) { lca.style[undo.prop] = undo.gap; continue; }
|
|
426
|
+
R.style.setProperty('--w', wider + 'px');
|
|
427
|
+
R.style.width = wider + 'px';
|
|
428
|
+
if (squeezed()) { lca.style[undo.prop] = undo.gap; R.style.width = undo.w; if (undo.v) R.style.setProperty('--w', undo.v); else R.style.removeProperty('--w'); continue; }
|
|
429
|
+
}
|
|
430
|
+
tried.set(key, undo);
|
|
431
|
+
changed = true;
|
|
432
|
+
}
|
|
433
|
+
return changed;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Whether any text now runs past the right edge of its box. */
|
|
437
|
+
function squeezed() {
|
|
438
|
+
return [...root().querySelectorAll('.node')].some((n) => {
|
|
439
|
+
const b = rectOf(n);
|
|
440
|
+
return [...n.querySelectorAll('*')].some((e) => [...e.childNodes].some((c) => c.nodeType === 3 && c.textContent.trim()) && (() => { const t = textRect(e); return t.w && t.x + t.w > b.x + b.w + 1; })());
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Puts back a widening that did not free its label (an arrow that runs around the outside, not through the gap). */
|
|
445
|
+
function unwiden(fails, tried) {
|
|
446
|
+
let changed = false;
|
|
447
|
+
for (const f of fails) {
|
|
448
|
+
const undo = tried.get(f.from + '>' + f.to);
|
|
449
|
+
if (!undo) continue;
|
|
450
|
+
undo.el.style[undo.prop] = undo.gap;
|
|
451
|
+
root().style.width = undo.w;
|
|
452
|
+
if (undo.v) root().style.setProperty('--w', undo.v); else root().style.removeProperty('--w');
|
|
453
|
+
tried.set(f.from + '>' + f.to, null);
|
|
454
|
+
changed = true;
|
|
455
|
+
}
|
|
456
|
+
return changed;
|
|
345
457
|
}
|
|
346
458
|
|
|
347
459
|
/** Put each arrow label where it touches nothing: on its line if the line is long enough, otherwise beside it. */
|
|
348
460
|
function placeLabels(labels, obs) {
|
|
349
461
|
const R = root(), W = R.offsetWidth, H = R.offsetHeight;
|
|
350
|
-
const placed = [];
|
|
462
|
+
const placed = [], fails = [];
|
|
351
463
|
const segsOf = (pts) => pts.slice(1).map((b, i) => ({ a: pts[i], b, len: Math.hypot(b[0] - pts[i][0], b[1] - pts[i][1]) }));
|
|
352
464
|
const wireHit = (r, skip) => (fig.wires ?? []).some((w) => segsOf(w.pts).some((s) => s !== skip && !(skip && s.a === skip.a && s.b === skip.b)
|
|
353
465
|
&& Math.min(Math.max(s.a[0], s.b[0]), r.x + r.w) >= Math.max(Math.min(s.a[0], s.b[0]), r.x) && Math.min(Math.max(s.a[1], s.b[1]), r.y + r.h) >= Math.max(Math.min(s.a[1], s.b[1]), r.y)));
|
|
@@ -396,11 +508,12 @@
|
|
|
396
508
|
if (placed.some((p) => overlap(grow(r, 4), p))) return false;
|
|
397
509
|
return !wireHit(grow(r, 2), cand.on);
|
|
398
510
|
};
|
|
399
|
-
const pick = cands.find(free) ?? cands[0];
|
|
511
|
+
const pick = cands.find(free) ?? (fails.push({ from: L.from, to: L.to, w, h }), cands[0]);
|
|
400
512
|
lab.style.left = pick.c[0] + 'px';
|
|
401
513
|
lab.style.top = pick.c[1] + 'px';
|
|
402
514
|
placed.push(rectAt(pick.c));
|
|
403
515
|
}
|
|
516
|
+
return fails;
|
|
404
517
|
}
|
|
405
518
|
|
|
406
519
|
// ---------- split bar and bars (drawn by viz/draw.js, loaded as window.figkitViz) ----------
|
|
@@ -431,7 +544,7 @@
|
|
|
431
544
|
if (!el) { fig.problems.push(`fig.plot: nothing matches ${target}.`); return; }
|
|
432
545
|
el.classList.add('chart');
|
|
433
546
|
const width = Math.floor(el.clientWidth) || 640;
|
|
434
|
-
const t = { ink: css('--ink'), muted: css('--muted'), soft: css('--soft'), line: css('--line'), hair: css('--hair'), accent: css('--accent'), second: css('--second'), good: css('--good'), bad: css('--bad'), paper: css('--paper'), panel: css('--panel') };
|
|
547
|
+
const t = { ink: css('--ink'), muted: css('--muted'), soft: css('--soft'), fill: css('--fill'), fill2: css('--fill2'), line: css('--line'), hair: css('--hair'), accent: css('--accent'), second: css('--second'), good: css('--good'), bad: css('--bad'), paper: css('--paper'), panel: css('--panel') };
|
|
435
548
|
try {
|
|
436
549
|
const opts = build({ Plot: window.Plot, d3: window.d3, width, theme: t });
|
|
437
550
|
const size = parseFloat(css('--small')) || 15;
|
|
@@ -503,17 +616,10 @@
|
|
|
503
616
|
if (hit) P.push(`Arrow ${w.from} → ${w.to} runs through ${name(o.el)}.`);
|
|
504
617
|
}
|
|
505
618
|
}
|
|
506
|
-
const crossings = new Set();
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const sh = s.a[1] === s.b[1], th = t.a[1] === t.b[1];
|
|
511
|
-
if (sh === th) continue;
|
|
512
|
-
const h = sh ? s : t, v = sh ? t : s;
|
|
513
|
-
const x = v.a[0], y = h.a[1];
|
|
514
|
-
if (x > Math.min(h.a[0], h.b[0]) + 1 && x < Math.max(h.a[0], h.b[0]) - 1 && y > Math.min(v.a[1], v.b[1]) + 1 && y < Math.max(v.a[1], v.b[1]) - 1) crossings.add(`${s.w.from} → ${s.w.to} and ${t.w.from} → ${t.w.to}`);
|
|
515
|
-
}
|
|
516
|
-
if (crossings.size) P.push(`Arrows cross: ${[...crossings].join('; ')}. Reorder the boxes so they do not, or drop an arrow.`);
|
|
619
|
+
const crossings = new Set(crossingPairs().map(([u, w]) => `${u.from} → ${u.to} and ${w.from} → ${w.to}`));
|
|
620
|
+
if (crossings.size) P.push(R.querySelector('.entities')
|
|
621
|
+
? `References cross: ${[...crossings].join('; ')}. Split the records over two figures; do not leave a reference out.`
|
|
622
|
+
: `Arrows cross: ${[...crossings].join('; ')}. Reorder the boxes so they do not, or drop an arrow.`);
|
|
517
623
|
|
|
518
624
|
// Neighbours that almost line up.
|
|
519
625
|
const boxes = obs.filter((o) => !o.text && !o.el.classList.contains('fig-wire-label') && !o.el.classList.contains('decision') && !o.el.parentElement.closest('.node'));
|
|
@@ -521,21 +627,31 @@
|
|
|
521
627
|
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
|
|
522
628
|
const a = boxes[i], b = boxes[j];
|
|
523
629
|
if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
|
|
630
|
+
// Records share a top edge in their row; their heights follow their fields, which figkit placed, not the author.
|
|
631
|
+
if (a.el.classList.contains('record') && b.el.classList.contains('record')) continue;
|
|
524
632
|
const edges = [['left edges', a.r.x, b.r.x], ['right edges', a.r.x + a.r.w, b.r.x + b.r.w], ['top edges', a.r.y, b.r.y], ['bottom edges', a.r.y + a.r.h, b.r.y + b.r.h]];
|
|
525
633
|
for (const [what, u, v] of edges) { const d = Math.abs(u - v); if (d > 0.75 && d <= 6) near.push(`${name(a.el)} and ${name(b.el)} (${what}, ${d.toFixed(0)}px apart)`); }
|
|
526
634
|
}
|
|
527
635
|
if (near.length) P.push(`Almost aligned but not quite: ${near.slice(0, 6).join('; ')}${near.length > 6 ? `; and ${near.length - 6} more` : ''}. Put them in one row, column or grid so they share the edge.`);
|
|
528
636
|
|
|
529
637
|
// Too much on the page.
|
|
530
|
-
|
|
638
|
+
// Panels repeat the same labels in each panel; a label read once counts once.
|
|
639
|
+
const panelText = R.querySelector('.panels') ? [...new Set([...R.querySelectorAll('.panels text, .panels h2')].map((e) => e.textContent.trim()))].join(' ') : null;
|
|
640
|
+
const outside = R.querySelector('.panels') ? [...R.children].filter((c) => !c.matches('script, style') && !c.querySelector('.panels') && !c.matches('.panels')).map((c) => c.innerText).join(' ') : null;
|
|
641
|
+
const words = (panelText !== null ? outside + ' ' + panelText : R.innerText).split(/\s+/).filter(Boolean).length;
|
|
531
642
|
const nodes = R.querySelectorAll('.node:not(.bare), .decision').length, arrows = (fig.wires ?? []).length;
|
|
532
|
-
const budget = detail ? { words: 190, nodes: 18, arrows: 18 } : { words: 70, nodes:
|
|
643
|
+
const budget = detail ? { words: 190, nodes: 18, arrows: 18 } : { words: 70, nodes: 5, arrows: 8 };
|
|
533
644
|
if (words > budget.words) P.push(`${words} words; a ${detail ? 'detail' : 'glance'} figure holds about ${budget.words}. Cut sentences that explain, and keep labels.`);
|
|
534
|
-
if (nodes > budget.nodes) P.push(`${nodes} boxes; more than ${budget.nodes} stops reading as one picture. Split it into two figures or merge boxes
|
|
645
|
+
if (nodes > budget.nodes) P.push(`${nodes} boxes; more than ${budget.nodes} stops reading as one picture. ${detail ? 'Split it into two figures or merge boxes.' : 'Split it into two or three figures in a row (what exists, one case through it, what changes), or merge boxes.'}`);
|
|
535
646
|
if (arrows > budget.arrows) P.push(`${arrows} arrows; more than ${budget.arrows} tangles. Show the main path and drop the rest.`);
|
|
536
647
|
const tones = TONES.filter((t) => R.querySelector(`.${t}, [${t}], .t-${t}`));
|
|
537
648
|
if (tones.length > 2) P.push(`${tones.length} colours carry meaning (${tones.join(', ')}); use one for what the reader must notice and at most one more.`);
|
|
538
649
|
for (const o of obs) if (o.text || o.el.matches('.node')) { const t = o.el.innerText ?? ''; if ((t.match(/ [·|•] /g) ?? []).length >= 1 && !o.el.querySelector('*')) P.push(`${name(o.el)} strings facts together with separators; give each fact its own line or cut one.`); }
|
|
650
|
+
// A face that never loaded draws in whatever the machine has, and every measurement above is of that fallback.
|
|
651
|
+
const faces = [...document.fonts];
|
|
652
|
+
const families = [...new Set(['--sans', '--serif'].map((v) => css(v).split(',')[0].replace(/["']/g, '').trim()))];
|
|
653
|
+
const missing = families.filter((f) => !faces.some((x) => x.family.replace(/["']/g, '') === f && x.status === 'loaded'));
|
|
654
|
+
if (missing.length) P.push(`figkit's fonts did not load (${missing.join(', ')}); the figure is drawn in fallback fonts. Reinstall figkit with its dependencies.`);
|
|
539
655
|
const h1 = R.querySelector('h1');
|
|
540
656
|
if (!h1) P.push('No <h1>: give the figure a title that states its point.');
|
|
541
657
|
else if (h1.innerText.split(/\s+/).length > 14) P.push('The title is over 14 words; state the point more shortly.');
|
|
@@ -564,7 +680,30 @@
|
|
|
564
680
|
else { buildSplits(); buildBars(); }
|
|
565
681
|
for (const job of fig.queue) job();
|
|
566
682
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
567
|
-
|
|
683
|
+
// A label with no free place opens the gap it needs, then everything is drawn again.
|
|
684
|
+
const tried = new Map();
|
|
685
|
+
for (let i = 0; ; i++) {
|
|
686
|
+
const fails = drawArrows();
|
|
687
|
+
if (!fails.length || i === 6) break;
|
|
688
|
+
const undone = unwiden(fails, tried), widened = widen(fails, tried);
|
|
689
|
+
if (!undone && !widened) break;
|
|
690
|
+
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
691
|
+
}
|
|
692
|
+
// Two lines that cross can often pass each other if the later one is drawn first.
|
|
693
|
+
for (let i = 0; i < 4; i++) {
|
|
694
|
+
const pairs = crossingPairs();
|
|
695
|
+
if (!pairs.length) break;
|
|
696
|
+
const tags = [...document.querySelectorAll('fig-arrow')], at = (w) => tags.find((t) => t.getAttribute('from') === w.from && t.getAttribute('to') === w.to);
|
|
697
|
+
const [u, w] = pairs[0], first = at(u), second = at(w);
|
|
698
|
+
if (!first || !second) break;
|
|
699
|
+
const mark = document.createComment('');
|
|
700
|
+
second.replaceWith(mark); first.before(second);
|
|
701
|
+
drawArrows();
|
|
702
|
+
if (crossingPairs().length < pairs.length) { mark.remove(); continue; }
|
|
703
|
+
second.remove(); mark.replaceWith(second);
|
|
704
|
+
drawArrows();
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
568
707
|
check();
|
|
569
708
|
} catch (e) { fig.problems.push('figkit failed: ' + (e.stack || e.message)); }
|
|
570
709
|
window.__figDone = true;
|