@liggi/figkit 0.4.0 → 0.6.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 +154 -12
- package/THIRD_PARTY_NOTICES.md +48 -1
- package/bin/fig.mjs +5 -3
- package/dist/viz.iife.js +86 -18
- package/examples/charts/bars-ref.json +6 -0
- package/examples/charts/estimates.json +7 -0
- package/examples/charts/heatmap-signed.json +5 -0
- package/examples/charts/map.json +269 -0
- package/examples/charts/states.json +71 -0
- package/package.json +2 -1
- package/runtime/fig.css +56 -0
- package/runtime/fig.js +218 -20
- package/viz/draw.js +168 -14
- package/viz/map-spec.js +121 -0
- package/viz/map.js +513 -0
- package/viz/spec.js +64 -6
- package/viz/states-spec.js +161 -0
- package/viz/states.js +203 -0
package/runtime/fig.js
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
function obstacles() {
|
|
43
43
|
const out = [];
|
|
44
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) });
|
|
45
|
+
for (const el of solid) if (!el.classList.contains('bare') || el.classList.contains('st-bracket')) out.push({ el, r: rectOf(el) });
|
|
46
46
|
const walker = document.createTreeWalker(root(), NodeFilter.SHOW_TEXT);
|
|
47
47
|
const seen = new Set();
|
|
48
48
|
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
@@ -107,6 +107,11 @@
|
|
|
107
107
|
function routeOrtho(A, B, obs, arrow, used, W, H) {
|
|
108
108
|
const hard = obs.map((o) => grow(o.r, 3)), soft = obs.map((o) => grow(o.r, M - 1));
|
|
109
109
|
const others = obs.filter((o) => o.el !== A.el && o.el !== B.el).map((o) => grow(o.r, 3));
|
|
110
|
+
// On a map, a line running along a region's border reads as part of the border, so a step beside one costs more.
|
|
111
|
+
const borders = [...root().querySelectorAll('.map-region')].map(rectOf);
|
|
112
|
+
const hugs = (x1, y1, x2, y2) => borders.some((r) => (y1 === y2
|
|
113
|
+
? Math.min(Math.abs(y1 - r.y), Math.abs(y1 - r.y - r.h)) < 16 && Math.max(x1, x2) > r.x && Math.min(x1, x2) < r.x + r.w
|
|
114
|
+
: Math.min(Math.abs(x1 - r.x), Math.abs(x1 - r.x - r.w)) < 16 && Math.max(y1, y2) > r.y && Math.min(y1, y2) < r.y + r.h));
|
|
110
115
|
const clear = (x1, y1, x2, y2) => {
|
|
111
116
|
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
117
|
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));
|
|
@@ -144,7 +149,8 @@
|
|
|
144
149
|
}
|
|
145
150
|
add(xs, 14, 'edge'); add(xs, W - 14, 'edge'); add(ys, 14, 'edge'); add(ys, H - 14, 'edge');
|
|
146
151
|
const pad = parseFloat(getComputedStyle(root()).paddingLeft) || 0;
|
|
147
|
-
|
|
152
|
+
// A map's regions start at the padding, so a line there would run along their edges.
|
|
153
|
+
if (pad > 14 && !root().querySelector('.map')) { add(xs, pad, 'mid'); add(xs, W - pad, 'mid'); }
|
|
148
154
|
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
155
|
const X = [...xs.keys()].sort((a, b) => a - b), Y = [...ys.keys()].sort((a, b) => a - b);
|
|
150
156
|
const NX = X.length, NY = Y.length;
|
|
@@ -156,6 +162,8 @@
|
|
|
156
162
|
for (const r of soft) if (inside(r, x, y)) return 5;
|
|
157
163
|
return 1;
|
|
158
164
|
};
|
|
165
|
+
// On a map, lines run a long way side by side between regions; closer than 24px apart they cannot be told apart.
|
|
166
|
+
const near = borders.length ? 24 : 10;
|
|
159
167
|
const usedCost = (x1, y1, x2, y2) => {
|
|
160
168
|
let k = 1, plus = 0;
|
|
161
169
|
const horiz = y1 === y2;
|
|
@@ -163,17 +171,19 @@
|
|
|
163
171
|
const uh = u.y1 === u.y2;
|
|
164
172
|
if (uh === horiz) {
|
|
165
173
|
const off = horiz ? Math.abs(u.y1 - y1) : Math.abs(u.x1 - x1);
|
|
166
|
-
if (off >=
|
|
174
|
+
if (off >= near) continue;
|
|
167
175
|
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
176
|
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
|
-
|
|
170
|
-
if (
|
|
177
|
+
// Carrying straight on where another line stops reads as that line going on; for the highlighted path, as the path going somewhere it doesn't.
|
|
178
|
+
if (hi - lo <= 0.5) { if ((arrow.ref || (borders.length && u.tone === 'accent' && arrow.tone !== 'accent')) && off < 0.5 && hi - lo > -160 && !bus(u)) plus += 400; continue; }
|
|
179
|
+
if (off < 0.5 && bus(u)) k = Math.min(k, 0.85); else k = Math.max(k, arrow.ref || (borders.length && u.tone === 'accent' && arrow.tone !== 'accent') ? 12 : 4);
|
|
171
180
|
} else if (!bus(u)) {
|
|
172
181
|
const cx = horiz ? u.x1 : x1, cy = horiz ? y1 : u.y1;
|
|
173
182
|
// Strictly inside this step, or at its far end: two lines that meet on a grid point cross there too.
|
|
174
183
|
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
184
|
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
|
-
|
|
185
|
+
// On a map, a grey line crossing the highlighted path breaks the run the reader follows, so it goes round.
|
|
186
|
+
if (onMe && onU) plus += arrow.ref ? 1500 : borders.length && u.tone === 'accent' && arrow.tone !== 'accent' ? 900 : 70;
|
|
177
187
|
}
|
|
178
188
|
}
|
|
179
189
|
return [k, plus];
|
|
@@ -210,8 +220,9 @@
|
|
|
210
220
|
const jx = ix + VEC[nd][0], jy = iy + VEC[nd][1];
|
|
211
221
|
if (jx < 0 || jy < 0 || jx >= NX || jy >= NY) continue;
|
|
212
222
|
const x1 = X[ix], y1 = Y[iy], x2 = X[jx], y2 = Y[jy];
|
|
213
|
-
|
|
223
|
+
let m = mult((x1 + x2) / 2, (y1 + y2) / 2);
|
|
214
224
|
if (m === Infinity) continue;
|
|
225
|
+
if (borders.length && hugs(x1, y1, x2, y2)) m *= 3;
|
|
215
226
|
const [k, plus] = usedCost(x1, y1, x2, y2);
|
|
216
227
|
const len = Math.abs(x2 - x1) + Math.abs(y2 - y1);
|
|
217
228
|
const cost = c + len * m * k * (VEC[nd][0] === 0 ? kx[ix] : ky[iy]) + plus + (nd === d ? 0 : BEND);
|
|
@@ -367,15 +378,58 @@
|
|
|
367
378
|
const attrs = { d: pathD, fill: 'none', stroke: color, 'stroke-width': weight, 'stroke-linecap': 'round' };
|
|
368
379
|
if (dash) attrs['stroke-dasharray'] = dash;
|
|
369
380
|
svgEl('path', attrs, svg);
|
|
370
|
-
fig.wires.push({ from, to, pts, mode, ref });
|
|
381
|
+
fig.wires.push({ from, to, pts, mode, ref, tone });
|
|
371
382
|
|
|
372
383
|
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 });
|
|
384
|
+
if (text) labels.push({ text, sub: tag.getAttribute('sub'), tone, color, pts, at: labelAt, want: tag.getAttribute('label-at'), from, to });
|
|
374
385
|
}
|
|
375
386
|
return placeLabels(labels, obs);
|
|
376
387
|
}
|
|
377
388
|
|
|
378
389
|
/** Pairs of drawn lines that cross. Arrows from one box, or into one box, share a trunk, so meeting is not crossing. */
|
|
390
|
+
/** Pairs of lines that run along each other on the same track, so the eye cannot tell which goes where. */
|
|
391
|
+
function overlapPairs() {
|
|
392
|
+
const segs = (fig.wires ?? []).filter((w) => w.mode !== 'curve').flatMap((w) => w.pts.slice(1).map((b, i) => ({ w, a: w.pts[i], b })));
|
|
393
|
+
const out = [];
|
|
394
|
+
for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) {
|
|
395
|
+
const s = segs[i], t = segs[j];
|
|
396
|
+
if (s.w === t.w || s.w.from === t.w.from || s.w.to === t.w.to) continue;
|
|
397
|
+
const sh = s.a[1] === s.b[1], th = t.a[1] === t.b[1];
|
|
398
|
+
if (sh !== th) continue;
|
|
399
|
+
const k = sh ? 1 : 0, q = 1 - k;
|
|
400
|
+
if (Math.abs(s.a[k] - t.a[k]) >= 3) continue;
|
|
401
|
+
const lo = Math.max(Math.min(s.a[q], s.b[q]), Math.min(t.a[q], t.b[q])), hi = Math.min(Math.max(s.a[q], s.b[q]), Math.max(t.a[q], t.b[q]));
|
|
402
|
+
if (hi - lo > 4 && !out.some(([u, w]) => u === s.w && w === t.w)) out.push([s.w, t.w]);
|
|
403
|
+
}
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* On a map, how the highlighted path reads: grey links crossing it, and each run along it (from a highlighted link
|
|
409
|
+
* no other highlighted link feeds, to one that feeds none) that doubles back on itself, left then right or down then up.
|
|
410
|
+
*/
|
|
411
|
+
function accentStats() {
|
|
412
|
+
const ws = (fig.wires ?? []).filter((w) => w.mode !== 'curve'), hot = ws.filter((w) => w.tone === 'accent');
|
|
413
|
+
const crossed = crossingPairs().filter(([u, w]) => (u.tone === 'accent') !== (w.tone === 'accent'));
|
|
414
|
+
const runs = [];
|
|
415
|
+
const walk = (w, path) => {
|
|
416
|
+
const next = hot.filter((n) => n.from === w.to && !path.includes(n));
|
|
417
|
+
if (!next.length) runs.push([...path, w]);
|
|
418
|
+
for (const n of next) walk(n, [...path, w]);
|
|
419
|
+
};
|
|
420
|
+
for (const w of hot) if (!hot.some((u) => u.to === w.from)) walk(w, []);
|
|
421
|
+
const back = runs.filter((run) => {
|
|
422
|
+
const seen = new Set();
|
|
423
|
+
for (const w of run) for (let i = 1; i < w.pts.length; i++) {
|
|
424
|
+
const dx = w.pts[i][0] - w.pts[i - 1][0], dy = w.pts[i][1] - w.pts[i - 1][1];
|
|
425
|
+
if (Math.abs(dx) >= 40) seen.add(dx > 0 ? 'r' : 'l');
|
|
426
|
+
if (Math.abs(dy) >= 40) seen.add(dy > 0 ? 'd' : 'u');
|
|
427
|
+
}
|
|
428
|
+
return (seen.has('r') && seen.has('l')) || (seen.has('d') && seen.has('u'));
|
|
429
|
+
});
|
|
430
|
+
return { crossed, back, runs, any: hot.length > 0 };
|
|
431
|
+
}
|
|
432
|
+
|
|
379
433
|
function crossingPairs() {
|
|
380
434
|
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
435
|
const out = [];
|
|
@@ -460,13 +514,17 @@
|
|
|
460
514
|
function placeLabels(labels, obs) {
|
|
461
515
|
const R = root(), W = R.offsetWidth, H = R.offsetHeight;
|
|
462
516
|
const placed = [], fails = [];
|
|
517
|
+
const onLine = !!R.querySelector('.map-region');
|
|
518
|
+
const heads = [...R.querySelectorAll('.mp-head')].map(rectOf);
|
|
463
519
|
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)
|
|
520
|
+
const wireHit = (r, skip, own) => (fig.wires ?? []).some((w) => w.pts !== own && segsOf(w.pts).some((s) => s !== skip && !(skip && s.a === skip.a && s.b === skip.b)
|
|
465
521
|
&& 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
522
|
for (const L of labels) {
|
|
467
523
|
const lab = document.createElement('div');
|
|
468
524
|
lab.className = 'fig-wire-label';
|
|
469
525
|
lab.textContent = L.text;
|
|
526
|
+
// Further lines under the label, such as a count: one span each.
|
|
527
|
+
for (const t of (L.sub ?? '').split('\n').filter(Boolean)) { const e = document.createElement('span'); e.className = 'fig-wire-sub'; e.textContent = t; lab.appendChild(e); }
|
|
470
528
|
if (L.tone !== 'line') lab.style.color = L.color;
|
|
471
529
|
lab.dataset.arrow = `${L.from} → ${L.to}`;
|
|
472
530
|
R.appendChild(lab);
|
|
@@ -490,9 +548,15 @@
|
|
|
490
548
|
for (const seg of segs) {
|
|
491
549
|
const horiz = seg.a[1] === seg.b[1], mid = [(seg.a[0] + seg.b[0]) / 2, (seg.a[1] + seg.b[1]) / 2];
|
|
492
550
|
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
|
-
|
|
551
|
+
// On a map, many lines run side by side between regions, so a label beside a line reads as naming its
|
|
552
|
+
// neighbour: there a label sits only on its own line, moved along it until it is clear.
|
|
553
|
+
// Clear of the line's ends: a little at a bend or the start, more before the arrowhead on the last segment.
|
|
554
|
+
const half = (horiz ? w : h) / 2, lastSeg = seg.b === L.pts[L.pts.length - 1], lo = half + 8, hi = seg.len - half - (lastSeg ? 26 : 8), slide = [];
|
|
555
|
+
if (onLine && hi >= lo) for (let d = 0, mid = (lo + hi) / 2; d <= (hi - lo) / 2; d += 4) slide.push((mid - d) / seg.len, (mid + d) / seg.len);
|
|
556
|
+
for (const t of onLine ? slide : [0.5, 0.3, 0.7]) {
|
|
494
557
|
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 });
|
|
558
|
+
if (onLine || seg.len >= (horiz ? w : h) + 36) cands.push({ c, on: real });
|
|
559
|
+
if (onLine) continue;
|
|
496
560
|
if (horiz) cands.push({ c: [c[0], c[1] - h / 2 - 7] }, { c: [c[0], c[1] + h / 2 + 7] });
|
|
497
561
|
else cands.push({ c: [c[0] + w / 2 + 9, c[1]] }, { c: [c[0] - w / 2 - 9, c[1]] });
|
|
498
562
|
}
|
|
@@ -504,11 +568,19 @@
|
|
|
504
568
|
const r = rectAt(cand.c);
|
|
505
569
|
if (r.x < 8 || r.y < 8 || r.x + r.w > W - 8 || r.y + r.h > H - 8) return false;
|
|
506
570
|
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
|
-
|
|
508
|
-
if (
|
|
509
|
-
|
|
571
|
+
// A map's region is a tinted area: a label sits wholly inside it or wholly outside, never across its edge.
|
|
572
|
+
if (obs.some((o) => (o.el.classList.contains('map-region') ? overlap(grow(r, 8), o.r) && !(r.x > o.r.x + 12 && r.y > o.r.y + 12 && r.x + r.w < o.r.x + o.r.w - 12 && r.y + r.h < o.r.y + o.r.h - 12) : o.el.classList.contains('group') ? overlap(grow(r, 3), o.r) && !within(o.r) : hits({ r: grow(r, 3) }, o)))) return false;
|
|
573
|
+
if (placed.some((p) => overlap(grow(r, 4), p)) || heads.some((g) => overlap(grow(r, 8), g))) return false;
|
|
574
|
+
return onLine ? !wireHit(grow(r, 8), null, L.pts) : !wireHit(grow(r, 2), cand.on);
|
|
510
575
|
};
|
|
511
|
-
|
|
576
|
+
let pick = cands.find(free);
|
|
577
|
+
if (!pick) {
|
|
578
|
+
fails.push({ from: L.from, to: L.to, w, h });
|
|
579
|
+
lab.dataset.stuck = '1';
|
|
580
|
+
// With no clear place, the label still goes on its own line, where check() reports it.
|
|
581
|
+
const long = segsOf(L.pts).sort((p, q) => q.len - p.len)[0];
|
|
582
|
+
pick = cands[0] ?? { c: [(long.a[0] + long.b[0]) / 2, (long.a[1] + long.b[1]) / 2] };
|
|
583
|
+
}
|
|
512
584
|
lab.style.left = pick.c[0] + 'px';
|
|
513
585
|
lab.style.top = pick.c[1] + 'px';
|
|
514
586
|
placed.push(rectAt(pick.c));
|
|
@@ -553,10 +625,66 @@
|
|
|
553
625
|
});
|
|
554
626
|
|
|
555
627
|
// ---------- checks ----------
|
|
628
|
+
/** The colour as [r, g, b, a] (0–255, alpha 0–1), or null when it is not a plain colour (none, a gradient, a pattern). */
|
|
629
|
+
function rgba(c) {
|
|
630
|
+
const m = /^rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)$/.exec((c || '').trim());
|
|
631
|
+
if (m) return [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]];
|
|
632
|
+
const s = /^color\(srgb ([\d.e-]+) ([\d.e-]+) ([\d.e-]+)(?: \/ ([\d.]+))?\)$/.exec((c || '').trim());
|
|
633
|
+
return s ? [+s[1] * 255, +s[2] * 255, +s[3] * 255, s[4] === undefined ? 1 : +s[4]] : null;
|
|
634
|
+
}
|
|
635
|
+
const lumOf = ([r, g, b]) => [r, g, b].map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }).reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);
|
|
636
|
+
const over = (top, under) => [0, 1, 2].map((i) => top[i] * top[3] + under[i] * (1 - top[3])).concat(1);
|
|
637
|
+
function opacityOf(el) { let o = 1; for (let x = el; x && x !== document.documentElement; x = x.parentElement) o *= parseFloat(getComputedStyle(x).opacity); return o; }
|
|
638
|
+
/** The colour an element paints over its own area, with its opacity, or undefined when it paints nothing there. */
|
|
639
|
+
function paintOf(el) {
|
|
640
|
+
const cs = getComputedStyle(el);
|
|
641
|
+
if (el instanceof SVGElement) {
|
|
642
|
+
if (!/^(rect|path|circle|ellipse|polygon)$/.test(el.tagName)) return undefined;
|
|
643
|
+
if (cs.fill === 'none') return undefined;
|
|
644
|
+
const c = rgba(cs.fill);
|
|
645
|
+
return c ? [c[0], c[1], c[2], c[3] * parseFloat(cs.fillOpacity) * opacityOf(el)] : null;
|
|
646
|
+
}
|
|
647
|
+
if (cs.backgroundImage !== 'none') return null;
|
|
648
|
+
const c = rgba(cs.backgroundColor);
|
|
649
|
+
if (!c) return null;
|
|
650
|
+
return c[3] === 0 ? undefined : [c[0], c[1], c[2], c[3] * opacityOf(el)];
|
|
651
|
+
}
|
|
652
|
+
/** Contrast between a text element and what lies under its middle, as a WCAG ratio; null when it cannot be told. */
|
|
653
|
+
function contrastOf(el) {
|
|
654
|
+
const b = el.getBoundingClientRect();
|
|
655
|
+
if (b.top < 0 || b.bottom > innerHeight || b.left < 0 || b.right > innerWidth) el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
656
|
+
const r = el.tagName === 'text' ? el.getBoundingClientRect() : (() => { const t = textRect(el), o = R0(); return { x: t.x + o.x, y: t.y + o.y, width: t.w, height: t.h }; })();
|
|
657
|
+
const stack = document.elementsFromPoint(r.x + r.width / 2, r.y + r.height / 2);
|
|
658
|
+
let i = stack.findIndex((x) => x === el || x.contains(el));
|
|
659
|
+
if (i < 0) return null;
|
|
660
|
+
if (el.tagName === 'text') i++;
|
|
661
|
+
const layers = [];
|
|
662
|
+
for (; i < stack.length; i++) {
|
|
663
|
+
const p = paintOf(stack[i]);
|
|
664
|
+
if (p === null) return null;
|
|
665
|
+
if (p === undefined) continue;
|
|
666
|
+
layers.push(p);
|
|
667
|
+
if (p[3] >= 0.99) break;
|
|
668
|
+
}
|
|
669
|
+
let bg = [255, 255, 255, 1];
|
|
670
|
+
for (const l of layers.reverse()) bg = over(l, bg);
|
|
671
|
+
const cs = getComputedStyle(el);
|
|
672
|
+
const fg = rgba(el.tagName === 'text' ? cs.fill : cs.color);
|
|
673
|
+
if (!fg) return null;
|
|
674
|
+
const ink = over([fg[0], fg[1], fg[2], fg[3] * opacityOf(el)], bg);
|
|
675
|
+
const la = lumOf(ink), lb = lumOf(bg);
|
|
676
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
677
|
+
}
|
|
678
|
+
const R0 = () => { const b = root().getBoundingClientRect(); return { x: b.x, y: b.y }; };
|
|
679
|
+
|
|
556
680
|
function check() {
|
|
557
681
|
const R = root(), P = fig.problems;
|
|
558
682
|
const W = R.offsetWidth, H = R.offsetHeight;
|
|
559
683
|
const detail = R.classList.contains('detail');
|
|
684
|
+
// A phone layout is 560px wide (fig.css); opening gaps for labels can widen it past that.
|
|
685
|
+
const phoneW = R.classList.contains('phone') ? 560 : 0;
|
|
686
|
+
const wide = Math.max(W, R.scrollWidth, R.getBoundingClientRect().width);
|
|
687
|
+
if (phoneW && wide > phoneW + 1) P.push(`The figure is ${Math.round(wide)}px wide on a phone, wider than ${phoneW}px; shorten the widest labels.`);
|
|
560
688
|
const minText = detail ? 13 : 15;
|
|
561
689
|
const obs = obstacles();
|
|
562
690
|
|
|
@@ -574,6 +702,15 @@
|
|
|
574
702
|
if (hit) P.push(`Chart label covers ${hit === 1 ? 'a dot' : hit + ' dots'}: "${a.e.textContent.trim()}".`);
|
|
575
703
|
}
|
|
576
704
|
|
|
705
|
+
// Text that cannot be read against what is drawn behind it, such as white numbers on a pale cell.
|
|
706
|
+
for (const e of [...R.querySelectorAll('*')].filter((x) => !x.closest('svg') || x.tagName === 'text')) {
|
|
707
|
+
if (e.tagName !== 'text' && ![...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) continue;
|
|
708
|
+
if (!e.textContent.trim() || !visible(e) || e.matches('script, style')) continue;
|
|
709
|
+
const ratio = contrastOf(e);
|
|
710
|
+
if (ratio !== null && ratio < 3) P.push(`Text cannot be read against what is behind it: "${e.textContent.trim().replace(/\s+/g, ' ')}" (contrast ${ratio.toFixed(1)} to 1; it needs 3).`);
|
|
711
|
+
}
|
|
712
|
+
window.scrollTo(0, 0);
|
|
713
|
+
|
|
577
714
|
// Text: too small, cut off, or past its box.
|
|
578
715
|
const small = [];
|
|
579
716
|
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()))))) {
|
|
@@ -599,6 +736,15 @@
|
|
|
599
736
|
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.`);
|
|
600
737
|
}
|
|
601
738
|
|
|
739
|
+
// On a map, a label names the line it sits on: its centre must lie on a segment of its own arrow, clear of the rest.
|
|
740
|
+
if (R.querySelector('.map-region')) for (const lab of R.querySelectorAll('.fig-wire-label')) {
|
|
741
|
+
const [from, to] = lab.dataset.arrow.split(' → '), w = (fig.wires ?? []).find((x) => x.from === from && x.to === to);
|
|
742
|
+
const r = rectOf(lab), cx = r.x + r.w / 2, cy = r.y + r.h / 2;
|
|
743
|
+
const on = w && w.pts.slice(1).some((b, i) => { const a = w.pts[i]; return Math.abs(a[0] - b[0]) < 0.5 ? Math.abs(cx - a[0]) <= 3 && cy >= Math.min(a[1], b[1]) && cy <= Math.max(a[1], b[1]) : Math.abs(cy - a[1]) <= 3 && cx >= Math.min(a[0], b[0]) && cx <= Math.max(a[0], b[0]); });
|
|
744
|
+
if (!on) P.push(`The label "${lab.firstChild.textContent}" of arrow ${lab.dataset.arrow} is not on its own line.`);
|
|
745
|
+
else if (lab.dataset.stuck) P.push(`The label "${lab.firstChild.textContent}" of arrow ${lab.dataset.arrow} has no clear place on its own line: other lines or boxes cover every spot. Drop the label, or shorten it.`);
|
|
746
|
+
}
|
|
747
|
+
|
|
602
748
|
// Boxes and labels that collide.
|
|
603
749
|
for (let i = 0; i < obs.length; i++) for (let j = i + 1; j < obs.length; j++) {
|
|
604
750
|
const a = obs[i], b = obs[j];
|
|
@@ -623,9 +769,26 @@
|
|
|
623
769
|
if (hit) P.push(`Arrow ${w.from} → ${w.to} runs through ${name(o.el)}.`);
|
|
624
770
|
}
|
|
625
771
|
}
|
|
772
|
+
if (R.querySelector('.map')) {
|
|
773
|
+
const along = overlapPairs().map(([u, w]) => `${u.from} → ${u.to} and ${w.from} → ${w.to}`);
|
|
774
|
+
if (along.length) P.push(`Links run on top of each other: ${along.join('; ')}. figkit found no arrangement that keeps them apart; leave out a link the question does not need.`);
|
|
775
|
+
}
|
|
626
776
|
const crossings = new Set(crossingPairs().map(([u, w]) => `${u.from} → ${u.to} and ${w.from} → ${w.to}`));
|
|
627
|
-
|
|
777
|
+
// A map of many links crosses some at right angles, which still reads full screen; up to one crossing per 2 links is
|
|
778
|
+
// allowed and named. Real systems of 24 parts and 30 links cross about 10 times; past 30 parts, 28 times or more.
|
|
779
|
+
const allowed = R.querySelector('.map') ? Math.floor((fig.wires ?? []).length / 2) : 0;
|
|
780
|
+
if (R.querySelector('.map')) {
|
|
781
|
+
// The highlighted path is what the map is about: it reads as one run, and grey links crossing it are named.
|
|
782
|
+
const st = accentStats(), arrow = (w) => `${w.from} → ${w.to}`;
|
|
783
|
+
if (st.back.length) P.push(`The highlighted path turns back on itself: ${st.back.map((run) => [run[0].from, ...run.map((w) => w.to)].join(' → ')).join('; ')}. figkit could not lay it out as one run; highlight only the links the question follows, in one direction.`);
|
|
784
|
+
const grey = [...new Set(st.crossed.map(([u, w]) => (u.tone === 'accent' ? arrow(w) : arrow(u))))];
|
|
785
|
+
if (grey.length) fig.notes = [...(fig.notes ?? []), `${grey.length} grey link${grey.length === 1 ? ' crosses' : 's cross'} the highlighted path: ${grey.join('; ')}.`];
|
|
786
|
+
}
|
|
787
|
+
if (crossings.size && crossings.size <= allowed) fig.notes = [...(fig.notes ?? []), `${crossings.size} crossing${crossings.size === 1 ? '' : 's'} of links (a map of ${(fig.wires ?? []).length} links is allowed ${allowed}): ${[...crossings].join('; ')}.`];
|
|
788
|
+
else if (crossings.size && R.querySelector('.map')) P.push(`Links cross ${crossings.size} times; a map of ${(fig.wires ?? []).length} links holds at most ${allowed}: ${[...crossings].join('; ')}. Leave out links the question does not need, or move a part to the subsystem it talks to most.`);
|
|
789
|
+
else if (crossings.size) P.push(R.querySelector('.entities')
|
|
628
790
|
? `References cross: ${[...crossings].join('; ')}. Split the records over two figures; do not leave a reference out.`
|
|
791
|
+
: R.querySelector('.states') ? `Moves cross: ${[...crossings].join('; ')}. figkit could not lay them out apart; draw the side branch as a second figure.`
|
|
629
792
|
: `Arrows cross: ${[...crossings].join('; ')}. Reorder the boxes so they do not, or drop an arrow.`);
|
|
630
793
|
|
|
631
794
|
// Neighbours that almost line up.
|
|
@@ -636,6 +799,7 @@
|
|
|
636
799
|
if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
|
|
637
800
|
// Records share a top edge in their row; their heights follow their fields, which figkit placed, not the author.
|
|
638
801
|
if (a.el.classList.contains('record') && b.el.classList.contains('record')) continue;
|
|
802
|
+
if (a.el.closest('.map') && b.el.closest('.map')) continue;
|
|
639
803
|
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]];
|
|
640
804
|
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)`); }
|
|
641
805
|
}
|
|
@@ -647,7 +811,11 @@
|
|
|
647
811
|
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;
|
|
648
812
|
const words = (panelText !== null ? outside + ' ' + panelText : R.innerText).split(/\s+/).filter(Boolean).length;
|
|
649
813
|
const nodes = R.querySelectorAll('.node:not(.bare), .decision').length, arrows = (fig.wires ?? []).length;
|
|
650
|
-
|
|
814
|
+
// A states picture reads down one path of short names, so it holds up to 7 states (see the states spec).
|
|
815
|
+
const states = !!R.querySelector('.states');
|
|
816
|
+
// A map is many parts laid out by figkit, so it holds what the map spec allows (map-spec.js), not a detail figure's 18.
|
|
817
|
+
const map = !!R.querySelector('.map');
|
|
818
|
+
const budget = map ? { words: 320, nodes: 24, arrows: 30 } : detail ? { words: 190, nodes: 18, arrows: 18 } : states ? { words: 90, nodes: 7, arrows: 12 } : { words: 70, nodes: 5, arrows: 8 };
|
|
651
819
|
if (words > budget.words) P.push(`${words} words; a ${detail ? 'detail' : 'glance'} figure holds about ${budget.words}. Cut sentences that explain, and keep labels.`);
|
|
652
820
|
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.'}`);
|
|
653
821
|
if (arrows > budget.arrows) P.push(`${arrows} arrows; more than ${budget.arrows} tangles. Show the main path and drop the rest.`);
|
|
@@ -683,10 +851,40 @@
|
|
|
683
851
|
await Promise.all([...document.images].map((img) => (img.complete ? null : new Promise((res) => { img.onload = img.onerror = res; }))));
|
|
684
852
|
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'; }
|
|
685
853
|
const spec = document.getElementById('figkit-spec');
|
|
686
|
-
|
|
687
|
-
|
|
854
|
+
const drawn = spec ? window.figkitViz.drawSpec(JSON.parse(spec.textContent), root()) : (buildSplits(), buildBars(), null);
|
|
855
|
+
if (drawn?.ready) await drawn.ready;
|
|
688
856
|
for (const job of fig.queue) job();
|
|
689
857
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
858
|
+
// A map offers a few arrangements of its regions: each is routed, and the one whose arrows cross least is kept.
|
|
859
|
+
if (fig.mapTry?.length > 1) {
|
|
860
|
+
let best = null;
|
|
861
|
+
for (const [i, apply] of fig.mapTry.entries()) {
|
|
862
|
+
apply();
|
|
863
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
864
|
+
const before = fig.problems.length;
|
|
865
|
+
const fails = drawArrows().length, crossings = crossingPairs().length + 2 * overlapPairs().length, lost = fig.problems.length - before;
|
|
866
|
+
// The checks themselves, so an arrangement whose arrows run through labels or boxes loses.
|
|
867
|
+
const notes = fig.notes;
|
|
868
|
+
check();
|
|
869
|
+
const flagged = fig.problems.length - before - lost;
|
|
870
|
+
fig.problems.length = before;
|
|
871
|
+
fig.notes = notes;
|
|
872
|
+
// The highlighted path should read as one run: grey links crossing it, and a run that doubles back, cost most.
|
|
873
|
+
const st = accentStats();
|
|
874
|
+
// And, as when the regions were packed, a map read larger on a full screen is better.
|
|
875
|
+
// With a highlighted path the arrangements differ a lot in size: one read larger on a full screen is better.
|
|
876
|
+
const read = st.any ? Math.min(1440 / Math.max(root().offsetWidth, 960), 860 / root().offsetHeight) : 1;
|
|
877
|
+
// Those arrangements are also built for the path rather than packed, so they vary more in what the checks refuse,
|
|
878
|
+
// and a refused figure is worse than any crossing: there, each problem counts double.
|
|
879
|
+
const k = st.any ? 2 : 1;
|
|
880
|
+
// And long detours, which those arrangements make more of, are lines the reader must trace further.
|
|
881
|
+
const far = st.any ? (fig.wires ?? []).reduce((t, w) => t + w.pts.slice(1).reduce((u, b, j) => u + Math.abs(b[0] - w.pts[j][0]) + Math.abs(b[1] - w.pts[j][1]), 0), 0) / 1000 : 0;
|
|
882
|
+
const score = crossings + k * (2 * fails + 3 * lost + 4 * flagged) + 3 * st.crossed.length + 8 * st.back.length - 8 * Math.log(read) + far + i * 0.1;
|
|
883
|
+
if (!best || score < best.score) best = { score, apply };
|
|
884
|
+
}
|
|
885
|
+
best.apply();
|
|
886
|
+
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
887
|
+
}
|
|
690
888
|
// A label with no free place opens the gap it needs, then everything is drawn again.
|
|
691
889
|
const tried = new Map();
|
|
692
890
|
for (let i = 0; ; i++) {
|