yichus 0.2.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/README.md +45 -16
- package/dist/cli.js +1328 -259
- package/dist/cli.js.map +1 -1
- package/dist/studio-page/dom.js +22 -0
- package/dist/studio-page/label-fit.js +7 -0
- package/dist/studio-page/layer-diagram.js +458 -0
- package/dist/studio-page/layer-groups.js +129 -0
- package/dist/studio-page/source-link.js +17 -0
- package/dist/studio-page/studio-views.js +256 -0
- package/dist/studio-page/system-map-record.js +122 -8
- package/dist/studio-page/system-map.js +461 -85
- package/dist/studio-page/what-happens-record.js +217 -0
- package/dist/studio-page/what-happens.js +752 -0
- package/dist/studio-reference/deploy-platforms.md +79 -0
- package/dist/studio-reference/portable-programs.md +58 -0
- package/dist/studio-reference/porting.md +32 -0
- package/dist/studio-reference/refund-desk-one-round.bosatsu +196 -0
- package/dist/studio-reference/refund-desk.bosatsu +194 -0
- package/dist/studio-reference/refund-policy.bosatsu +97 -0
- package/dist/studio-reference/refund-test.bosatsu +75 -0
- package/dist/studio-reference/the-page.md +107 -0
- package/dist/studio-reference/worker-authority.md +159 -0
- package/package.json +2 -2
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Elements built from data. Strings become text nodes and attribute values
|
|
2
|
+
// are set as attributes, so no value is ever read as markup.
|
|
3
|
+
const SVG = 'http://www.w3.org/2000/svg';
|
|
4
|
+
|
|
5
|
+
function build(element, attrs, children) {
|
|
6
|
+
for (const [name, value] of Object.entries(attrs ?? {})) {
|
|
7
|
+
if (value === false || value === null || value === undefined) continue;
|
|
8
|
+
if (name.startsWith('on') && typeof value === 'function') element.addEventListener(name.slice(2), value);
|
|
9
|
+
else element.setAttribute(name, value === true ? '' : String(value));
|
|
10
|
+
}
|
|
11
|
+
element.append(...children.flat(Infinity).filter(child => child !== false && child !== null && child !== undefined).map(child =>
|
|
12
|
+
child instanceof Node ? child : String(child)));
|
|
13
|
+
return element;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const el = (tag, attrs, ...children) => build(document.createElement(tag), attrs, children);
|
|
17
|
+
export const svg = (tag, attrs, ...children) => build(document.createElementNS(SVG, tag), attrs, children);
|
|
18
|
+
|
|
19
|
+
// A visible frame around output the tool computed. Narrative and how-to-read
|
|
20
|
+
// text belongs outside it, so a reader can tell the two apart.
|
|
21
|
+
export const toolOutput = (label, ...children) => el('figure', { class: 'tool-output' },
|
|
22
|
+
el('figcaption', { class: 'tool-output-label' }, el('span', {}, 'Yichus output'), label), ...children);
|
|
@@ -96,6 +96,13 @@ export function wrapText(s, size, room, mono = true, weight = 400, italic = fals
|
|
|
96
96
|
return lines.map(l => (w(l) <= room ? l : clipText(l, size, room, mono, weight, italic, family)));
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/** The narrowest room `wrapText` breaks `s` into without clipping: its widest
|
|
100
|
+
* piece, a word or, for a word that is broken, a part of one. */
|
|
101
|
+
export function widestPiece(s, size, mono = true, weight = 400, italic = false, family = null) {
|
|
102
|
+
const w = t => textWidth(t, size, mono, weight, italic, family);
|
|
103
|
+
return Math.max(0, ...String(s).match(WORDS).filter(Boolean).flatMap(p => p.match(PARTS).filter(Boolean)).map(p => w(p.trimEnd())));
|
|
104
|
+
}
|
|
105
|
+
|
|
99
106
|
// A row of labels that hangs from one thing (a route: its line and its name)
|
|
100
107
|
// says so: the thing's shapes carry data-anchor="<kind>:<id>", and each label
|
|
101
108
|
// of the row sits in a data-bound group with the same value. Each bound
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// A program's packages as a layer diagram: boxes in rows, arrows to the
|
|
2
|
+
// packages each one is built from. The facts are the recorded output of
|
|
3
|
+
// the real engine's layers lens (scripts/diagram-examples.mjs); this file
|
|
4
|
+
// only groups and lays them out and never measures code.
|
|
5
|
+
import { el, svg, toolOutput } from './dom.js';
|
|
6
|
+
import { dimensions, foldGroups, groupPackages, usesElsewhere } from './layer-groups.js';
|
|
7
|
+
import { textWidth, cssFont } from './label-fit.js';
|
|
8
|
+
import { writeSourceLink } from './source-link.js';
|
|
9
|
+
|
|
10
|
+
const evidenceRoot = new URL('evidence/diagram-examples/', import.meta.url);
|
|
11
|
+
const viewerUrl = new URL('source.html', import.meta.url);
|
|
12
|
+
// The recordings whose source files the site serves (deploy/manifest.json),
|
|
13
|
+
// so the source viewer can open them. Any other recording names its source
|
|
14
|
+
// directory as text.
|
|
15
|
+
const servedSources = new Set(['structure-sqlite', 'structure-rust-analyzer', 'structure-typescript',
|
|
16
|
+
'structure-cosmic-python', 'structure-redux', 'structure-koa', 'structure-vscode']);
|
|
17
|
+
const counted = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
18
|
+
// An edge is an import: definitions used, or types and constructors only.
|
|
19
|
+
const usage = e => [e.refs ? `${counted(e.refs, 'definition')} used` : '', e.types ? `${counted(e.types, 'type')} used` : ''].filter(Boolean).join(', ');
|
|
20
|
+
const minW = 150, minH = 44, growW = 110, growH = 26, inner = 12, gap = 28, rowGap = 58, labelH = 20, margin = 12;
|
|
21
|
+
// A program with more packages than this opens with its groups folded.
|
|
22
|
+
const foldAbove = 14;
|
|
23
|
+
|
|
24
|
+
// Box text is measured in the fonts the stylesheet sets (.layer-name,
|
|
25
|
+
// .layer-does, .layer-group > text), so a box is never narrower than its words.
|
|
26
|
+
const mono = () => cssFont('--mono', 'monospace'), ui = () => cssFont('--font-ui', 'sans-serif');
|
|
27
|
+
const nameWidth = name => textWidth(name, 12.5, true, 400, false, mono());
|
|
28
|
+
const doesWidth = (does, io) => textWidth(does, 11, false, io ? 600 : 400, false, ui());
|
|
29
|
+
const boxWidth = (name, does, io) => Math.max(nameWidth(name), doesWidth(does, io)) + 22;
|
|
30
|
+
|
|
31
|
+
// Rows are levels from the highest down, so every arrow points down. Each
|
|
32
|
+
// group is a column over the rows its members occupy. Columns go left to
|
|
33
|
+
// right in an order that puts each beside the columns it connects to, and
|
|
34
|
+
// each slides left until it meets a column sharing one of its rows (a
|
|
35
|
+
// layout choice, not a fact). Box size grows with how much the rest of
|
|
36
|
+
// the program uses the box.
|
|
37
|
+
function layout(nodes, edges, columns, size) {
|
|
38
|
+
const levels = [...new Set(nodes.map(n => n.level))].sort((a, b) => b - a);
|
|
39
|
+
const row = new Map(levels.map((level, i) => [level, i]));
|
|
40
|
+
const rowOf = new Map(nodes.map(n => [n.name, row.get(n.level)]));
|
|
41
|
+
const tallest = minH + growH, rowH = tallest + rowGap;
|
|
42
|
+
const cols = columns.map((members, i) => {
|
|
43
|
+
const rows = new Map();
|
|
44
|
+
for (const m of members) rows.set(rowOf.get(m), [...(rows.get(rowOf.get(m)) ?? []), m]);
|
|
45
|
+
const lineW = ms => ms.reduce((w, m) => w + size.get(m).w, 0) + inner * (ms.length - 1);
|
|
46
|
+
const width = Math.max(...[...rows.values()].map(lineW)) + (members.length > 1 ? 2 * inner : 0);
|
|
47
|
+
return { i, members, rows, lineW, width, top: Math.min(...rows.keys()), bottom: Math.max(...rows.keys()) };
|
|
48
|
+
});
|
|
49
|
+
const colOf = new Map(cols.flatMap(c => c.members.map(m => [m, c.i])));
|
|
50
|
+
const neighbours = new Map(nodes.map(n => [n.name, []]));
|
|
51
|
+
for (const e of edges) { neighbours.get(e.from).push(e.to); neighbours.get(e.to).push(e.from); }
|
|
52
|
+
let order = cols.map(c => c.i);
|
|
53
|
+
for (let pass = 0; pass < 6; pass++) {
|
|
54
|
+
const pos = new Map(order.map((k, i) => [k, i]));
|
|
55
|
+
const pull = k => {
|
|
56
|
+
const near = cols[k].members.flatMap(m => neighbours.get(m)).map(n => pos.get(colOf.get(n)));
|
|
57
|
+
return near.length ? near.reduce((a, b) => a + b, 0) / near.length : pos.get(k);
|
|
58
|
+
};
|
|
59
|
+
const score = new Map(order.map(k => [k, pull(k)]));
|
|
60
|
+
order = [...order].sort((a, b) => score.get(a) - score.get(b) || pos.get(a) - pos.get(b));
|
|
61
|
+
}
|
|
62
|
+
// Top rows first; each column is placed under the middle of the boxes
|
|
63
|
+
// it connects to that are already placed, as far left as that allows.
|
|
64
|
+
const rank = new Map(order.map((k, i) => [k, i]));
|
|
65
|
+
const at = new Map(), outlines = [], placed = [];
|
|
66
|
+
const rowY = r => margin + labelH + r * rowH;
|
|
67
|
+
// Room at each side for an arrow that goes around a row's outer box.
|
|
68
|
+
const side = margin + gap;
|
|
69
|
+
for (const k of [...order].sort((a, b) => cols[a].top - cols[b].top || rank.get(a) - rank.get(b))) {
|
|
70
|
+
const c = cols[k];
|
|
71
|
+
const near = c.members.flatMap(m => neighbours.get(m)).filter(n => at.has(n)).map(n => at.get(n).x + at.get(n).w / 2);
|
|
72
|
+
const want = near.length ? near.reduce((a, b) => a + b, 0) / near.length - c.width / 2 : margin;
|
|
73
|
+
const left = placed.filter(o => !(c.bottom < o.top || c.top > o.bottom)).reduce((x, o) => Math.max(x, o.right + gap), side);
|
|
74
|
+
placed.push({ top: c.top, bottom: c.bottom, right: left + c.width });
|
|
75
|
+
for (const [r, ms] of c.rows) {
|
|
76
|
+
let x = left + (c.width - c.lineW(ms)) / 2;
|
|
77
|
+
for (const m of ms) {
|
|
78
|
+
const { w, h } = size.get(m);
|
|
79
|
+
at.set(m, { x, y: rowY(r) + (tallest - h) / 2, w, h });
|
|
80
|
+
x += w + inner;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (c.members.length > 1)
|
|
84
|
+
outlines.push({ i: c.i, x: left, y: rowY(c.top) - 8, width: c.width, height: (c.bottom - c.top) * rowH + tallest + 16 });
|
|
85
|
+
}
|
|
86
|
+
const width = Math.max(...placed.map(o => o.right)) + side;
|
|
87
|
+
const bands = levels.map((_, r) => ({ top: rowY(r) - 8, height: tallest + 16 }));
|
|
88
|
+
// An arrow that skips rows passes each row between its boxes, never
|
|
89
|
+
// behind one: it crosses each row straight down the gap nearest the
|
|
90
|
+
// straight line from its start to its end, and bends only between rows.
|
|
91
|
+
const spans = new Map();
|
|
92
|
+
for (const [m, b] of at) {
|
|
93
|
+
const r = rowOf.get(m);
|
|
94
|
+
spans.set(r, [...(spans.get(r) ?? []), [b.x - inner / 2, b.x + b.w + inner / 2]].sort((p, q) => p[0] - q[0]));
|
|
95
|
+
}
|
|
96
|
+
const pass = (r, want) => {
|
|
97
|
+
const boxes = spans.get(r) ?? [];
|
|
98
|
+
if (!boxes.some(([l, h]) => want > l && want < h)) return want;
|
|
99
|
+
const gaps = [boxes[0][0] - gap / 2, ...boxes.slice(1).map(([l], i) => (boxes[i][1] + l) / 2), Math.min(boxes.at(-1)[1] + gap / 2, width - 4)];
|
|
100
|
+
return gaps.reduce((a, b) => Math.abs(b - want) < Math.abs(a - want) ? b : a);
|
|
101
|
+
};
|
|
102
|
+
const route = (from, to) => {
|
|
103
|
+
const a = at.get(from), b = at.get(to), ra = rowOf.get(from), rb = rowOf.get(to);
|
|
104
|
+
const start = [a.x + a.w / 2, a.y + a.h], end = [b.x + b.w / 2, b.y - 4];
|
|
105
|
+
const via = [];
|
|
106
|
+
for (let r = ra + 1; r < rb; r++) {
|
|
107
|
+
const t = (r - ra) / (rb - ra);
|
|
108
|
+
const x = pass(r, start[0] + (end[0] - start[0]) * t);
|
|
109
|
+
via.push([x, rowY(r) - 4], [x, rowY(r) + tallest + 4]);
|
|
110
|
+
}
|
|
111
|
+
return [start, ...via, end];
|
|
112
|
+
};
|
|
113
|
+
return { at, bands, outlines, width, route, height: rowY(levels.length - 1) + tallest + margin + 8 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A stack: each level is a layer, highest on top, and everything in a
|
|
117
|
+
// layer is built only from layers below it. A layer's tiles wrap onto
|
|
118
|
+
// more lines when the frame is narrow; a group is a tile holding its
|
|
119
|
+
// packages, a line per level. Spare width on a line goes to the tiles
|
|
120
|
+
// the rest of the program uses most, so a wider tile is used more (a
|
|
121
|
+
// layout choice).
|
|
122
|
+
// a group has no heading (its shared facts are its title), so its members start one pad below its top
|
|
123
|
+
const tileGap = 8, pad = 8, headH = pad, subH = 40, oneH = 46;
|
|
124
|
+
export function stackLayout(layers, width) {
|
|
125
|
+
const inside = width - 2 * margin;
|
|
126
|
+
const fill = (items, room) => {
|
|
127
|
+
const lines = [];
|
|
128
|
+
for (const item of items) {
|
|
129
|
+
const line = lines.at(-1);
|
|
130
|
+
if (line && line.reduce((w, i) => w + i.min + tileGap, item.min) <= room) line.push(item);
|
|
131
|
+
else lines.push([item]);
|
|
132
|
+
}
|
|
133
|
+
return lines.map(line => {
|
|
134
|
+
const spare = room - line.reduce((w, i) => w + i.min, 0) - tileGap * (line.length - 1);
|
|
135
|
+
const weight = line.reduce((w, i) => w + i.weight, 0);
|
|
136
|
+
let x = 0;
|
|
137
|
+
return line.map(i => { const w = i.min + spare * i.weight / weight, at = { item: i, x, w }; x += w + tileGap; return at; });
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
// Inside a group, each level of its members starts a new line, so a
|
|
141
|
+
// member sits above the members it is built from.
|
|
142
|
+
const byLevel = members => members.reduce((lines, m) => {
|
|
143
|
+
if (lines.length && lines.at(-1)[0].level === m.level) lines.at(-1).push(m); else lines.push([m]);
|
|
144
|
+
return lines;
|
|
145
|
+
}, []);
|
|
146
|
+
if (layers.some(tiles => tiles.some(t => t.min > inside))) return null;
|
|
147
|
+
const tiles = [], bands = [];
|
|
148
|
+
let y = margin;
|
|
149
|
+
for (const layer of layers) {
|
|
150
|
+
const top = y;
|
|
151
|
+
for (const line of fill(layer, inside)) {
|
|
152
|
+
const placed = line.map(({ item, x, w }) => {
|
|
153
|
+
if (!item.members) return { item, x: margin + x, w, h: oneH, subs: [] };
|
|
154
|
+
const rows = byLevel(item.members).flatMap(level => fill(level, w - 2 * pad));
|
|
155
|
+
return { item, x: margin + x, w, h: headH + rows.length * (subH + tileGap) + pad - tileGap + 4,
|
|
156
|
+
subs: rows.flatMap((row, r) => row.map(s => ({ item: s.item, x: margin + x + pad + s.x, y: headH + r * (subH + tileGap), w: s.w, h: subH }))) };
|
|
157
|
+
});
|
|
158
|
+
const h = Math.max(...placed.map(t => t.h));
|
|
159
|
+
for (const t of placed) tiles.push({ ...t, y, h, subs: t.subs.map(s => ({ ...s, y: y + s.y })) });
|
|
160
|
+
y += h + tileGap;
|
|
161
|
+
}
|
|
162
|
+
bands.push({ top: top - 4, height: y - tileGap - top + 8 });
|
|
163
|
+
y += 10;
|
|
164
|
+
}
|
|
165
|
+
return { tiles, bands, width, height: y - 10 + margin };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let mounted = 0;
|
|
169
|
+
function mount(root, name, recorded) {
|
|
170
|
+
// Radio groups and the arrow marker need names unique on the page.
|
|
171
|
+
const id = `layers-${++mounted}`;
|
|
172
|
+
const data = recorded.layers;
|
|
173
|
+
const sourceDir = recorded.files[0].path.slice(0, recorded.files[0].path.lastIndexOf('/'));
|
|
174
|
+
const prefix = (() => {
|
|
175
|
+
const first = data.packages[0].name, cut = first.lastIndexOf('/') + 1;
|
|
176
|
+
return data.packages.every(p => p.name.startsWith(first.slice(0, cut))) ? first.slice(0, cut) : '';
|
|
177
|
+
})();
|
|
178
|
+
const short = name => name.slice(prefix.length);
|
|
179
|
+
const byName = new Map(data.packages.map(p => [p.name, p]));
|
|
180
|
+
const packageUses = usesElsewhere(data);
|
|
181
|
+
|
|
182
|
+
// Tables a caller hands in are the caller's data passing through, so
|
|
183
|
+
// the counts name only the package's own tables.
|
|
184
|
+
const own = (p, tables) => tables.filter(t => !p.handedIn.includes(t));
|
|
185
|
+
const handed = p => {
|
|
186
|
+
const reads = p.reads.filter(t => p.handedIn.includes(t)), writes = p.writes.filter(t => p.handedIn.includes(t));
|
|
187
|
+
return p.handedIn.length ? ` Through tables its callers hand in, it ${[reads.length ? `reads ${reads.join(', ')}` : '', writes.length ? `writes ${writes.join(', ')}` : ''].filter(Boolean).join(' and ')}.` : '';
|
|
188
|
+
};
|
|
189
|
+
// One line of IO for a package, or for the packages of a folded group.
|
|
190
|
+
const summary = ps => {
|
|
191
|
+
const io = ps.filter(p => p.effects.length > 0);
|
|
192
|
+
if (io.length === 0) return 'no direct IO';
|
|
193
|
+
const tables = pick => new Set(io.flatMap(p => own(p, pick(p)))).size;
|
|
194
|
+
const reads = tables(p => p.reads), writes = tables(p => p.writes), handedIn = new Set(io.flatMap(p => p.handedIn)).size;
|
|
195
|
+
const parts = [reads ? `reads ${reads}` : '', writes ? `writes ${writes}` : '', handedIn ? `${handedIn} handed in` : ''].filter(Boolean);
|
|
196
|
+
return parts.length ? `direct IO · ${parts.join(', ')}` : 'direct IO, no tables';
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Which groups are folded into one box; a big program starts folded.
|
|
200
|
+
const foldAll = data.packages.length > foldAbove;
|
|
201
|
+
// A page may pin how the diagram opens: data-drawing and data-dimension.
|
|
202
|
+
const opening = (value, allowed, fallback) => {
|
|
203
|
+
if (value === undefined) return fallback;
|
|
204
|
+
if (!allowed.includes(value)) throw new Error(`unknown layer diagram setting ${value}`);
|
|
205
|
+
return value;
|
|
206
|
+
};
|
|
207
|
+
let dimension = opening(root.dataset.dimension, Object.keys(dimensions), 'connections');
|
|
208
|
+
let drawing = opening(root.dataset.drawing, ['stack', 'arrows'], 'stack'), groups, folded, fold, scene, drawn, fitted;
|
|
209
|
+
const refold = () => { fold = foldGroups(data, groups, i => folded.has(i)); };
|
|
210
|
+
const regroup = () => {
|
|
211
|
+
groups = groupPackages(data, dimension);
|
|
212
|
+
folded = new Set(foldAll ? groups.keys() : []);
|
|
213
|
+
refold();
|
|
214
|
+
};
|
|
215
|
+
regroup();
|
|
216
|
+
// Start on the entry point at the top, or the folded box holding it.
|
|
217
|
+
const entry = [...data.packages].sort((a, b) => b.level - a.level || a.name.localeCompare(b.name))[0].name;
|
|
218
|
+
const chosen = opening(root.dataset.selected, ['none', ...data.packages.map(p => short(p.name))], undefined);
|
|
219
|
+
let selected = chosen === undefined ? entry : chosen === 'none' ? null : prefix + chosen;
|
|
220
|
+
const mostUsed = members => [...members].sort((a, b) => packageUses.get(b) - packageUses.get(a) || a.localeCompare(b));
|
|
221
|
+
|
|
222
|
+
// A group's label is the facts most of its members share, cut to fit.
|
|
223
|
+
const factName = { connections: short, types: f => f.slice(f.indexOf('::') + 2), tables: f => f };
|
|
224
|
+
// Members are listed beside the group, so a connections label names
|
|
225
|
+
// only the packages outside it that most members link to.
|
|
226
|
+
const lead = { connections: 'linked to', types: 'types', tables: 'tables' };
|
|
227
|
+
// all the facts a group's members share (its title and its selection's text)
|
|
228
|
+
const groupLabel = g => {
|
|
229
|
+
const names = g.shared.filter(f => !g.members.includes(f)).map(factName[dimension]);
|
|
230
|
+
return names.length ? `${lead[dimension]} ${names.join(', ')}` : 'linked to each other';
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// What the picture draws: each folded group as one box named by the
|
|
234
|
+
// members the rest of the program uses most, and every other package.
|
|
235
|
+
const build = () => {
|
|
236
|
+
if (drawing === 'stack') return {
|
|
237
|
+
nodes: data.packages.map(p => ({ name: p.name, level: p.level, title: short(p.name), does: summary([p]), packages: [p] })),
|
|
238
|
+
edges: data.packageEdges, uses: packageUses };
|
|
239
|
+
const nodes = fold.nodes.map(n => {
|
|
240
|
+
const ps = n.members.map(m => byName.get(m));
|
|
241
|
+
if (!n.group) return { name: n.name, level: n.level, title: short(n.name), does: summary(ps), packages: ps };
|
|
242
|
+
const ranked = mostUsed(n.members);
|
|
243
|
+
return { name: n.name, level: n.level, title: `${short(ranked[0])} +${ranked.length - 1}`,
|
|
244
|
+
does: `${ps.length} packages · ${summary(ps)}`, packages: ps, group: n.group, index: groups.indexOf(n.group) };
|
|
245
|
+
});
|
|
246
|
+
return { nodes, edges: fold.edges, columns: groups.map((g, i) => folded.has(i) && g.members.length > 1 ? [`group:${i}`] : g.members),
|
|
247
|
+
uses: usesElsewhere(data, fold.nodeOf) };
|
|
248
|
+
};
|
|
249
|
+
const node = name => scene.nodes.find(n => n.name === name);
|
|
250
|
+
|
|
251
|
+
// The stack folds every group and draws its packages inside it; the
|
|
252
|
+
// rows are the folded levels, so a group sits on what it is built from.
|
|
253
|
+
const stackPicture = width => {
|
|
254
|
+
const tile = p => ({ name: p.name, level: p.level, min: boxWidth(short(p.name), summary([p]), p.effects.length > 0), weight: 1 + Math.sqrt(packageUses.get(p.name)), p });
|
|
255
|
+
const all = foldGroups(data, groups);
|
|
256
|
+
const levels = [...new Set(all.nodes.map(n => n.level))].sort((a, b) => b - a);
|
|
257
|
+
const layers = levels.map((level, layer) => all.nodes.filter(n => n.level === level).map(n => {
|
|
258
|
+
if (!n.group) return { ...tile(byName.get(n.name)), layer };
|
|
259
|
+
// Inside a group, packages higher in the program come first.
|
|
260
|
+
const members = mostUsed(n.members).sort((a, b) => byName.get(b).level - byName.get(a).level).map(m => tile(byName.get(m)));
|
|
261
|
+
return { name: n.name, group: n.group, members, layer, weight: members.reduce((w, m) => w + m.weight, 0),
|
|
262
|
+
min: Math.max(...members.map(m => m.min + 2 * pad)) };
|
|
263
|
+
}).sort((a, b) => b.weight - a.weight || a.name.localeCompare(b.name)));
|
|
264
|
+
const laid = stackLayout(layers, width);
|
|
265
|
+
if (!laid) return null;
|
|
266
|
+
const box = layer => ({ item, x, y, w, h }) => svg('g', { class: 'layer-box', 'data-package': item.name, 'data-layer': layer, 'data-io': String(item.p.effects.length > 0),
|
|
267
|
+
'data-uses': packageUses.get(item.name), tabindex: 0, role: 'button', 'aria-label': `${short(item.name)}, ${summary([item.p])}, ${counted(packageUses.get(item.name), 'use')} from other packages`, 'data-fit': `box:${item.name}` },
|
|
268
|
+
svg('rect', { x, y, width: w, height: h, 'data-box': `box:${item.name}` }),
|
|
269
|
+
svg('text', { class: 'layer-name', x: x + 10, y: y + h / 2 - 3 }, short(item.name)),
|
|
270
|
+
svg('text', { class: 'layer-does', x: x + 10, y: y + h / 2 + 14 }, summary([item.p])));
|
|
271
|
+
return svg('svg', { class: 'layer-svg layer-stack', viewBox: `0 0 ${laid.width} ${laid.height}`, width: laid.width, height: laid.height, role: 'group',
|
|
272
|
+
'aria-label': `Packages of ${recorded.request.args.title} as a stack: each layer is built from the layers below it${dimension === 'none' ? '' : `, grouped by ${dimensions[dimension]}`}` },
|
|
273
|
+
laid.bands.map((b, i) => svg('rect', { class: 'layer-band', 'data-shade': i % 2, x: 0, y: b.top, width: laid.width, height: b.height })),
|
|
274
|
+
laid.tiles.map(t => t.item.members
|
|
275
|
+
? svg('g', { class: 'layer-group', 'data-members': t.item.group.members.join(' ') },
|
|
276
|
+
// the shared facts are not drawn as a heading (cut to fit, they read
|
|
277
|
+
// as noise); they are the outline's title and the selection's text
|
|
278
|
+
svg('title', {}, `Most of them: ${groupLabel(t.item.group)}`),
|
|
279
|
+
svg('rect', { x: t.x, y: t.y, width: t.w, height: t.h, rx: 6, 'data-box': `group:${t.item.name}` }),
|
|
280
|
+
t.subs.map(box(t.item.layer)))
|
|
281
|
+
: box(t.item.layer)(t)));
|
|
282
|
+
};
|
|
283
|
+
const picture = () => {
|
|
284
|
+
scene = build();
|
|
285
|
+
if (drawing === 'stack') {
|
|
286
|
+
// A phone gets a stack a little wider than its frame, scaled down.
|
|
287
|
+
const stack = stackPicture(Math.max(440, frame.clientWidth - 2));
|
|
288
|
+
if (stack) return stack;
|
|
289
|
+
// Too narrow for every tile: fall back to boxes and arrows.
|
|
290
|
+
drawing = 'arrows';
|
|
291
|
+
selected = selected && fold.nodeOf.get(packageOf(selected));
|
|
292
|
+
scene = build();
|
|
293
|
+
}
|
|
294
|
+
const most = Math.max(1, ...scene.uses.values());
|
|
295
|
+
const size = new Map(scene.nodes.map(n => {
|
|
296
|
+
const t = Math.sqrt(scene.uses.get(n.name) / most);
|
|
297
|
+
return [n.name, { w: Math.max(minW + growW * t, boxWidth(n.title, n.does, n.packages.some(p => p.effects.length > 0))), h: minH + growH * t }];
|
|
298
|
+
}));
|
|
299
|
+
const { at, bands, outlines, width, height, route } = layout(scene.nodes, scene.edges, scene.columns, size);
|
|
300
|
+
const edges = scene.edges.map(e => {
|
|
301
|
+
const points = route(e.from, e.to);
|
|
302
|
+
const d = points.slice(1).reduce((path, [x2, y2], i) => {
|
|
303
|
+
const [x1, y1] = points[i], bend = Math.max(24, (y2 - y1) / 2);
|
|
304
|
+
return `${path} C${x1},${y1 + bend} ${x2},${y2 - bend} ${x2},${y2}`;
|
|
305
|
+
}, `M${points[0][0]},${points[0][1]}`);
|
|
306
|
+
return svg('path', { class: 'layer-edge', 'data-from': e.from, 'data-to': e.to, d, 'marker-end': `url(#${id}-arrow)` },
|
|
307
|
+
svg('title', {}, `${node(e.from).title} uses ${usage(e)} of ${node(e.to).title}`));
|
|
308
|
+
});
|
|
309
|
+
const boxes = scene.nodes.map(n => {
|
|
310
|
+
const { x, y, w, h } = at.get(n.name);
|
|
311
|
+
return svg('g', { class: 'layer-box', 'data-package': n.name, 'data-folded': String(Boolean(n.group)), 'data-io': String(n.packages.some(p => p.effects.length > 0)),
|
|
312
|
+
'data-uses': scene.uses.get(n.name), tabindex: 0, role: 'button', 'aria-label': `${n.title}, ${n.does}, ${counted(scene.uses.get(n.name), 'use')} from the rest of the program`, 'data-fit': `box:${n.name}` },
|
|
313
|
+
svg('rect', { x, y, width: w, height: h, 'data-box': `box:${n.name}` }),
|
|
314
|
+
svg('text', { class: 'layer-name', x: x + 10, y: y + h / 2 - 3 }, n.title),
|
|
315
|
+
svg('text', { class: 'layer-does', x: x + 10, y: y + h / 2 + 14 }, n.does));
|
|
316
|
+
});
|
|
317
|
+
const hulls = outlines.map(o => svg('g', { class: 'layer-group', 'data-members': groups[o.i].members.join(' ') },
|
|
318
|
+
svg('title', {}, `Most of them: ${groupLabel(groups[o.i])}`),
|
|
319
|
+
svg('rect', { x: o.x - 4, y: o.y, width: o.width + 8, height: o.height, rx: 6, 'data-box': `group:${o.i}` })));
|
|
320
|
+
return svg('svg', { class: 'layer-svg', viewBox: `0 0 ${width} ${height}`, width, height, role: 'group',
|
|
321
|
+
'aria-label': `Packages of ${recorded.request.args.title} in bands by level, ${dimension === 'none' ? 'ungrouped' : `grouped by ${dimensions[dimension]}`}; each arrow points to what a box is built from` },
|
|
322
|
+
svg('defs', {}, svg('marker', { id: `${id}-arrow`, markerWidth: 8, markerHeight: 8, refX: 6, refY: 4, orient: 'auto', markerUnits: 'userSpaceOnUse' },
|
|
323
|
+
svg('path', { d: 'M0,0 L7,4 L0,8 z' }))),
|
|
324
|
+
bands.map((b, i) => svg('rect', { class: 'layer-band', 'data-shade': i % 2, x: 0, y: b.top, width, height: b.height })),
|
|
325
|
+
hulls,
|
|
326
|
+
svg('g', { class: 'layer-edges' }, edges),
|
|
327
|
+
boxes);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const frame = el('div', { class: 'layer-scroll' });
|
|
331
|
+
const detail = el('div', { class: 'layer-detail', 'aria-live': 'polite' });
|
|
332
|
+
const draw = () => {
|
|
333
|
+
fitted = frame.clientWidth;
|
|
334
|
+
drawn = picture();
|
|
335
|
+
frame.replaceChildren(drawn);
|
|
336
|
+
for (const input of controls.querySelectorAll('input[name$="-fold"]')) input.disabled = drawing === 'stack';
|
|
337
|
+
for (const input of controls.querySelectorAll('input[name$="-drawing"]')) input.checked = input.value === drawing;
|
|
338
|
+
render();
|
|
339
|
+
};
|
|
340
|
+
// A stack fits its frame, so it is laid out again when the frame resizes.
|
|
341
|
+
if ('ResizeObserver' in window) new ResizeObserver(() => { if (drawing === 'stack' && frame.clientWidth !== fitted) draw(); }).observe(frame);
|
|
342
|
+
const choice = (legend, key, options, current, pick) => el('fieldset', { class: 'layer-choice' }, el('legend', {}, legend),
|
|
343
|
+
options.map(([value, words]) => el('label', {},
|
|
344
|
+
el('input', { type: 'radio', name: `${id}-${key}`, value, checked: value === current, onchange: () => { pick(value); draw(); } }), ` ${words}`)));
|
|
345
|
+
// The selection follows its package: into the box its group folds
|
|
346
|
+
// into, and out to the most used member when a group opens.
|
|
347
|
+
const packageOf = name => byName.has(name) ? name : mostUsed(fold.nodes.find(n => n.name === name).members)[0];
|
|
348
|
+
const refoldKeeping = change => {
|
|
349
|
+
const was = selected && packageOf(selected);
|
|
350
|
+
change();
|
|
351
|
+
refold();
|
|
352
|
+
selected = was && (drawing === 'stack' ? was : fold.nodeOf.get(was));
|
|
353
|
+
};
|
|
354
|
+
const controls = el('div', { class: 'layer-controls' },
|
|
355
|
+
choice('Draw as', 'drawing', [['stack', 'a stack of layers'], ['arrows', 'boxes and arrows']], drawing, value => {
|
|
356
|
+
const was = selected && packageOf(selected);
|
|
357
|
+
drawing = value;
|
|
358
|
+
selected = was && (drawing === 'stack' ? was : fold.nodeOf.get(was));
|
|
359
|
+
}),
|
|
360
|
+
choice('Group packages by', 'grouping', Object.entries(dimensions), dimension, value => refoldKeeping(() => {
|
|
361
|
+
dimension = value;
|
|
362
|
+
groups = groupPackages(data, dimension);
|
|
363
|
+
folded = new Set(foldAll ? groups.keys() : []);
|
|
364
|
+
})),
|
|
365
|
+
choice('Show', 'fold', [['folded', 'each group as one box'], ['open', 'every package']], foldAll ? 'folded' : 'open', value => refoldKeeping(() => {
|
|
366
|
+
folded = new Set(value === 'folded' ? groups.keys() : []);
|
|
367
|
+
})));
|
|
368
|
+
const toggleGroup = i => refoldKeeping(() => { if (folded.has(i)) folded.delete(i); else folded.add(i); });
|
|
369
|
+
root.replaceChildren(
|
|
370
|
+
controls,
|
|
371
|
+
toolOutput(`layers lens · ${recorded.request.args.title} · ${data.packages.length} packages`, frame, detail,
|
|
372
|
+
el('p', { class: 'lens-source' }, ...(servedSources.has(name)
|
|
373
|
+
? ['Recorded from the type-checked program. ',
|
|
374
|
+
el('a', { href: `${viewerUrl}${writeSourceLink(recorded.files.map(f => f.path), 0)}` }, 'Read the source')]
|
|
375
|
+
: ['Recorded from the type-checked program in ', el('code', {}, sourceDir), '.']), ' · ',
|
|
376
|
+
el('a', { href: new URL(`${name}-layers.json`, evidenceRoot) }, 'Raw lens output'),
|
|
377
|
+
data.declarations.length ? ` · Not drawn, declarations only: ${data.declarations.map(short).join(', ')}.` : '')),
|
|
378
|
+
el('p', { class: 'layer-key' }, 'How to read it: in the stack, each shaded layer is built only from the layers below it, though not always the one just below, and inside a group each package sits above the ones it is built from; select a package to see exactly what it uses. With boxes and arrows, each band is one level and arrows point to what a box is built from. A bigger box or a wider tile is used more by the rest of the program: references to its definitions plus types imported from it. Packages alike on the dimension you pick form a group, outlined with what most of them share, or folded into one box named by the members used most. ',
|
|
379
|
+
el('span', { class: 'layer-key-io' }, 'Direct IO'),
|
|
380
|
+
" marks effect sites in that package's own definitions, and the numbers count their tables; tables its callers pass in are counted as handed in, since that data is theirs. A package without direct sites may still call another package's IO. Click a box to select it (",
|
|
381
|
+
el('span', { class: 'layer-key-selected' }, 'filled'), ') and outline what it ', el('span', { class: 'layer-key-uses' }, 'uses'),
|
|
382
|
+
' and what it is ', el('span', { class: 'layer-key-used' }, 'used by'), '.'));
|
|
383
|
+
|
|
384
|
+
const joined = parts => parts.flatMap((part, i) => [i ? ', ' : '', part]);
|
|
385
|
+
const list = (es, pick, kind) => es.length
|
|
386
|
+
? joined(es.map(e => [el('span', { class: `layer-${kind}` }, node(pick(e)).title), ` (${usage(e)})`]))
|
|
387
|
+
: 'nothing in this program';
|
|
388
|
+
function describe(n) {
|
|
389
|
+
const outs = scene.edges.filter(e => e.from === n.name), ins = scene.edges.filter(e => e.to === n.name);
|
|
390
|
+
const links = ['Uses ', list(outs, e => e.to, 'uses'), '.', el('br'), 'Used by ', list(ins, e => e.from, 'used'), '.', el('br'),
|
|
391
|
+
`${counted(scene.uses.get(n.name), 'use')} from the rest of the program.`];
|
|
392
|
+
if (n.group) return [el('strong', {}, n.title), `, ${n.packages.length} packages grouped by ${dimensions[dimension]}: `,
|
|
393
|
+
joined(n.packages.map(p => el('code', {}, short(p.name)))), '. ', `Most of them: ${groupLabel(n.group)}.`, el('br'), ...links, el('br'),
|
|
394
|
+
el('button', { type: 'button', class: 'layer-fold', onclick: () => { toggleGroup(n.index); draw(); } }, 'Open this group')];
|
|
395
|
+
const p = n.packages[0], group = groups.find(g => g.members.includes(p.name)), others = group.members.filter(m => m !== p.name).map(short);
|
|
396
|
+
const iface = p.definitions.filter(d => d.usedOutside).map(d => el('code', {}, d.name));
|
|
397
|
+
const does = p.effects.length === 0 ? 'No direct effect sites; it may call another package\'s IO.'
|
|
398
|
+
: `Direct effect sites: ${p.effects.join(', ')}.` + (own(p, p.reads).length ? ` Reads ${own(p, p.reads).join(', ')}.` : '') + (own(p, p.writes).length ? ` Writes ${own(p, p.writes).join(', ')}.` : '')
|
|
399
|
+
+ handed(p);
|
|
400
|
+
return [el('strong', {}, short(p.name)), `, ${p.definitions.length} definitions. ${does}`, el('br'), ...links, el('br'),
|
|
401
|
+
iface.length ? ['Definitions other packages use: ', joined(iface), '.'] : 'No other package uses its definitions: it is an entry point.',
|
|
402
|
+
others.length ? [el('br'), 'Grouped with ', joined(others), ` by ${dimensions[dimension]}. `, drawing === 'stack' ? [] :
|
|
403
|
+
el('button', { type: 'button', class: 'layer-fold', onclick: () => { toggleGroup(groups.indexOf(group)); draw(); } }, 'Fold this group')] : []];
|
|
404
|
+
}
|
|
405
|
+
function render() {
|
|
406
|
+
drawn.toggleAttribute('data-has-selection', Boolean(selected));
|
|
407
|
+
for (const g of drawn.querySelectorAll('.layer-box')) {
|
|
408
|
+
const name = g.dataset.package;
|
|
409
|
+
g.dataset.relation = name === selected ? 'selected'
|
|
410
|
+
: scene.edges.some(e => e.from === selected && e.to === name) ? 'uses'
|
|
411
|
+
: scene.edges.some(e => e.to === selected && e.from === name) ? 'used' : '';
|
|
412
|
+
g.setAttribute('aria-pressed', String(name === selected));
|
|
413
|
+
}
|
|
414
|
+
for (const path of drawn.querySelectorAll('.layer-edge'))
|
|
415
|
+
path.dataset.relation = path.dataset.from === selected ? 'uses' : path.dataset.to === selected ? 'used' : '';
|
|
416
|
+
detail.replaceChildren(...(selected ? describe(node(selected))
|
|
417
|
+
: ['Select a box to see what it uses, what uses it, and what it does itself.']).flat(Infinity));
|
|
418
|
+
}
|
|
419
|
+
const chooseBox = g => { selected = selected === g.dataset.package ? null : g.dataset.package; render(); };
|
|
420
|
+
frame.addEventListener('click', event => { const g = event.target.closest('.layer-box'); if (g) chooseBox(g); });
|
|
421
|
+
frame.addEventListener('keydown', event => {
|
|
422
|
+
const g = event.target.closest('.layer-box');
|
|
423
|
+
if (g && (event.key === 'Enter' || event.key === ' ')) {
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
chooseBox(g);
|
|
426
|
+
frame.querySelector(`.layer-box[data-package="${CSS.escape(g.dataset.package)}"]`)?.focus();
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
draw();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// One load per diagram; a retry after a failure starts a new generation
|
|
433
|
+
// so a slow earlier response cannot overwrite a later one.
|
|
434
|
+
for (const root of document.querySelectorAll('[data-layer-diagram]')) {
|
|
435
|
+
let generation = 0;
|
|
436
|
+
const load = async () => {
|
|
437
|
+
const current = ++generation;
|
|
438
|
+
try {
|
|
439
|
+
const response = await fetch(new URL(`${root.dataset.layerDiagram}-layers.json`, evidenceRoot));
|
|
440
|
+
if (current !== generation) return;
|
|
441
|
+
if (!response.ok) throw new Error(`Layer data request failed (${response.status})`);
|
|
442
|
+
const data = await response.json();
|
|
443
|
+
if (current !== generation) return;
|
|
444
|
+
mount(root, root.dataset.layerDiagram, data);
|
|
445
|
+
} catch (error) {
|
|
446
|
+
if (current !== generation) return;
|
|
447
|
+
const message = document.createElement('p');
|
|
448
|
+
message.setAttribute('role', 'alert');
|
|
449
|
+
message.textContent = `The layer diagram could not be loaded: ${error.message}.`;
|
|
450
|
+
const retry = document.createElement('button');
|
|
451
|
+
retry.type = 'button';
|
|
452
|
+
retry.textContent = 'Retry';
|
|
453
|
+
retry.addEventListener('click', load);
|
|
454
|
+
root.replaceChildren(message, retry);
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
load();
|
|
458
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Groups and weights for the layer diagram, computed from the layers
|
|
2
|
+
// lens output alone. Packages are alike on one chosen dimension: the
|
|
3
|
+
// packages they connect to, the types they share, or the tables they
|
|
4
|
+
// touch. Names are only labels here; a group comes from shared facts.
|
|
5
|
+
|
|
6
|
+
export const dimensions = Object.freeze({
|
|
7
|
+
connections: 'packages they connect to',
|
|
8
|
+
types: 'types they share',
|
|
9
|
+
tables: 'tables they touch',
|
|
10
|
+
none: 'no grouping',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Two groups merge while their members are, on average, at least this
|
|
14
|
+
// alike (Jaccard similarity of the chosen facts).
|
|
15
|
+
const together = 0.3;
|
|
16
|
+
|
|
17
|
+
// What a package has on one dimension, as a set of fact keys. A type is
|
|
18
|
+
// shared by the package that defines it and every package importing it.
|
|
19
|
+
function facts(data, dimension) {
|
|
20
|
+
const sets = new Map(data.packages.map(p => [p.name, new Set()]));
|
|
21
|
+
for (const p of data.packages) {
|
|
22
|
+
const f = sets.get(p.name);
|
|
23
|
+
if (dimension === 'tables') [...p.reads, ...p.writes].forEach(t => f.add(t));
|
|
24
|
+
if (dimension === 'connections') f.add(p.name);
|
|
25
|
+
}
|
|
26
|
+
for (const e of data.packageEdges) {
|
|
27
|
+
if (dimension === 'connections') { sets.get(e.from).add(e.to); sets.get(e.to).add(e.from); }
|
|
28
|
+
if (dimension === 'types') for (const t of e.typeNames) {
|
|
29
|
+
sets.get(e.from).add(`${e.to}::${t}`);
|
|
30
|
+
sets.get(e.to).add(`${e.to}::${t}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return sets;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const alike = (a, b) => {
|
|
37
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
38
|
+
let shared = 0;
|
|
39
|
+
for (const x of a) if (b.has(x)) shared++;
|
|
40
|
+
return shared / (a.size + b.size - shared);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Whether folding each group into one node leaves a graph with no cycle.
|
|
44
|
+
function foldsToLayers(data, groups) {
|
|
45
|
+
const nodeOf = new Map(groups.flatMap((g, i) => g.map(m => [m, i])));
|
|
46
|
+
const out = groups.map(() => new Set());
|
|
47
|
+
for (const e of data.packageEdges) if (nodeOf.get(e.from) !== nodeOf.get(e.to)) out[nodeOf.get(e.from)].add(nodeOf.get(e.to));
|
|
48
|
+
const state = groups.map(() => 0);
|
|
49
|
+
const acyclic = i => {
|
|
50
|
+
if (state[i]) return state[i] === 2;
|
|
51
|
+
state[i] = 1;
|
|
52
|
+
const ok = [...out[i]].every(acyclic);
|
|
53
|
+
state[i] = 2;
|
|
54
|
+
return ok;
|
|
55
|
+
};
|
|
56
|
+
return groups.every((_, i) => acyclic(i));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Average-linkage clustering. Two groups merge only if folding every group
|
|
60
|
+
// into one box still leaves arrows that all point down. Members keep the order they
|
|
61
|
+
// were merged in, so the most alike packages sit next to each other.
|
|
62
|
+
export function groupPackages(data, dimension) {
|
|
63
|
+
const sets = facts(data, dimension);
|
|
64
|
+
let groups = data.packages.map(p => [p.name]);
|
|
65
|
+
// A group of more than half the program would say nothing about it.
|
|
66
|
+
const half = Math.max(2, Math.floor(data.packages.length / 2));
|
|
67
|
+
if (dimension === 'none') return groups.map(members => ({ members, shared: [] }));
|
|
68
|
+
const score = (x, y) => x.reduce((s, a) => s + y.reduce((t, b) => t + alike(sets.get(a), sets.get(b)), 0), 0) / (x.length * y.length);
|
|
69
|
+
for (;;) {
|
|
70
|
+
const pairs = [];
|
|
71
|
+
for (let i = 0; i < groups.length; i++)
|
|
72
|
+
for (let j = i + 1; j < groups.length; j++) {
|
|
73
|
+
const s = score(groups[i], groups[j]);
|
|
74
|
+
if (s >= together) pairs.push({ s, i, j });
|
|
75
|
+
}
|
|
76
|
+
const best = pairs.sort((a, b) => b.s - a.s).find(({ i, j }) => groups[i].length + groups[j].length <= half && foldsToLayers(data, groups.map((g, k) => k === i ? [...g, ...groups[j]] : g).filter((_, k) => k !== j)));
|
|
77
|
+
if (!best) break;
|
|
78
|
+
groups[best.i] = [...groups[best.i], ...groups[best.j]];
|
|
79
|
+
groups = groups.filter((_, k) => k !== best.j);
|
|
80
|
+
}
|
|
81
|
+
return groups.map(members => ({ members, shared: members.length > 1 ? sharedFacts(members, sets) : [] }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The facts at least half the members have, most common first.
|
|
85
|
+
function sharedFacts(members, sets) {
|
|
86
|
+
const count = new Map();
|
|
87
|
+
for (const m of members) for (const x of sets.get(m)) count.set(x, (count.get(x) ?? 0) + 1);
|
|
88
|
+
return [...count].filter(([, n]) => n * 2 >= members.length)
|
|
89
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([x]) => x);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// How much the rest of the program leans on each node (a package, or a
|
|
93
|
+
// folded group): references from definitions outside it to definitions in
|
|
94
|
+
// it, plus each type imported into it from outside.
|
|
95
|
+
export function usesElsewhere(data, nodeOf = new Map(data.packages.map(p => [p.name, p.name]))) {
|
|
96
|
+
const pkg = id => id.slice(0, id.indexOf('::'));
|
|
97
|
+
const uses = new Map([...new Set(nodeOf.values())].map(n => [n, 0]));
|
|
98
|
+
const add = (from, to, n) => { if (nodeOf.get(from) !== nodeOf.get(to)) uses.set(nodeOf.get(to), uses.get(nodeOf.get(to)) + n); };
|
|
99
|
+
for (const e of data.crossEdges) add(pkg(e.from), pkg(e.to), 1);
|
|
100
|
+
for (const e of data.packageEdges) add(e.from, e.to, e.typeNames.length);
|
|
101
|
+
return uses;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Each folded group of two or more as one node; every other package stays
|
|
105
|
+
// itself. A fold of groups that folds to layers (see groupPackages) has no
|
|
106
|
+
// cycle whichever groups are folded, so a node's level is one more than
|
|
107
|
+
// the highest level it is built from.
|
|
108
|
+
export function foldGroups(data, groups, folded = () => true) {
|
|
109
|
+
const nodes = groups.flatMap((g, i) => g.members.length > 1 && folded(i)
|
|
110
|
+
? [{ name: `group:${i}`, group: g, members: g.members }]
|
|
111
|
+
: g.members.map(m => ({ name: m, members: [m] })));
|
|
112
|
+
const nodeOf = new Map(nodes.flatMap(n => n.members.map(m => [m, n.name])));
|
|
113
|
+
const edges = new Map();
|
|
114
|
+
for (const e of data.packageEdges) {
|
|
115
|
+
const from = nodeOf.get(e.from), to = nodeOf.get(e.to);
|
|
116
|
+
if (from === to) continue;
|
|
117
|
+
const key = `${from}\n${to}`;
|
|
118
|
+
const sum = edges.get(key) ?? { from, to, refs: 0, types: 0 };
|
|
119
|
+
edges.set(key, { ...sum, refs: sum.refs + e.refs, types: sum.types + e.typeNames.length });
|
|
120
|
+
}
|
|
121
|
+
const below = new Map(nodes.map(n => [n.name, []]));
|
|
122
|
+
for (const e of edges.values()) below.get(e.from).push(e.to);
|
|
123
|
+
const level = new Map();
|
|
124
|
+
const visit = n => {
|
|
125
|
+
if (!level.has(n)) level.set(n, below.get(n).reduce((l, m) => Math.max(l, visit(m) + 1), 0));
|
|
126
|
+
return level.get(n);
|
|
127
|
+
};
|
|
128
|
+
return { nodes: nodes.map(n => ({ ...n, level: visit(n.name) })), edges: [...edges.values()], nodeOf };
|
|
129
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The source viewer's link format, `#src=<comma-separated site paths>&tab=<index>`,
|
|
2
|
+
// read and written in one place for every page that opens it. A path is kept
|
|
3
|
+
// only when it resolves to this site, so a shared link never shows another
|
|
4
|
+
// origin's file as one of ours.
|
|
5
|
+
export function readSourceLink(hash, base) {
|
|
6
|
+
const params = new URLSearchParams(hash.replace(/^#/, ''));
|
|
7
|
+
const origin = new URL(base).origin;
|
|
8
|
+
const files = (params.get('src') ?? '').split(',').map((path) => path.trim())
|
|
9
|
+
.filter((path) => path !== '' && URL.canParse(path, base) && new URL(path, base).origin === origin);
|
|
10
|
+
const tab = Number(params.get('tab') ?? 0);
|
|
11
|
+
return { files, tab: Number.isSafeInteger(tab) && tab >= 0 && tab < files.length ? tab : 0 };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Paths keep their slashes, so a shared link reads `#src=demos/x.bosatsu`.
|
|
15
|
+
export function writeSourceLink(files, tab) {
|
|
16
|
+
return '#src=' + files.join(',') + '&tab=' + tab;
|
|
17
|
+
}
|