@liggi/figkit 0.5.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 +61 -6
- package/THIRD_PARTY_NOTICES.md +48 -1
- package/bin/fig.mjs +5 -3
- package/dist/viz.iife.js +47 -42
- package/examples/charts/map.json +269 -0
- package/package.json +2 -1
- package/runtime/fig.css +17 -0
- package/runtime/fig.js +146 -18
- package/viz/draw.js +13 -0
- package/viz/map-spec.js +121 -0
- package/viz/map.js +513 -0
- package/viz/spec.js +8 -3
package/viz/map-spec.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/* The "map" spec: a whole system, its parts grouped by subsystem and the links between them. spec.js adds it to the union. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const slug = z.string({ error: 'needs an id, e.g. "queue"' }).regex(/^[a-z][a-z0-9-]{0,31}$/, 'ids are short lowercase words, e.g. "queue" or "canary-gate"');
|
|
5
|
+
const tone = z.literal('accent', { error: 'tone: only "accent", on what the question is about' }).optional();
|
|
6
|
+
|
|
7
|
+
/** Limits, kept in one place so the guide and the refusals say the same numbers. */
|
|
8
|
+
export const MAP_LIMITS = { parts: 24, minParts: 6, subsystems: 6, perSubsystem: 8, links: 30, accentLinks: 28, sideLinks: 3 };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A system with many parts: each part sits in one subsystem, and each link says one part uses, calls or feeds another.
|
|
12
|
+
* figkit lays it out; the author only says what exists and what connects.
|
|
13
|
+
*/
|
|
14
|
+
export const mapShape = {
|
|
15
|
+
subsystems: z.array(z.object({
|
|
16
|
+
id: slug,
|
|
17
|
+
name: z.string({ error: 'needs a name' }).trim().min(1, 'needs a name').max(32, 'keep subsystem names to 32 characters'),
|
|
18
|
+
kind: z.string().trim().min(1).max(36, 'keep the kind to 36 characters: where it lives, e.g. "ash_builder/detectors/"').optional(),
|
|
19
|
+
}).strict()).min(2, 'at least 2 subsystems; with one, draw a plain figure').max(MAP_LIMITS.subsystems, `at most ${MAP_LIMITS.subsystems} subsystems; more do not read as regions, so merge the small ones or draw two maps`),
|
|
20
|
+
parts: z.array(z.object({
|
|
21
|
+
id: slug,
|
|
22
|
+
name: z.string({ error: 'needs a name' }).trim().min(1, 'needs a name').max(28, 'keep part names to 28 characters'),
|
|
23
|
+
kind: z.string().trim().min(1).max(32, 'keep the kind to 32 characters: what it is or the file it lives in').optional(),
|
|
24
|
+
in: slug.optional(),
|
|
25
|
+
tone,
|
|
26
|
+
}).strict()).min(MAP_LIMITS.minParts, `at least ${MAP_LIMITS.minParts} parts; fewer is a plain figure of at most 5 boxes`).max(MAP_LIMITS.parts, `at most ${MAP_LIMITS.parts} parts; past that the links tangle even full screen, so draw the subsystems as one map and the busiest subsystem as a second`),
|
|
27
|
+
links: z.array(z.object({
|
|
28
|
+
from: slug,
|
|
29
|
+
to: slug,
|
|
30
|
+
label: z.string().trim().min(1).max(22, 'keep link labels to 22 characters: a verb or the thing passed, e.g. "writes rows"').optional(),
|
|
31
|
+
tone,
|
|
32
|
+
}).strict()).min(1, 'at least 1 link').max(MAP_LIMITS.links, `at most ${MAP_LIMITS.links} links; more cross too often to follow, so leave out links the question does not need and say so in the note`),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const list = (ids) => ids.map((x) => `"${x}"`).join(', ');
|
|
36
|
+
/** The id one or two letters away from a mistyped one, if there is exactly one. */
|
|
37
|
+
function near(id, ids) {
|
|
38
|
+
const d = (a, b) => {
|
|
39
|
+
const row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
40
|
+
for (let i = 1; i <= a.length; i++) {
|
|
41
|
+
let prev = row[0]; row[0] = i;
|
|
42
|
+
for (let j = 1; j <= b.length; j++) { const t = row[j]; row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1)); prev = t; }
|
|
43
|
+
}
|
|
44
|
+
return row[b.length];
|
|
45
|
+
};
|
|
46
|
+
const close = ids.filter((x) => d(id, x) <= 2);
|
|
47
|
+
return close.length === 1 ? `; did you mean "${close[0]}"?` : '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Rules that span fields: ids that exist, every part in a subsystem, every part linked, no repeats. */
|
|
51
|
+
export function mapChecks(s) {
|
|
52
|
+
const out = [];
|
|
53
|
+
const subIds = s.subsystems.map((x) => x.id), partIds = s.parts.map((x) => x.id);
|
|
54
|
+
const all = [...subIds, ...partIds];
|
|
55
|
+
const dup = all.find((id, i) => all.indexOf(id) !== i);
|
|
56
|
+
if (dup) return [`ids: "${dup}" is used twice; every part and subsystem needs its own id`];
|
|
57
|
+
const names = s.parts.map((p) => p.name.toLowerCase());
|
|
58
|
+
const twice = s.parts.find((p, i) => names.indexOf(p.name.toLowerCase()) !== i);
|
|
59
|
+
if (twice) out.push(`parts: two parts are named "${twice.name}"; name each part so the reader can tell them apart`);
|
|
60
|
+
|
|
61
|
+
s.parts.forEach((p, i) => {
|
|
62
|
+
if (p.in == null) out.push(`parts[${i}]: "${p.id}" is in no subsystem; give it "in", one of ${list(subIds)}. Something outside the system still goes in a subsystem, such as "Outside"`);
|
|
63
|
+
else if (!subIds.includes(p.in)) out.push(`parts[${i}].in: no subsystem has the id "${p.in}"${partIds.includes(p.in) ? ' (that is a part; parts cannot hold parts)' : ''}; the subsystems are ${list(subIds)}`);
|
|
64
|
+
});
|
|
65
|
+
s.links.forEach((l, i) => {
|
|
66
|
+
for (const end of ['from', 'to']) if (!partIds.includes(l[end])) {
|
|
67
|
+
out.push(`links[${i}].${end}: no part has the id "${l[end]}"${subIds.includes(l[end]) ? '; that is a subsystem, and links join parts, so name the part inside it' : near(l[end], partIds)}`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
if (out.length) return out;
|
|
71
|
+
|
|
72
|
+
for (const sub of s.subsystems) {
|
|
73
|
+
const n = s.parts.filter((p) => p.in === sub.id).length;
|
|
74
|
+
if (n === 0) out.push(`subsystems: "${sub.id}" has no parts; put parts in it with "in": "${sub.id}", or leave it out`);
|
|
75
|
+
if (n > MAP_LIMITS.perSubsystem) out.push(`subsystems: "${sub.id}" has ${n} parts, more than ${MAP_LIMITS.perSubsystem}; split it in two, or merge parts the question does not need`);
|
|
76
|
+
}
|
|
77
|
+
const seen = new Map();
|
|
78
|
+
s.links.forEach((l, i) => {
|
|
79
|
+
if (l.from === l.to) out.push(`links[${i}]: "${l.from}" links to itself; say what it does to itself in its kind instead`);
|
|
80
|
+
const key = l.from + '>' + l.to;
|
|
81
|
+
if (seen.has(key)) out.push(`links[${i}]: a second link from "${l.from}" to "${l.to}" (links[${seen.get(key)}] is the first); make it one link with one label`);
|
|
82
|
+
const back = seen.get(l.to + '>' + l.from);
|
|
83
|
+
if (back != null) out.push(`links[${i}]: "${l.from}" and "${l.to}" link both ways (links[${back}] is the other); keep the one from the part that starts the exchange, the reply is implied`);
|
|
84
|
+
seen.set(key, i);
|
|
85
|
+
});
|
|
86
|
+
const loose = partIds.filter((id) => !s.links.some((l) => l.from === id || l.to === id));
|
|
87
|
+
if (loose.length) out.push(`parts: ${list(loose)} ${loose.length === 1 ? 'is' : 'are'} in no link; a part with no link says nothing on a map, so link ${loose.length === 1 ? 'it' : 'them'} or leave ${loose.length === 1 ? 'it' : 'them'} out`);
|
|
88
|
+
const accented = s.parts.filter((p) => p.tone).length + s.links.filter((l) => l.tone).length;
|
|
89
|
+
if (accented && accented === s.parts.length + s.links.length) out.push('tone: everything is accented, so nothing stands out; accent only what the question is about');
|
|
90
|
+
if (s.links.some((l) => l.tone)) {
|
|
91
|
+
// Measured on two real maps: the highlighted path stays one straight run at 28 links, but not at 29 or 30, and not with 4 grey links on one part of it.
|
|
92
|
+
if (s.links.length > MAP_LIMITS.accentLinks) out.push(`links: ${s.links.length} links with a highlighted path; at most ${MAP_LIMITS.accentLinks}, or figkit cannot keep the path one straight run, so leave out links the question does not need and say so in the note`);
|
|
93
|
+
const onPath = new Set(s.parts.filter((p) => p.tone).map((p) => p.id));
|
|
94
|
+
for (const l of s.links) if (l.tone) { onPath.add(l.from); onPath.add(l.to); }
|
|
95
|
+
for (const id of onPath) {
|
|
96
|
+
const grey = s.links.filter((l) => !l.tone && (l.from === id || l.to === id));
|
|
97
|
+
if (grey.length > MAP_LIMITS.sideLinks) out.push(`links: "${id}" is on the highlighted path and has ${grey.length} grey links (${grey.map((l) => `${l.from} → ${l.to}`).join(', ')}); a part on the path holds at most ${MAP_LIMITS.sideLinks}, because the path takes its top and bottom and the grey links must come in from the sides. Leave out the ones the question does not need`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (s.parts.filter((p) => p.tone).length > 8) out.push('parts: more than 8 parts are accented; a map points at one corner of the system, so accent only that corner');
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The map with each subsystem as one box: the links between subsystems counted, the links inside one left out.
|
|
106
|
+
* For the phone layout, where the full map cannot be read.
|
|
107
|
+
*/
|
|
108
|
+
export function collapse(s) {
|
|
109
|
+
const where = Object.fromEntries(s.parts.map((p) => [p.id, p.in]));
|
|
110
|
+
const pairs = new Map();
|
|
111
|
+
for (const l of s.links) {
|
|
112
|
+
const a = where[l.from], b = where[l.to];
|
|
113
|
+
if (a === b) continue;
|
|
114
|
+
const key = a + '>' + b;
|
|
115
|
+
const e = pairs.get(key) ?? { from: a, to: b, count: 0, tone: undefined };
|
|
116
|
+
e.count++;
|
|
117
|
+
if (l.tone) e.tone = 'accent';
|
|
118
|
+
pairs.set(key, e);
|
|
119
|
+
}
|
|
120
|
+
return [...pairs.values()];
|
|
121
|
+
}
|
package/viz/map.js
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/* The "map" spec drawn: parts in labelled subsystem regions packed into rows; on a phone, one box per subsystem. */
|
|
2
|
+
import dagre from '@dagrejs/dagre';
|
|
3
|
+
import { collapse } from './map-spec.js';
|
|
4
|
+
|
|
5
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
6
|
+
/** The widest a map may be drawn (GUIDE.md: a detail figure widens to 1560px at most). */
|
|
7
|
+
const MAX_W = 1560;
|
|
8
|
+
/** A desktop screen the map is opened full screen on: the arrangement read largest there is preferred. */
|
|
9
|
+
const SCREEN = { w: 1440, h: 860 };
|
|
10
|
+
/** Space between regions: a lane for the links between subsystems and their labels. */
|
|
11
|
+
const GAP = { desk: 104, phone: 64 };
|
|
12
|
+
const PAD = { x: 22, bottom: 22 };
|
|
13
|
+
/** A lane round the outside of the regions, for links that leave a region and come back in elsewhere. */
|
|
14
|
+
const LANE = 32;
|
|
15
|
+
/** How far beside a part the highlighted path runs when it steps around another part: figkit's clearance from a box. */
|
|
16
|
+
const LANE_OFF = 16;
|
|
17
|
+
/** How many of the arrangements built around a highlighted path are routed for real. */
|
|
18
|
+
const SPINE_TRIES = 40;
|
|
19
|
+
|
|
20
|
+
/** The boxes, regions and links to lay out: the full map, or on a phone one box per subsystem. */
|
|
21
|
+
function graphOf(spec, phone) {
|
|
22
|
+
if (!phone) {
|
|
23
|
+
return {
|
|
24
|
+
groups: spec.subsystems,
|
|
25
|
+
nodes: spec.parts.map((p) => ({ id: p.id, group: p.in, tone: p.tone, html: `<h3>${esc(p.name)}</h3>${p.kind ? `<p>${esc(p.kind)}</p>` : ''}` })),
|
|
26
|
+
edges: spec.links.map((l) => ({ from: l.from, to: l.to, label: l.label, tone: l.tone })),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
groups: [],
|
|
31
|
+
nodes: spec.subsystems.map((g) => {
|
|
32
|
+
const parts = spec.parts.filter((p) => p.in === g.id);
|
|
33
|
+
return {
|
|
34
|
+
id: g.id, tone: parts.some((p) => p.tone) ? 'accent' : undefined, sub: true,
|
|
35
|
+
html: `<h3>${esc(g.name)}</h3><ul>${parts.map((p) => `<li${p.tone ? ' class="t-accent"' : ''}>${esc(p.name)}</li>`).join('')}</ul>`,
|
|
36
|
+
};
|
|
37
|
+
}),
|
|
38
|
+
edges: collapse(spec).map((e) => ({ from: e.from, to: e.to, label: String(e.count), tone: e.tone })),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The parts of one region placed in layers by dagre, across (RIGHT) or down (DOWN), with room kept between layers for
|
|
44
|
+
* the labels. Returns the region's inner size and each part's top-left corner.
|
|
45
|
+
*/
|
|
46
|
+
function layoutRegion(children, edges, dir) {
|
|
47
|
+
const g = new dagre.graphlib.Graph({ multigraph: true });
|
|
48
|
+
g.setGraph({ rankdir: dir === 'RIGHT' ? 'LR' : 'TB', nodesep: 28, ranksep: 56, edgesep: 14, marginx: 0, marginy: 0 });
|
|
49
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
50
|
+
for (const c of children) g.setNode(c.id, { width: c.width, height: c.height });
|
|
51
|
+
// A highlighted link pulls hard, so the highlighted path runs straight through the region.
|
|
52
|
+
for (const e of edges) g.setEdge(e.from, e.to, { width: e.label?.width ?? 0, height: e.label?.height ?? 0, labelpos: 'c', weight: e.tone ? 12 : 1 }, e.id);
|
|
53
|
+
dagre.layout(g);
|
|
54
|
+
const placed = children.map((c) => { const n = g.node(c.id); return { id: c.id, width: c.width, height: c.height, x: n.x - c.width / 2, y: n.y - c.height / 2 }; });
|
|
55
|
+
const x0 = Math.min(...placed.map((c) => c.x)), y0 = Math.min(...placed.map((c) => c.y));
|
|
56
|
+
for (const c of placed) { c.x -= x0; c.y -= y0; }
|
|
57
|
+
return { children: placed, width: Math.max(...placed.map((c) => c.x + c.width)), height: Math.max(...placed.map((c) => c.y + c.height)) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The runs along the highlighted links: from a highlighted link no other highlighted link feeds, to one that feeds none.
|
|
62
|
+
* Each run is the list of its links.
|
|
63
|
+
*/
|
|
64
|
+
function runsOf(edges) {
|
|
65
|
+
const hot = edges.filter((e) => e.tone), runs = [];
|
|
66
|
+
const walk = (e, path) => {
|
|
67
|
+
const next = hot.filter((n) => n.from === e.to && !path.includes(n));
|
|
68
|
+
if (!next.length) runs.push([...path, e]);
|
|
69
|
+
for (const n of next) walk(n, [...path, e]);
|
|
70
|
+
};
|
|
71
|
+
for (const e of hot) if (!hot.some((u) => u.to === e.from)) walk(e, []);
|
|
72
|
+
return runs;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Whether a run through these points doubles back: goes left and right, or down and up, by more than a step. */
|
|
76
|
+
function doublesBack(pts) {
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
for (let i = 1; i < pts.length; i++) {
|
|
79
|
+
const dx = pts[i][0] - pts[i - 1][0], dy = pts[i][1] - pts[i - 1][1];
|
|
80
|
+
if (Math.abs(dx) >= 40) seen.add(dx > 0 ? 'r' : 'l');
|
|
81
|
+
if (Math.abs(dy) >= 40) seen.add(dy > 0 ? 'd' : 'u');
|
|
82
|
+
}
|
|
83
|
+
return (seen.has('r') && seen.has('l')) || (seen.has('d') && seen.has('u'));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function permutations(n) {
|
|
87
|
+
const out = [], a = [...Array(n).keys()];
|
|
88
|
+
const walk = (k) => {
|
|
89
|
+
if (k === n) { out.push([...a]); return; }
|
|
90
|
+
for (let i = k; i < n; i++) { [a[k], a[i]] = [a[i], a[k]]; walk(k + 1); [a[k], a[i]] = [a[i], a[k]]; }
|
|
91
|
+
};
|
|
92
|
+
walk(0);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Where each region goes: regions in rows, left to right, the rows top to bottom. Every order and every way of
|
|
98
|
+
* breaking it into rows is tried. An arrangement costs more for links that must go round another region, for long
|
|
99
|
+
* links, and for links that run back up or to the left; on a desktop it also costs more the smaller it reads on a
|
|
100
|
+
* full screen. Each region may be laid out across or down, whichever arrangement is cheaper.
|
|
101
|
+
*/
|
|
102
|
+
function pack(items, links, { maxW, gap, extra, phone, hotRuns = [] }) {
|
|
103
|
+
const n = items.length;
|
|
104
|
+
const keep = [];
|
|
105
|
+
// Every mix of regions laid out across and down.
|
|
106
|
+
const options = phone ? [items.map(() => 0)] : Array.from({ length: 1 << n }, (_, m) => items.map((_, i) => (m >> i) & 1));
|
|
107
|
+
for (const choice of options) {
|
|
108
|
+
const dims = items.map((it, i) => it.dims[choice[i]]);
|
|
109
|
+
for (const perm of permutations(n)) {
|
|
110
|
+
for (let mask = 0; mask < 1 << (n - 1); mask++) {
|
|
111
|
+
const rect = new Array(n);
|
|
112
|
+
let x = 0, y = 0, rowH = 0, W = 0;
|
|
113
|
+
perm.forEach((k, j) => {
|
|
114
|
+
if (j > 0 && mask & (1 << (j - 1))) { y += rowH + gap; x = 0; rowH = 0; }
|
|
115
|
+
rect[k] = { x, y, w: dims[k].w, h: dims[k].h };
|
|
116
|
+
x += dims[k].w + gap; rowH = Math.max(rowH, dims[k].h); W = Math.max(W, x - gap);
|
|
117
|
+
});
|
|
118
|
+
const H = y + rowH;
|
|
119
|
+
let cost = W > maxW ? 1000 + W / 100 : 0;
|
|
120
|
+
for (const { a, b, count } of links) {
|
|
121
|
+
const A = rect[a], B = rect[b];
|
|
122
|
+
const dx = Math.max(0, A.x - (B.x + B.w), B.x - (A.x + A.w)), dy = Math.max(0, A.y - (B.y + B.h), B.y - (A.y + A.h));
|
|
123
|
+
const ca = [A.x + A.w / 2, A.y + A.h / 2], cb = [B.x + B.w / 2, B.y + B.h / 2];
|
|
124
|
+
const between = rect.some((r, k) => k !== a && k !== b && segHits(ca, cb, r));
|
|
125
|
+
const back = cb[1] < A.y - 1 || (Math.abs(A.y - B.y) < 1 && cb[0] < ca[0]);
|
|
126
|
+
cost += count * ((dx + dy) / 500 + (between ? 1.2 : 0) + (back ? (phone ? 1.5 : 0.25) : 0));
|
|
127
|
+
}
|
|
128
|
+
// The highlighted path, subsystem to subsystem, should keep one way across the map, never turn back.
|
|
129
|
+
for (const run of hotRuns) if (doublesBack(run.map((k) => [rect[k].x + rect[k].w / 2, rect[k].y + rect[k].h / 2]))) cost += 3;
|
|
130
|
+
const read = Math.min(SCREEN.w / Math.max(W + extra.w, 960), SCREEN.h / (H + extra.h));
|
|
131
|
+
cost += phone ? H / 2000 : -8 * Math.log(read);
|
|
132
|
+
if (keep.length < 60 || cost < keep[keep.length - 1].cost) {
|
|
133
|
+
keep.push({ cost, rect, W, H, choice });
|
|
134
|
+
keep.sort((p, q) => p.cost - q.cost);
|
|
135
|
+
if (keep.length > 60) keep.pop();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return keep;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The cheapest arrangement once the parts themselves are placed: each candidate is tried with each region mirrored
|
|
145
|
+
* (down a region laid out across, across one laid out down, so links still run the same way), and costs more for
|
|
146
|
+
* every pair of links whose straight lines cross and every link whose line passes through a part it does not join.
|
|
147
|
+
* Returns them all, cheapest first.
|
|
148
|
+
*/
|
|
149
|
+
function refine(cands, items, edges, partsOf) {
|
|
150
|
+
const all = [];
|
|
151
|
+
const n = items.length;
|
|
152
|
+
const runs = runsOf(edges);
|
|
153
|
+
for (const c of cands) {
|
|
154
|
+
for (let flip = 0; flip < 1 << n; flip++) {
|
|
155
|
+
const at = {}, rects = [];
|
|
156
|
+
items.forEach((it, k) => {
|
|
157
|
+
const r = c.rect[k];
|
|
158
|
+
for (const p of partsOf(k, c.choice[k], (flip >> k) & 1)) {
|
|
159
|
+
at[p.id] = [r.x + p.x + p.w / 2, r.y + p.y + p.h / 2];
|
|
160
|
+
rects.push({ id: p.id, x: r.x + p.x, y: r.y + p.y, w: p.w, h: p.h });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
const segs = edges.map((e) => [at[e.from], at[e.to], e]);
|
|
164
|
+
let cost = c.cost;
|
|
165
|
+
for (let i = 0; i < segs.length; i++) {
|
|
166
|
+
const [p, q, e] = segs[i];
|
|
167
|
+
for (let j = i + 1; j < segs.length; j++) {
|
|
168
|
+
const [u, v, f] = segs[j];
|
|
169
|
+
if (e.from === f.from || e.from === f.to || e.to === f.from || e.to === f.to) continue;
|
|
170
|
+
if (crosses(p, q, u, v)) cost += !e.tone !== !f.tone ? 4 : 1;
|
|
171
|
+
}
|
|
172
|
+
for (const r of rects) if (r.id !== e.from && r.id !== e.to && segHits(p, q, r)) cost += 0.5;
|
|
173
|
+
}
|
|
174
|
+
// A highlighted run that doubles back is what the layout must avoid most.
|
|
175
|
+
for (const run of runs) if (doublesBack([at[run[0].from], ...run.map((e) => at[e.to])])) cost += 8;
|
|
176
|
+
all.push({ ...c, cost, flip });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return all.sort((p, q) => p.cost - q.cost);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Arrangements built around the highlighted path. The subsystems it passes through are placed one after another in the
|
|
184
|
+
* order the path reaches them, top to bottom (each laid out down) or left to right (each laid out across), and moved
|
|
185
|
+
* so the part the path enters lines up with the part it left: the path runs as one straight line, and steps aside only
|
|
186
|
+
* where it forks. The other subsystems go on either side of it, or after its end, wherever their links are shortest and
|
|
187
|
+
* cross the path least. Returns them cheapest first, in the form refine() returns; none when nothing is highlighted, or
|
|
188
|
+
* when the path goes back to a subsystem it has left.
|
|
189
|
+
*/
|
|
190
|
+
function spine(items, edges, partsOf, owner, { maxW, gap, extra }) {
|
|
191
|
+
const hot = edges.filter((e) => e.tone);
|
|
192
|
+
if (!hot.length) return [];
|
|
193
|
+
const n = items.length;
|
|
194
|
+
const step = hot.map((e) => [owner(e.from), owner(e.to), e]).filter(([a, b]) => a !== b);
|
|
195
|
+
const on = new Set(hot.flatMap((e) => [owner(e.from), owner(e.to)]));
|
|
196
|
+
// Each of the path's subsystems at the step it is reached, counting the highlighted links between subsystems.
|
|
197
|
+
const level = new Map([...on].map((k) => [k, 0]));
|
|
198
|
+
for (let i = 0; i <= on.size; i++) {
|
|
199
|
+
let moved = false;
|
|
200
|
+
for (const [a, b] of step) if (level.get(b) < level.get(a) + 1) { level.set(b, level.get(a) + 1); moved = true; }
|
|
201
|
+
if (moved && i === on.size) return [];
|
|
202
|
+
}
|
|
203
|
+
const levels = [];
|
|
204
|
+
for (const [k, l] of level) (levels[l] ??= []).push(k);
|
|
205
|
+
if (levels.some((l) => !l)) return [];
|
|
206
|
+
const rest = [...Array(n).keys()].filter((k) => !on.has(k));
|
|
207
|
+
const spineK = [...on];
|
|
208
|
+
const runs = runsOf(edges);
|
|
209
|
+
const out = [];
|
|
210
|
+
const seen = new Set();
|
|
211
|
+
const keep = (c) => {
|
|
212
|
+
// The same placement reached another way (a mirror that moves nothing) is kept once.
|
|
213
|
+
const key = c.key;
|
|
214
|
+
if (seen.has(key)) return;
|
|
215
|
+
seen.add(key);
|
|
216
|
+
if (out.length < 80 || c.cost < out[out.length - 1].cost) { out.push(c); out.sort((a, b) => a.cost - b.cost); if (out.length > 80) out.pop(); }
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// Worked in the path's own frame: "along" is the way the path runs, "across" the other way. Down: along is y.
|
|
220
|
+
for (const down of [true, false]) {
|
|
221
|
+
const sw = (r) => (down ? r : { x: r.y, y: r.x, w: r.h, h: r.w });
|
|
222
|
+
const pathChoice = down ? 1 : 0;
|
|
223
|
+
// Where the path forks, the subsystems side by side in every order.
|
|
224
|
+
let orders = [[]];
|
|
225
|
+
for (const lv of levels) orders = orders.flatMap((o) => permutations(lv.length).map((pm) => [...o, pm.map((i) => lv[i])])).slice(0, 24);
|
|
226
|
+
for (const order of orders)
|
|
227
|
+
for (let flipMask = 0; flipMask < 1 << spineK.length; flipMask++) {
|
|
228
|
+
const flipOf = (k) => (spineK.includes(k) ? (flipMask >> spineK.indexOf(k)) & 1 : 0);
|
|
229
|
+
const inset = new Array(n).fill(0);
|
|
230
|
+
// A part in the path's subsystem, in that frame, relative to the subsystem's corner.
|
|
231
|
+
const local = (k, choice, flip) => Object.fromEntries(partsOf(k, choice, flip, inset[k]).map((p) => [p.id, sw(p)]));
|
|
232
|
+
const size = (k, choice) => sw({ x: 0, y: 0, w: items[k].dims[choice].w + inset[k], h: items[k].dims[choice].h });
|
|
233
|
+
const rect = new Array(n), at = {};
|
|
234
|
+
const put = (k, choice, flip, x, y) => {
|
|
235
|
+
const d = size(k, choice);
|
|
236
|
+
rect[k] = { x, y, w: d.w, h: d.h };
|
|
237
|
+
for (const [id, p] of Object.entries(local(k, choice, flip))) at[id] = { x: x + p.x, y: y + p.y, w: p.w, h: p.h };
|
|
238
|
+
};
|
|
239
|
+
// Where a line may run into (or out of) a part along the path without crossing another part of its subsystem or
|
|
240
|
+
// its heading: straight in at the middle ("c"), or in a lane just to its left ("l") or right ("r"), entering from
|
|
241
|
+
// the side. Each as an offset across from the subsystem's corner, freest first.
|
|
242
|
+
const lanes = (k, choice, flip, id, into) => {
|
|
243
|
+
const ps = local(k, choice, flip), me = ps[id], d = size(k, choice);
|
|
244
|
+
const head = sw({ x: PAD.x, y: 0, w: items[k].headW, h: items[k].dims[choice].top - 8 });
|
|
245
|
+
const others = [...Object.entries(ps).filter(([q]) => q !== id).map(([, r]) => r), head];
|
|
246
|
+
const y0 = into ? -1 : me.y + me.h, y1 = into ? me.y : d.h + 1;
|
|
247
|
+
const free = (x) => !others.some((r) => r.x < x + 14 && r.x + r.w > x - 14 && r.y < y1 && r.y + r.h > y0);
|
|
248
|
+
// A part near the subsystem's edge steps out into the gap beside the subsystem, not along its border.
|
|
249
|
+
const l = me.x < 64 ? -gap / 2 : me.x - LANE_OFF, r = d.w - me.x - me.w < 64 ? d.w + gap / 2 : me.x + me.w + LANE_OFF;
|
|
250
|
+
return [['c', me.x + me.w / 2], ['l', l], ['r', r]].filter(([, x]) => free(x));
|
|
251
|
+
};
|
|
252
|
+
let y = 0;
|
|
253
|
+
const entered = {};
|
|
254
|
+
for (const lv of order) {
|
|
255
|
+
const into = lv.map((k) => step.find(([a, b]) => b === k && rect[a]));
|
|
256
|
+
// Laid out down, a line coming in from above would cross the subsystem's heading at its top left: the parts
|
|
257
|
+
// move right until a line straight down the middle clears it.
|
|
258
|
+
if (down) lv.forEach((k, i) => {
|
|
259
|
+
if (!into[i]) return;
|
|
260
|
+
const dst = local(k, pathChoice, flipOf(k))[into[i][2].to];
|
|
261
|
+
const need = Math.max(0, PAD.x + items[k].headW + 16 - (dst.x + dst.w / 2));
|
|
262
|
+
const before = inset[k];
|
|
263
|
+
inset[k] = need;
|
|
264
|
+
if (!lanes(k, pathChoice, flipOf(k), into[i][2].to, true).some(([s]) => s === 'c')) inset[k] = before;
|
|
265
|
+
});
|
|
266
|
+
// Out of the part above and into the part here: straight down if both are clear, else stepping aside through a
|
|
267
|
+
// lane, never out to one side and back in from the other, which would turn the path back on itself.
|
|
268
|
+
const xs = lv.map((k, i) => {
|
|
269
|
+
if (!into[i]) return null;
|
|
270
|
+
const [a, , e] = into[i], src = at[e.from];
|
|
271
|
+
// A part entered from one side is left by the other side or the bottom, so the path keeps going one way.
|
|
272
|
+
const outs = lanes(a, pathChoice, flipOf(a), e.from, false).filter(([s]) => s === 'c' || s !== entered[e.from]).map(([s, x]) => [s, x + rect[a].x]);
|
|
273
|
+
const ins = lanes(k, pathChoice, flipOf(k), e.to, true);
|
|
274
|
+
const rank = ([so], [si]) => (so === 'c' ? 0 : 1) + (si === 'c' ? 0 : 1);
|
|
275
|
+
const pairs = outs.flatMap((o) => ins.filter((n) => !(o[0] !== 'c' && n[0] === o[0])).map((n) => [o, n])).sort((p, q) => rank(...p) - rank(...q));
|
|
276
|
+
if (!pairs.length) return src.x + src.w / 2 - (local(k, pathChoice, flipOf(k))[e.to].x + local(k, pathChoice, flipOf(k))[e.to].w / 2);
|
|
277
|
+
const [[, ox], [si, ix]] = pairs[0];
|
|
278
|
+
entered[e.to] = si;
|
|
279
|
+
return ox - ix;
|
|
280
|
+
});
|
|
281
|
+
const h = Math.max(...lv.map((k) => size(k, pathChoice).h));
|
|
282
|
+
if (lv.length === 1) put(lv[0], pathChoice, flipOf(lv[0]), xs[0] ?? 0, y);
|
|
283
|
+
else {
|
|
284
|
+
// Where the path forks into several subsystems, they sit side by side, centred on where it came from.
|
|
285
|
+
const ws = lv.map((k) => size(k, pathChoice).w), total = ws.reduce((a, b) => a + b, 0) + gap * (lv.length - 1);
|
|
286
|
+
const known = lv.map((k, i) => (xs[i] == null ? null : xs[i] + ws[i] / 2)).filter((v) => v != null);
|
|
287
|
+
const mid = known.length ? known.reduce((a, b) => a + b, 0) / known.length : total / 2;
|
|
288
|
+
let x = mid - total / 2;
|
|
289
|
+
lv.forEach((k, i) => { put(k, pathChoice, flipOf(k), x, y); x += ws[i] + gap; });
|
|
290
|
+
}
|
|
291
|
+
y += h + gap;
|
|
292
|
+
}
|
|
293
|
+
const sx0 = Math.min(...spineK.map((k) => rect[k].x)), sx1 = Math.max(...spineK.map((k) => rect[k].x + rect[k].w));
|
|
294
|
+
const sy1 = y - gap;
|
|
295
|
+
// Each other subsystem: before the path (0), after it (1) or past its end (2), laid out across or down.
|
|
296
|
+
const options = 3 ** rest.length * 2 ** rest.length;
|
|
297
|
+
for (let o = 0; o < options; o++) {
|
|
298
|
+
let m = o;
|
|
299
|
+
const side = rest.map(() => { const v = m % 3; m = Math.floor(m / 3); return v; });
|
|
300
|
+
const choice = rest.map(() => { const v = m % 2; m >>= 1; return v; });
|
|
301
|
+
const sorted = [[], [], []];
|
|
302
|
+
rest.forEach((k, i) => sorted[side[i]].push(i));
|
|
303
|
+
// Each column in every order: the subsystem its links pull highest need not be the best one on top.
|
|
304
|
+
for (const p0 of permutations(sorted[0].length)) for (const p1 of permutations(sorted[1].length)) {
|
|
305
|
+
const R = rect.slice(), A = { ...at };
|
|
306
|
+
const placeRest = (i, x, yy) => {
|
|
307
|
+
const k = rest[i], d = size(k, choice[i]);
|
|
308
|
+
R[k] = { x, y: yy, w: d.w, h: d.h };
|
|
309
|
+
for (const [id, p] of Object.entries(local(k, choice[i], 0))) A[id] = { x: x + p.x, y: yy + p.y, w: p.w, h: p.h };
|
|
310
|
+
};
|
|
311
|
+
const cols = [p0.map((j) => sorted[0][j]), p1.map((j) => sorted[1][j]), sorted[2]];
|
|
312
|
+
// Beside the path, a subsystem sits level with the path's parts it links to.
|
|
313
|
+
const pull = (i) => {
|
|
314
|
+
const k = rest[i], ys = edges.filter((e) => (owner(e.from) === k) !== (owner(e.to) === k) && on.has(owner(owner(e.from) === k ? e.to : e.from)))
|
|
315
|
+
.map((e) => { const p = at[owner(e.from) === k ? e.to : e.from]; return p.y + p.h / 2; });
|
|
316
|
+
return ys.length ? ys.reduce((a, b) => a + b, 0) / ys.length : sy1;
|
|
317
|
+
};
|
|
318
|
+
for (const c of [0, 1]) {
|
|
319
|
+
// The gap beside the path widens with the links that cross it, so their lines and labels stay apart.
|
|
320
|
+
const ks = new Set(cols[c].map((i) => rest[i]));
|
|
321
|
+
const through = edges.filter((e) => ks.has(owner(e.from)) !== ks.has(owner(e.to))).length;
|
|
322
|
+
const cgap = gap + Math.max(0, through - 4) * 14;
|
|
323
|
+
let cy = 0;
|
|
324
|
+
const wmax = Math.max(0, ...cols[c].map((i) => size(rest[i], choice[i]).w));
|
|
325
|
+
for (const i of cols[c]) {
|
|
326
|
+
const d = size(rest[i], choice[i]);
|
|
327
|
+
const yy = Math.max(0, cy, Math.min(pull(i) - d.h / 2, sy1 - d.h));
|
|
328
|
+
placeRest(i, c === 0 ? sx0 - cgap - (wmax + d.w) / 2 : sx1 + cgap + (wmax - d.w) / 2, yy);
|
|
329
|
+
cy = yy + d.h + gap;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
let bx = Math.min(sx0, ...cols[0].map((i) => R[rest[i]].x));
|
|
333
|
+
const below = Math.max(sy1, ...[0, 1].flatMap((c) => cols[c].map((i) => R[rest[i]].y + R[rest[i]].h))) + gap;
|
|
334
|
+
for (const i of cols[2]) { placeRest(i, bx, below); bx += size(rest[i], choice[i]).w + gap; }
|
|
335
|
+
// Back to the page's frame, starting at 0, 0.
|
|
336
|
+
const real = R.map(sw), x0 = Math.min(...real.map((r) => r.x)), y0 = Math.min(...real.map((r) => r.y));
|
|
337
|
+
const fin = real.map((r) => ({ ...r, x: r.x - x0, y: r.y - y0 }));
|
|
338
|
+
if (fin.some((r, i) => fin.some((q, j) => j > i && r.x < q.x + q.w + gap / 2 && q.x < r.x + r.w + gap / 2 && r.y < q.y + q.h + gap / 2 && q.y < r.y + r.h + gap / 2))) continue;
|
|
339
|
+
const W = Math.max(...fin.map((r) => r.x + r.w)), H = Math.max(...fin.map((r) => r.y + r.h));
|
|
340
|
+
const P = Object.fromEntries(Object.entries(A).map(([id, r]) => { const q = sw(r); return [id, { x: q.x - x0, y: q.y - y0, w: q.w, h: q.h }]; }));
|
|
341
|
+
const c = (id) => [P[id].x + P[id].w / 2, P[id].y + P[id].h / 2];
|
|
342
|
+
let cost = W > maxW ? 1000 + W / 100 : 0;
|
|
343
|
+
cost += -8 * Math.log(Math.min(SCREEN.w / Math.max(W + extra.w, 960), SCREEN.h / (H + extra.h)));
|
|
344
|
+
const segs = edges.map((e) => [c(e.from), c(e.to), e]);
|
|
345
|
+
for (let i = 0; i < segs.length; i++) {
|
|
346
|
+
const [p, q, e] = segs[i];
|
|
347
|
+
cost += (Math.abs(p[0] - q[0]) + Math.abs(p[1] - q[1])) / 500;
|
|
348
|
+
for (let j = i + 1; j < segs.length; j++) {
|
|
349
|
+
const [u, v, f] = segs[j];
|
|
350
|
+
if (e.from === f.from || e.from === f.to || e.to === f.from || e.to === f.to) continue;
|
|
351
|
+
if (crosses(p, q, u, v)) cost += !e.tone !== !f.tone ? 4 : 1;
|
|
352
|
+
}
|
|
353
|
+
for (const id in P) if (id !== e.from && id !== e.to && segHits(p, q, P[id])) cost += 0.5;
|
|
354
|
+
}
|
|
355
|
+
for (const run of runs) if (doublesBack([c(run[0].from), ...run.map((e) => c(e.to))])) cost += 8;
|
|
356
|
+
const ch = items.map((_, k) => (on.has(k) ? pathChoice : choice[rest.indexOf(k)]));
|
|
357
|
+
keep({ key: Object.keys(P).sort().map((id) => c(id).map(Math.round).join(',')).join(' '), cost, rect: fin, W, H, choice: ch, flip: spineK.reduce((f, k) => f | (flipOf(k) << k), 0), inset: inset.slice() });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function crosses(a, b, c, d) {
|
|
366
|
+
const o = (p, q, r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
|
|
367
|
+
return o(a, b, c) * o(a, b, d) < 0 && o(c, d, a) * o(c, d, b) < 0;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Whether the line from p to q passes through rectangle r (Liang–Barsky clipping). */
|
|
371
|
+
function segHits(p, q, r) {
|
|
372
|
+
const dx = q[0] - p[0], dy = q[1] - p[1];
|
|
373
|
+
let t0 = 0, t1 = 1;
|
|
374
|
+
for (const [pp, qq] of [[-dx, p[0] - r.x], [dx, r.x + r.w - p[0]], [-dy, p[1] - r.y], [dy, r.y + r.h - p[1]]]) {
|
|
375
|
+
if (pp === 0) { if (qq <= 0) return false; continue; }
|
|
376
|
+
const t = qq / pp;
|
|
377
|
+
if (pp < 0) t0 = Math.max(t0, t); else t1 = Math.min(t1, t);
|
|
378
|
+
if (t0 >= t1) return false;
|
|
379
|
+
}
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Lays the map out and draws it. Each box is measured in the page first. dagre lays out the parts inside each
|
|
385
|
+
* subsystem, across and down; figkit then packs the subsystems into rows that fit the figure, and its own router draws
|
|
386
|
+
* every link around the boxes, as for any figure, so the checks on arrows and labels apply unchanged.
|
|
387
|
+
*/
|
|
388
|
+
export async function drawMap(spec, main) {
|
|
389
|
+
const phone = main.classList.contains('phone');
|
|
390
|
+
const { groups, nodes, edges } = graphOf(spec, phone);
|
|
391
|
+
const pad = parseFloat(getComputedStyle(main).paddingLeft) || 48;
|
|
392
|
+
const wrap = document.createElement('div');
|
|
393
|
+
wrap.className = 'map' + (phone ? ' map-overview' : '');
|
|
394
|
+
main.appendChild(wrap);
|
|
395
|
+
|
|
396
|
+
const box = {};
|
|
397
|
+
for (const n of nodes) {
|
|
398
|
+
const el = document.createElement('div');
|
|
399
|
+
el.className = 'node map-part' + (n.sub ? ' map-sub' : '') + (n.tone ? ' accent' : '');
|
|
400
|
+
el.id = 'mp-' + n.id;
|
|
401
|
+
el.innerHTML = n.html;
|
|
402
|
+
wrap.appendChild(el);
|
|
403
|
+
box[n.id] = el;
|
|
404
|
+
}
|
|
405
|
+
const region = {};
|
|
406
|
+
for (const g of groups) {
|
|
407
|
+
const el = document.createElement('div');
|
|
408
|
+
el.className = 'group map-region';
|
|
409
|
+
el.id = 'mp-' + g.id;
|
|
410
|
+
el.innerHTML = `<div class="mp-head"><h2>${esc(g.name)}</h2>${g.kind ? `<span class="tag">${esc(g.kind)}</span>` : ''}</div>`;
|
|
411
|
+
wrap.appendChild(el);
|
|
412
|
+
region[g.id] = el;
|
|
413
|
+
}
|
|
414
|
+
// A label measured as figkit draws it, so the layout keeps room for it between the layers of a region.
|
|
415
|
+
const probe = (text) => {
|
|
416
|
+
const el = document.createElement('div');
|
|
417
|
+
el.className = 'fig-wire-label';
|
|
418
|
+
el.style.visibility = 'hidden';
|
|
419
|
+
el.textContent = text;
|
|
420
|
+
wrap.appendChild(el);
|
|
421
|
+
const r = el.getBoundingClientRect();
|
|
422
|
+
el.remove();
|
|
423
|
+
return { width: Math.ceil(r.width), height: Math.ceil(r.height) };
|
|
424
|
+
};
|
|
425
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
426
|
+
// Fractional widths rounded down would wrap the text again once the box is given that width.
|
|
427
|
+
const size = (el) => { const r = el.getBoundingClientRect(); return { width: Math.ceil(r.width) + 1, height: Math.ceil(r.height) }; };
|
|
428
|
+
const sizes = Object.fromEntries(nodes.map((n) => [n.id, size(box[n.id])]));
|
|
429
|
+
|
|
430
|
+
// Each region laid out on its own, across and down; on a phone each subsystem is one box and needs no layout.
|
|
431
|
+
const items = [];
|
|
432
|
+
if (phone) for (const n of nodes) items.push({ id: n.id, dims: [{ w: sizes[n.id].width, h: sizes[n.id].height }] });
|
|
433
|
+
for (const g of groups) {
|
|
434
|
+
const head = size(region[g.id].querySelector('.mp-head'));
|
|
435
|
+
const mine = nodes.filter((n) => n.group === g.id);
|
|
436
|
+
const ids = new Set(mine.map((n) => n.id));
|
|
437
|
+
const inner = edges.map((e, i) => ({ e, i })).filter(({ e }) => ids.has(e.from) && ids.has(e.to));
|
|
438
|
+
const dims = [];
|
|
439
|
+
for (const dir of ['RIGHT', 'DOWN']) {
|
|
440
|
+
const out = layoutRegion(mine.map((n) => ({ id: n.id, ...sizes[n.id] })), inner.map(({ e, i }) => ({ id: 'e' + i, from: e.from, to: e.to, tone: e.tone, label: e.label ? probe(e.label) : null })), dir);
|
|
441
|
+
const top = head.height + 30;
|
|
442
|
+
dims.push({ w: Math.max(out.width + 2 * PAD.x, head.width + 2 * PAD.x), h: out.height + top + PAD.bottom, top, out });
|
|
443
|
+
}
|
|
444
|
+
items.push({ id: g.id, dims, headW: head.width });
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const index = Object.fromEntries(items.map((it, i) => [it.id, i]));
|
|
448
|
+
const owner = (id) => index[phone ? id : nodes.find((n) => n.id === id).group];
|
|
449
|
+
const counts = new Map();
|
|
450
|
+
for (const e of edges) {
|
|
451
|
+
const a = owner(e.from), b = owner(e.to);
|
|
452
|
+
if (a === b) continue;
|
|
453
|
+
const key = a + ',' + b;
|
|
454
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
455
|
+
}
|
|
456
|
+
const links = [...counts].map(([k, count]) => { const [a, b] = k.split(',').map(Number); return { a, b, count }; });
|
|
457
|
+
const header = main.querySelector(':scope > header');
|
|
458
|
+
const gap = phone ? GAP.phone : GAP.desk;
|
|
459
|
+
const inner = phone ? (parseFloat(getComputedStyle(main).width) || 560) - 2 * pad : MAX_W - 2 * pad - 2 * LANE;
|
|
460
|
+
// Each highlighted run as the subsystems it passes through, in order.
|
|
461
|
+
const hotRuns = runsOf(edges).map((run) => [owner(run[0].from), ...run.map((e) => owner(e.to))].filter((k, i, a) => i === 0 || a[i - 1] !== k)).filter((r) => r.length > 2);
|
|
462
|
+
const cands = pack(items, links, { maxW: inner, gap, extra: { w: 2 * pad, h: (header ? header.offsetHeight + 28 : 0) + 2 * pad }, phone, hotRuns });
|
|
463
|
+
// Each part's box within its region, for a region laid out across (0) or down (1), mirrored or not.
|
|
464
|
+
const partsOf = (k, choice, flip, inset = 0) => {
|
|
465
|
+
const it = items[k];
|
|
466
|
+
if (phone) return [{ id: it.id, x: 0, y: 0, w: it.dims[0].w, h: it.dims[0].h }];
|
|
467
|
+
const d = it.dims[choice];
|
|
468
|
+
// A region wider than its parts (for its heading) centres them.
|
|
469
|
+
const shift = Math.max(0, (d.w - (d.out.width + 2 * PAD.x)) / 2);
|
|
470
|
+
return d.out.children.map((c) => ({
|
|
471
|
+
id: c.id, w: c.width, h: c.height,
|
|
472
|
+
x: PAD.x + shift + inset + (flip && choice === 1 ? d.out.width - c.x - c.width : c.x),
|
|
473
|
+
y: d.top + (flip && choice === 0 ? d.out.height - c.y - c.height : c.y),
|
|
474
|
+
}));
|
|
475
|
+
};
|
|
476
|
+
// With a highlighted path, the arrangements built around it are tried first; the packed ones stay as a fallback.
|
|
477
|
+
const around = phone ? [] : spine(items, edges, partsOf, owner, { maxW: inner, gap, extra: { w: 2 * pad, h: (header ? header.offsetHeight + 28 : 0) + 2 * pad } });
|
|
478
|
+
const ranked = [...around.slice(0, SPINE_TRIES), ...refine(cands, items, edges, partsOf)];
|
|
479
|
+
|
|
480
|
+
// Regions, and the parts inside each at the place the layout gave them; on a phone the boxes themselves.
|
|
481
|
+
const apply = (best) => {
|
|
482
|
+
const place = (el, x, y, w, h) => Object.assign(el.style, { left: x + 'px', top: y + 'px', ...(w ? { width: w + 'px', height: h + 'px' } : {}) });
|
|
483
|
+
const offsetX = phone ? Math.max(0, (inner - best.W) / 2) : 0;
|
|
484
|
+
items.forEach((it, k) => {
|
|
485
|
+
const r = best.rect[k];
|
|
486
|
+
if (phone) { place(box[it.id], r.x + offsetX, r.y); return; }
|
|
487
|
+
// A region shorter than its row keeps its own height; its parts are centred across its width.
|
|
488
|
+
place(region[it.id], r.x + LANE, r.y, r.w, r.h);
|
|
489
|
+
for (const p of partsOf(k, best.choice[k], (best.flip >> k) & 1, best.inset?.[k] ?? 0)) { region[it.id].appendChild(box[p.id]); place(box[p.id], p.x, p.y, p.w, p.h); }
|
|
490
|
+
});
|
|
491
|
+
wrap.style.width = (phone ? inner : best.W + 2 * LANE) + 'px';
|
|
492
|
+
wrap.style.height = best.H + 'px';
|
|
493
|
+
if (!phone) main.style.setProperty('--w', Math.round(Math.max(960, best.W + 2 * LANE + 2 * pad)) + 'px');
|
|
494
|
+
|
|
495
|
+
};
|
|
496
|
+
// The few cheapest arrangements are routed for real by fig.js, which keeps the one whose arrows cross least.
|
|
497
|
+
const per = new Map();
|
|
498
|
+
const tries = ranked.filter((c) => { const k = per.get(c.rect) ?? 0; per.set(c.rect, k + 1); return k < (phone ? 1 : 4); }).slice(0, phone ? 12 : 32 + around.slice(0, SPINE_TRIES).length);
|
|
499
|
+
window.fig.mapTry = tries.map((c) => () => apply(c));
|
|
500
|
+
apply(ranked[0]);
|
|
501
|
+
|
|
502
|
+
// Every link is an ordinary figkit arrow, routed around the boxes and regions with its label placed clear of them.
|
|
503
|
+
// The highlighted links are routed first, so they take the straightest way and the grey links go around them.
|
|
504
|
+
for (const e of [...edges.filter((x) => x.tone), ...edges.filter((x) => !x.tone)]) {
|
|
505
|
+
const a = document.createElement('fig-arrow');
|
|
506
|
+
a.setAttribute('from', 'mp-' + e.from);
|
|
507
|
+
a.setAttribute('to', 'mp-' + e.to);
|
|
508
|
+
if (e.label) a.setAttribute('label', e.label);
|
|
509
|
+
if (e.tone) a.setAttribute('accent', '');
|
|
510
|
+
main.appendChild(a);
|
|
511
|
+
}
|
|
512
|
+
return wrap;
|
|
513
|
+
}
|