geometryview 0.0.0-stage → 0.1.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.
Files changed (46) hide show
  1. package/README.md +37 -2
  2. package/bin/geometryview.mjs +65 -0
  3. package/package.json +42 -4
  4. package/src/bridge.mjs +77 -0
  5. package/src/mcp.mjs +73 -0
  6. package/src/native.mjs +176 -0
  7. package/src/page.mjs +147 -0
  8. package/src/tools.mjs +265 -0
  9. package/src/web.mjs +173 -0
  10. package/src/websocket.mjs +104 -0
  11. package/web/LICENSE +21 -0
  12. package/web/NOTICES.md +49 -0
  13. package/web/bridge.js +29 -0
  14. package/web/build/geometryview-web.mjs +2 -0
  15. package/web/build/geometryview-web.wasm +0 -0
  16. package/web/chrome.js +541 -0
  17. package/web/engine.js +453 -0
  18. package/web/fonts/IBMPlexMono-Medium.ttf +0 -0
  19. package/web/fonts/IBMPlexMono-Regular.ttf +0 -0
  20. package/web/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  21. package/web/fonts/LICENSE.txt +93 -0
  22. package/web/icons/icon-180.png +0 -0
  23. package/web/icons/icon-192.png +0 -0
  24. package/web/icons/icon-32.png +0 -0
  25. package/web/icons/icon-512.png +0 -0
  26. package/web/icons.js +50 -0
  27. package/web/import-worker.js +329 -0
  28. package/web/index.html +17 -0
  29. package/web/input.js +255 -0
  30. package/web/isolation-worker.js +21 -0
  31. package/web/library-rows.js +120 -0
  32. package/web/library-store.js +368 -0
  33. package/web/library.js +306 -0
  34. package/web/manifest.webmanifest +14 -0
  35. package/web/occt/geometryview-occt-web.mjs +2 -0
  36. package/web/occt/geometryview-occt-web.wasm +0 -0
  37. package/web/occt/geometryview-step-web.mjs +2 -0
  38. package/web/occt/geometryview-step-web.wasm +0 -0
  39. package/web/occt-threads/geometryview-step-web.mjs +2 -0
  40. package/web/occt-threads/geometryview-step-web.wasm +0 -0
  41. package/web/occt-worker.js +45 -0
  42. package/web/structure.js +435 -0
  43. package/web/toolbar-fold.js +49 -0
  44. package/web/ui.js +134 -0
  45. package/web/viewer.css +558 -0
  46. package/web/viewer.js +1163 -0
@@ -0,0 +1,435 @@
1
+ // The assembly tree of everything open, with search, after the Mac's StructureCard and TreeRows.
2
+ // Rows come from walking nodes depth first and jumping over each collapsed subtree, so the work
3
+ // grows with what is shown; and only the rows in view are made into elements, so a tree of a
4
+ // million nodes scrolls as one of ten.
5
+
6
+ import { Actions, NodeActions, NodeKinds } from "./engine.js";
7
+ import { iconNode, icons } from "./icons.js";
8
+ import { el, pill, segmented, VirtualList } from "./ui.js";
9
+
10
+ // A row's height, as the Mac's; a phone's are taller, for fingers.
11
+ const rowHeight = () => (document.body.classList.contains("phone") ? 30 : 28);
12
+ const kIndent = 14;
13
+ const kNoNode = 0xFFFFFFFF;
14
+ const kInk = [0x23, 0x1e, 0x16];
15
+ const kDocumentRow = -1;
16
+
17
+ // The visible rows across all open documents.
18
+ export class TreeRows {
19
+ documents = [];
20
+ rows = []; // { document, node, depth }, node kDocumentRow for a file's own row
21
+
22
+ reset() {
23
+ this.documents = [];
24
+ this.rows = [];
25
+ }
26
+
27
+ addDocument(name) {
28
+ this.documents.push({ name, model: null, depths: null, partCounts: null,
29
+ expanded: new Set(), documentExpanded: true });
30
+ this.#rebuild();
31
+ }
32
+
33
+ setModel(document, model) {
34
+ const d = this.documents[document];
35
+ if (!d) return;
36
+ // A parent always comes before its children, so one pass down gives every depth and one pass
37
+ // back up every count of parts.
38
+ const depths = new Uint16Array(model.length);
39
+ const parts = new Uint32Array(model.length);
40
+ for (let i = 0; i < model.length; ++i) {
41
+ if (model[i].parent >= 0) depths[i] = depths[model[i].parent] + 1;
42
+ }
43
+ for (let i = model.length - 1; i >= 0; --i) {
44
+ if (model[i].kind === NodeKinds.Part) parts[i] += 1;
45
+ if (model[i].parent >= 0) parts[model[i].parent] += parts[i];
46
+ }
47
+ Object.assign(d, { model, depths, partCounts: parts });
48
+ // Roots start open, and a root with few children opens its sub-assemblies too.
49
+ for (let i = 0; i < model.length; ++i) {
50
+ if (model[i].parent >= 0) continue;
51
+ d.expanded.add(i);
52
+ const children = [];
53
+ for (let child = i + 1; child < model[i].end && children.length <= 12; child = model[child].end) {
54
+ children.push(child);
55
+ }
56
+ if (children.length > 12) continue;
57
+ for (const child of children) if (model[child].kind === NodeKinds.Assembly) d.expanded.add(child);
58
+ }
59
+ this.#rebuild();
60
+ }
61
+
62
+ // A file's own row heads its tree when several files are open, or while its tree is on its way.
63
+ #headed(d) { return this.documents.length > 1 || !d.model; }
64
+
65
+ hasChildren(row) {
66
+ const d = this.documents[row.document];
67
+ if (row.node === kDocumentRow) return (d.model?.length ?? 0) > 0;
68
+ return d.model !== null && d.model[row.node].end > row.node + 1;
69
+ }
70
+
71
+ isExpanded(row) {
72
+ const d = this.documents[row.document];
73
+ return row.node === kDocumentRow ? d.documentExpanded : d.expanded.has(row.node);
74
+ }
75
+
76
+ toggle(row) {
77
+ const d = this.documents[row.document];
78
+ if (row.node === kDocumentRow) d.documentExpanded = !d.documentExpanded;
79
+ else if (d.expanded.has(row.node)) d.expanded.delete(row.node);
80
+ else d.expanded.add(row.node);
81
+ this.#rebuild();
82
+ }
83
+
84
+ // Opens every ancestor of a node and returns its row's index.
85
+ reveal(document, node) {
86
+ const d = this.documents[document];
87
+ if (!d?.model || node >= d.model.length) return -1;
88
+ d.documentExpanded = true;
89
+ for (let parent = d.model[node].parent; parent >= 0; parent = d.model[parent].parent) {
90
+ d.expanded.add(parent);
91
+ }
92
+ this.#rebuild();
93
+ return this.rows.findIndex((row) => row.document === document && row.node === node);
94
+ }
95
+
96
+ name(document, node) {
97
+ const d = this.documents[document];
98
+ if (node === kDocumentRow) return d.name;
99
+ if (!d.model) return "";
100
+ const n = d.model[node];
101
+ if (n.name) return n.name;
102
+ if (n.kind === NodeKinds.Assembly) return "Unnamed assembly";
103
+ if (n.kind === NodeKinds.Body) {
104
+ // Its place among its parent's bodies, from 1.
105
+ let ordinal = 1;
106
+ if (n.parent >= 0) {
107
+ for (let sibling = n.parent + 1; sibling < node; sibling = d.model[sibling].end) {
108
+ if (d.model[sibling].kind === NodeKinds.Body) ordinal += 1;
109
+ }
110
+ }
111
+ return `Body ${ordinal}`;
112
+ }
113
+ return "Unnamed part";
114
+ }
115
+
116
+ // The names of a node's ancestors, from the file down.
117
+ path(document, node) {
118
+ const d = this.documents[document];
119
+ if (node === kDocumentRow || !d.model) return "";
120
+ const names = [];
121
+ for (let parent = d.model[node].parent; parent >= 0; parent = d.model[parent].parent) {
122
+ names.push(this.name(document, parent));
123
+ }
124
+ names.push(d.name);
125
+ return names.reverse().join(" / ");
126
+ }
127
+
128
+ // The parts in an assembly or a file, or ×N on a part made of N bodies.
129
+ count(row) {
130
+ const d = this.documents[row.document];
131
+ if (!d.model) return "";
132
+ if (row.node === kDocumentRow) {
133
+ let parts = 0;
134
+ d.model.forEach((n, i) => { if (n.parent < 0) parts += d.partCounts[i]; });
135
+ return parts > 0 ? String(parts) : "";
136
+ }
137
+ const n = d.model[row.node];
138
+ if (n.kind === NodeKinds.Assembly) return d.partCounts[row.node] > 0 ? String(d.partCounts[row.node]) : "";
139
+ if (n.kind === NodeKinds.Part) {
140
+ const bodies = n.end - row.node - 1;
141
+ return bodies > 1 ? `×${bodies}` : "";
142
+ }
143
+ return "";
144
+ }
145
+
146
+ #rebuild() {
147
+ const rows = [];
148
+ this.documents.forEach((d, document) => {
149
+ const headed = this.#headed(d);
150
+ if (headed) rows.push({ document, node: kDocumentRow, depth: 0 });
151
+ if ((headed && !d.documentExpanded) || !d.model) return;
152
+ const indent = headed ? 1 : 0;
153
+ for (let node = 0; node < d.model.length;) {
154
+ rows.push({ document, node, depth: d.depths[node] + indent });
155
+ node = d.expanded.has(node) ? node + 1 : d.model[node].end;
156
+ }
157
+ });
158
+ this.rows = rows;
159
+ }
160
+ }
161
+
162
+ // The card: Library and Structure switch, search, the rows, and a footer for what is hidden.
163
+ export class StructureCard {
164
+ tree = new TreeRows();
165
+ #engine;
166
+ #list;
167
+ #nothing;
168
+ #search;
169
+ #matchLabel;
170
+ #footer;
171
+ #hiddenLabel;
172
+ #ghostedLabel;
173
+ #showAll;
174
+ #unghostAll;
175
+ #matches = [];
176
+ #matchIndex = 0;
177
+ #states = []; // per document, one NodeVisibility per node
178
+ #swatches = { colors: new Uint32Array(0), bases: new Uint32Array(0) };
179
+ #selected = null; // { document, node } of the selection's focus row
180
+ #highlighted = null; // from the pointer in the view
181
+ #pointerRow = -1;
182
+
183
+ constructor(engine, { onSwitch }) {
184
+ this.#engine = engine;
185
+ this.switcher = segmented(["Library", "Structure"], { selected: 1, help: "Library or structure",
186
+ onChange: (i) => onSwitch?.(i === 0) });
187
+ this.#search = el("input.search", { type: "search", placeholder: "Search parts",
188
+ "aria-label": "Search parts", spellcheck: "false" });
189
+ this.#search.addEventListener("input", () => this.#searchChanged());
190
+ this.#search.addEventListener("keydown", (e) => {
191
+ if (e.key === "Enter" && this.#matches.length > 0) {
192
+ this.#matchIndex = (this.#matchIndex + 1) % this.#matches.length;
193
+ this.#showMatch();
194
+ } else if (e.key === "Escape") {
195
+ this.clearSearch();
196
+ this.#search.blur();
197
+ }
198
+ });
199
+ this.#matchLabel = el("span.match");
200
+ this.#list = new VirtualList({ role: "tree", rowHeight, count: () => this.tree.rows.length,
201
+ row: (index) => this.#row(index) });
202
+ this.#list.element.addEventListener("pointerleave", () => this.#hoverRow(-1));
203
+ this.#hiddenLabel = el("span");
204
+ this.#ghostedLabel = el("span");
205
+ this.#showAll = pill({ title: "Show all", onPress: () => engine.perform(Actions.ShowAll) });
206
+ this.#unghostAll = pill({ title: "Unghost all", onPress: () => engine.perform(Actions.UnghostAll) });
207
+ this.#footer = el("div.footer", { hidden: true }, [
208
+ el("div.counts", {}, [this.#hiddenLabel, this.#ghostedLabel]),
209
+ el("div.buttons", {}, [this.#showAll, this.#unghostAll]),
210
+ ]);
211
+ this.#nothing = el("p.nothing", { hidden: true, text: "Nothing open yet." });
212
+ this.element = el("section.card.structure", { "aria-label": "Structure" }, [
213
+ this.switcher.element,
214
+ this.#nothing,
215
+ el("label.searchbox", {}, [el("span.glyph", { html: icons.search(14) }), this.#search, this.#matchLabel]),
216
+ this.#list.element,
217
+ this.#footer,
218
+ ]);
219
+ }
220
+
221
+ // With nothing open, the card says so in place of the search and the tree.
222
+ showNothing(nothing) {
223
+ this.#nothing.hidden = !nothing;
224
+ for (const part of this.element.querySelectorAll(".searchbox, .tree")) part.hidden = nothing;
225
+ }
226
+
227
+ reset() {
228
+ this.tree.reset();
229
+ this.#states = [];
230
+ this.#selected = null;
231
+ this.clearSearch();
232
+ this.#render();
233
+ }
234
+
235
+ addDocument(name) {
236
+ this.tree.addDocument(name);
237
+ this.#render();
238
+ }
239
+
240
+ modelReady(document) {
241
+ const model = this.#engine.model(document);
242
+ if (model) this.tree.setModel(document, model);
243
+ this.#states[document] = this.#engine.nodeStates(document);
244
+ this.#render();
245
+ }
246
+
247
+ // The selection's focus, from the engine: revealed and marked, or nothing.
248
+ reveal(document, node) {
249
+ if (node === kNoNode) {
250
+ this.#selected = null;
251
+ this.#render();
252
+ return;
253
+ }
254
+ this.#selected = { document, node };
255
+ const index = this.tree.reveal(document, node);
256
+ if (index >= 0) this.#scrollToRow(index);
257
+ this.#render();
258
+ }
259
+
260
+ // The part under the pointer in the view.
261
+ highlight(document, node) {
262
+ this.#highlighted = node === kNoNode ? null : { document, node };
263
+ this.#schedule();
264
+ }
265
+
266
+ visibilityChanged(hidden, ghosted) {
267
+ this.tree.documents.forEach((_, document) => {
268
+ this.#states[document] = this.#engine.nodeStates(document);
269
+ });
270
+ this.#hiddenLabel.textContent = hidden > 0 ? `${hidden.toLocaleString("en-US")} ${hidden === 1 ? "body" : "bodies"} hidden` : "";
271
+ this.#ghostedLabel.textContent = ghosted > 0 ? `${ghosted.toLocaleString("en-US")} ${ghosted === 1 ? "body" : "bodies"} ghosted` : "";
272
+ this.#showAll.hidden = hidden === 0;
273
+ this.#unghostAll.hidden = ghosted === 0;
274
+ this.#footer.hidden = hidden === 0 && ghosted === 0;
275
+ this.#schedule();
276
+ }
277
+
278
+ looksChanged() {
279
+ this.#swatches = this.#engine.swatches();
280
+ this.#schedule();
281
+ }
282
+
283
+ clearSearch() {
284
+ this.#search.value = "";
285
+ this.#matches = [];
286
+ this.#matchLabel.textContent = "";
287
+ }
288
+
289
+ focusSearch() { this.#search.focus(); }
290
+
291
+ // ---- Search ------------------------------------------------------------------------------
292
+
293
+ #searchChanged() {
294
+ const query = this.#search.value.trim().toLowerCase();
295
+ this.#matches = [];
296
+ this.#matchIndex = 0;
297
+ if (!query) {
298
+ this.#matchLabel.textContent = "";
299
+ return;
300
+ }
301
+ this.tree.documents.forEach((d, document) => {
302
+ d.model?.forEach((n, node) => {
303
+ if (n.name.toLowerCase().includes(query)) this.#matches.push({ document, node });
304
+ });
305
+ });
306
+ this.#showMatch();
307
+ }
308
+
309
+ #showMatch() {
310
+ const total = this.#matches.length;
311
+ this.#matchLabel.textContent = total === 0 ? "no match" : `${this.#matchIndex + 1} of ${total}`;
312
+ const match = this.#matches[this.#matchIndex];
313
+ if (!match) return;
314
+ this.#selected = match;
315
+ const index = this.tree.reveal(match.document, match.node);
316
+ if (index >= 0) this.#scrollToRow(index);
317
+ this.#render();
318
+ }
319
+
320
+ // ---- Rows --------------------------------------------------------------------------------
321
+
322
+ #scrollToRow(index) { this.#list.scrollToRow(index); }
323
+ #schedule() { this.#list.schedule(); }
324
+ #render() { this.#list.render(); }
325
+
326
+ #state(row) {
327
+ if (row.node === kDocumentRow) return null;
328
+ return this.#states[row.document]?.[row.node] ?? 0;
329
+ }
330
+
331
+ #row(index) {
332
+ const row = this.tree.rows[index];
333
+ const isDocument = row.node === kDocumentRow;
334
+ const state = this.#state(row);
335
+ const d = this.tree.documents[row.document];
336
+ const kind = isDocument ? null : d.model?.[row.node]?.kind;
337
+ const element = el("div.row", { role: "treeitem", "data-index": index });
338
+ element.style.paddingLeft = `${8 + row.depth * kIndent}px`;
339
+ const selected = this.#selected && this.#selected.document === row.document &&
340
+ this.#selected.node === row.node;
341
+ const highlighted = this.#highlighted && !isDocument &&
342
+ this.#highlighted.document === row.document && this.#highlighted.node === row.node;
343
+ element.classList.toggle("selected", Boolean(selected));
344
+ element.classList.toggle("highlighted", Boolean(highlighted) || index === this.#pointerRow);
345
+ element.classList.toggle("hidden-node", state === 1);
346
+ element.classList.toggle("ghosted-node", state === 2);
347
+
348
+ if (this.tree.hasChildren(row)) {
349
+ const chevron = el("button.chevron", { type: "button", "aria-label": "Expand" }, [iconNode("chevron", 12)]);
350
+ chevron.classList.toggle("open", this.tree.isExpanded(row));
351
+ chevron.addEventListener("click", (e) => {
352
+ e.stopPropagation();
353
+ this.tree.toggle(row);
354
+ this.#render();
355
+ });
356
+ element.append(chevron);
357
+ } else {
358
+ element.append(el("span.chevron"));
359
+ }
360
+ if (isDocument) {
361
+ element.append(el("span.glyph", {}, [iconNode("file", 14)]));
362
+ } else if (kind === NodeKinds.Assembly) {
363
+ element.append(el("span.glyph", {}, [iconNode("assembly", 16)]));
364
+ } else {
365
+ const swatch = el(`span.swatch${kind === NodeKinds.Body ? ".small" : ""}`);
366
+ const chip = this.#chipOf(row);
367
+ if (chip !== null) swatch.style.backgroundImage = chip;
368
+ element.append(swatch);
369
+ }
370
+ element.append(el("span.name", { text: this.tree.name(row.document, row.node) }));
371
+ if (!isDocument) {
372
+ const ghosted = state === 2, hidden = state === 1;
373
+ const actions = el("span.actions", {}, [
374
+ this.#rowButton(ghosted ? "Unghost" : "Ghost", "ghost", ghosted, () =>
375
+ this.#engine.nodeAction(row.document, row.node, ghosted ? NodeActions.Unghost : NodeActions.Ghost)),
376
+ this.#rowButton(hidden ? "Show" : "Hide", hidden ? "eyeSlash" : "eye", hidden, () =>
377
+ this.#engine.nodeAction(row.document, row.node, hidden ? NodeActions.Show : NodeActions.Hide)),
378
+ this.#rowButton("Show only this, framed", "scope", false, () =>
379
+ this.#engine.nodeAction(row.document, row.node, NodeActions.Isolate)),
380
+ ]);
381
+ element.append(actions);
382
+ }
383
+ const mark = state === 1 ? "eyeSlash" : state === 2 ? "ghost" : state === 3 ? "seeThrough" : null;
384
+ if (mark) element.append(el("span.mark", {}, [iconNode(mark, 12)]));
385
+ else {
386
+ const count = this.tree.count(row);
387
+ if (count) element.append(el("span.count", { text: count }));
388
+ }
389
+
390
+ element.addEventListener("click", (e) => {
391
+ if (isDocument) {
392
+ this.tree.toggle(row);
393
+ this.#render();
394
+ return;
395
+ }
396
+ this.#selected = { document: row.document, node: row.node };
397
+ this.#engine.selectNode(row.document, row.node, e.shiftKey || e.metaKey || e.ctrlKey);
398
+ this.#render();
399
+ });
400
+ element.addEventListener("pointerenter", () => this.#hoverRow(index));
401
+ return element;
402
+ }
403
+
404
+ #rowButton(help, icon, on, press) {
405
+ const button = el(`button.rowbutton${on ? ".on" : ""}`, { type: "button", title: help,
406
+ "aria-label": help }, [iconNode(icon, 13)]);
407
+ button.addEventListener("click", (e) => {
408
+ e.stopPropagation();
409
+ press();
410
+ });
411
+ return button;
412
+ }
413
+
414
+ #hoverRow(index) {
415
+ if (index === this.#pointerRow) return;
416
+ this.#pointerRow = index;
417
+ const row = this.tree.rows[index];
418
+ if (!row || row.node === kDocumentRow) this.#engine.hoverNode(0, kNoNode);
419
+ else this.#engine.hoverNode(row.document, row.node);
420
+ }
421
+
422
+ // The colour the part is drawn in, from its first occurrence's swatch, as its chip: the colour,
423
+ // and past a diagonal 55% of the way across, the colour 16% toward ink.
424
+ #chipOf(row) {
425
+ const d = this.tree.documents[row.document];
426
+ const n = d.model?.[row.node];
427
+ if (!n || n.occurrenceEnd <= n.firstOccurrence) return null;
428
+ const base = this.#swatches.bases[row.document];
429
+ const color = base === undefined ? 0 : this.#swatches.colors[base + n.firstOccurrence] ?? 0;
430
+ if (color === 0) return null;
431
+ const rgb = [color & 255, (color >>> 8) & 255, (color >>> 16) & 255];
432
+ const shade = rgb.map((c, i) => Math.round(c * 0.84 + kInk[i] * 0.16));
433
+ return `linear-gradient(to bottom right, rgb(${rgb}) 55%, rgb(${shade}) 55%)`;
434
+ }
435
+ }
@@ -0,0 +1,49 @@
1
+ // Which of the toolbar's tools fold into its More menu, when the toolbar is wider than its space:
2
+ // the least used first, a rank at a time, until it fits. Pure, so it can be tested; the Mac's
3
+ // Toolbar.swift folds by the same rule (foldPlan there).
4
+
5
+ // The order tools fold in: lower first; tools of a rank fold together. Tools not named never fold.
6
+ export const kFoldRanks = Object.freeze({
7
+ shortcuts: 0, projection: 1, views: 2, fit: 3, open: 4, connect: 4, look: 5, section: 6,
8
+ seeThrough: 7, isolate: 8, ghost: 9, hide: 9, undo: 10, redo: 10, showAll: 11,
9
+ });
10
+
11
+ // The toolbar's measures, as drawn: a card's inset each side, the gap between items, a divider's
12
+ // width, the More button's.
13
+ export const kToolbarMeasures = Object.freeze({ inset: 10, gap: 8, rule: 1.5, more: 30 });
14
+
15
+ // `items`, in toolbar order: { key, width, group }, width 0 for a tool not shown at all (another
16
+ // platform's, say). Groups are divided by rules, drawn only between groups with something showing;
17
+ // More goes last, in the last group. Returns the keys to fold, a Set, empty if all fit.
18
+ export function foldPlan(items, available, measures = kToolbarMeasures) {
19
+ const ranks = [...new Set(items.filter((i) => i.width > 0 && i.key in kFoldRanks)
20
+ .map((i) => kFoldRanks[i.key]))].sort((a, b) => a - b);
21
+ let folded = new Set();
22
+ for (let n = 0; n <= ranks.length; ++n) {
23
+ folded = new Set(n === 0 ? [] : items.filter((i) => i.key in kFoldRanks && kFoldRanks[i.key] <= ranks[n - 1])
24
+ .map((i) => i.key));
25
+ if (widthOf(items, folded, measures) <= available) break;
26
+ }
27
+ return folded;
28
+ }
29
+
30
+ // The toolbar's width with `folded` folded, More included if anything is.
31
+ export function widthOf(items, folded, measures = kToolbarMeasures) {
32
+ const shown = items.filter((i) => i.width > 0 && !folded.has(i.key));
33
+ const lastGroup = items.length > 0 ? items[items.length - 1].group : 0;
34
+ if (folded.size > 0) shown.push({ key: "more", width: measures.more, group: lastGroup });
35
+ const groups = new Set(shown.map((i) => i.group)).size;
36
+ const rules = Math.max(0, groups - 1);
37
+ const count = shown.length + rules;
38
+ return 2 * measures.inset + shown.reduce((sum, i) => sum + i.width, 0) + rules * measures.rule +
39
+ Math.max(0, count - 1) * measures.gap;
40
+ }
41
+
42
+ // Whether the rule after group `group` shows: only between groups that both have something showing.
43
+ export function ruleShows(items, folded, group) {
44
+ const showing = (g) => items.some((i) => i.group === g && i.width > 0 && !folded.has(i.key)) ||
45
+ (folded.size > 0 && g === items[items.length - 1].group);
46
+ const groups = [...new Set(items.map((i) => i.group))];
47
+ const at = groups.indexOf(group);
48
+ return at >= 0 && showing(group) && groups.slice(at + 1).some(showing);
49
+ }
package/web/ui.js ADDED
@@ -0,0 +1,134 @@
1
+ // The chrome's controls, after the Mac app's Design folder: sticker cards, pill buttons,
2
+ // segmented pills and a slider, as plain elements styled by viewer.css.
3
+
4
+ // An element: `el("div.card.look", { title: "…", onclick }, [children])`.
5
+ export function el(spec, attributes = {}, children = []) {
6
+ const [tag, ...classes] = spec.split(".");
7
+ const node = document.createElement(tag || "div");
8
+ if (classes.length) node.className = classes.join(" ");
9
+ for (const [key, value] of Object.entries(attributes)) {
10
+ if (value === undefined || value === null || value === false) continue;
11
+ if (key.startsWith("on")) node.addEventListener(key.slice(2), value);
12
+ else if (key === "html") node.innerHTML = value;
13
+ else if (key === "text") node.textContent = value;
14
+ else node.setAttribute(key, value === true ? "" : value);
15
+ }
16
+ for (const child of [].concat(children)) {
17
+ if (child !== null && child !== undefined && child !== false) {
18
+ node.append(child instanceof Node ? child : document.createTextNode(String(child)));
19
+ }
20
+ }
21
+ return node;
22
+ }
23
+
24
+ // A rounded button with a title or an icon. `style` is "plain" or "ghostly".
25
+ export function pill({ title, icon, help, style = "plain", onPress }) {
26
+ const button = el(`button.pill.${style}${icon && !title ? ".icon" : ""}`, {
27
+ type: "button", title: help, "aria-label": help ?? title, onclick: () => onPress?.(),
28
+ });
29
+ if (icon) button.insertAdjacentHTML("beforeend", icon(15));
30
+ if (title) button.append(el("span", { text: title }));
31
+ return button;
32
+ }
33
+
34
+ // A row of choices, one selected: { element, selected (get/set), enabled (set) }.
35
+ export function segmented(titles, { selected = 0, help, tips = [], onChange } = {}) {
36
+ const element = el("div.segmented", { role: "radiogroup", title: help, "aria-label": help });
37
+ let current = selected;
38
+ const buttons = titles.map((title, index) => el("button", {
39
+ type: "button", role: "radio", title: tips[index], text: title,
40
+ onclick: () => {
41
+ if (index === current) return;
42
+ control.selected = index;
43
+ onChange?.(index);
44
+ },
45
+ }));
46
+ element.append(...buttons);
47
+ const control = {
48
+ element,
49
+ get selected() { return current; },
50
+ set selected(index) {
51
+ current = index;
52
+ buttons.forEach((button, i) => {
53
+ button.classList.toggle("on", i === index);
54
+ button.setAttribute("aria-checked", i === index ? "true" : "false");
55
+ });
56
+ },
57
+ set enabled(enabled) { element.classList.toggle("disabled", !enabled); },
58
+ };
59
+ control.selected = selected;
60
+ return control;
61
+ }
62
+
63
+ // A slider from 0 to 1: { element, value (get/set), enabled (set) }.
64
+ export function slider({ help, onChange }) {
65
+ const input = el("input.slider", { type: "range", min: 0, max: 1, step: 0.001, title: help,
66
+ "aria-label": help });
67
+ input.addEventListener("input", () => onChange?.(Number(input.value)));
68
+ return {
69
+ element: input,
70
+ get value() { return Number(input.value); },
71
+ set value(v) { input.value = String(v); },
72
+ set enabled(enabled) { input.disabled = !enabled; },
73
+ };
74
+ }
75
+
76
+ // A key cap, as the shortcuts sheet and the hold hint show keys.
77
+ export const keyCap = (text) => el("kbd.cap", { text });
78
+
79
+ export const formatNumber = (value) => value.toLocaleString("en-US");
80
+
81
+ // A scrolling list that makes elements only for the rows in view and a few either side, so a list
82
+ // of a million rows scrolls as one of ten. `count()` and `row(index)` give the rows and
83
+ // `rowHeight()` their height; `render()` after they change, or `schedule()` to render once at the
84
+ // next frame, as scrolling and resizing do.
85
+ export class VirtualList {
86
+ #spacer = el("div.spacer");
87
+ #rows;
88
+ #pending = false;
89
+ #count;
90
+ #row;
91
+ #rowHeight;
92
+ element;
93
+
94
+ constructor({ role, count, row, rowHeight }) {
95
+ this.#count = count;
96
+ this.#row = row;
97
+ this.#rowHeight = rowHeight;
98
+ this.#rows = el("div.rows", { role });
99
+ this.element = el("div.tree", {}, [this.#spacer, this.#rows]);
100
+ this.element.addEventListener("scroll", () => this.schedule());
101
+ new ResizeObserver(() => this.schedule()).observe(this.element);
102
+ }
103
+
104
+ schedule() {
105
+ if (this.#pending) return;
106
+ this.#pending = true;
107
+ requestAnimationFrame(() => {
108
+ this.#pending = false;
109
+ this.render();
110
+ });
111
+ }
112
+
113
+ render() {
114
+ const count = this.#count();
115
+ const height = this.#rowHeight();
116
+ this.#spacer.style.height = `${count * height}px`;
117
+ const first = Math.max(0, Math.floor(this.element.scrollTop / height) - 4);
118
+ const last = Math.min(count, first + Math.ceil(this.element.clientHeight / height) + 8);
119
+ this.#rows.style.transform = `translateY(${first * height}px)`;
120
+ const children = [];
121
+ for (let index = first; index < last; ++index) children.push(this.#row(index));
122
+ this.#rows.replaceChildren(...children);
123
+ }
124
+
125
+ // Scrolls `index` into view, a third of the way down, if it is out of view.
126
+ scrollToRow(index) {
127
+ const height = this.#rowHeight();
128
+ const top = index * height;
129
+ const view = this.element.clientHeight;
130
+ if (top < this.element.scrollTop || top + height > this.element.scrollTop + view) {
131
+ this.element.scrollTop = Math.max(0, top - view / 3);
132
+ }
133
+ }
134
+ }