@smartmemory/wiki-components 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +23 -3
  2. package/package.json +26 -4
  3. package/src/Ledger.jsx +47 -0
  4. package/src/data-client.d.ts +40 -1
  5. package/src/data-client.jsx +5 -0
  6. package/src/graph/components/wiki/DocCard.jsx +46 -0
  7. package/src/graph/components/wiki/GraphBreadcrumb.jsx +23 -0
  8. package/src/graph/components/wiki/GraphCanvas.jsx +663 -0
  9. package/src/graph/components/wiki/GraphCanvas3D.jsx +1366 -0
  10. package/src/graph/components/wiki/GraphDocPane.jsx +56 -0
  11. package/src/graph/components/wiki/GraphLegend.jsx +119 -0
  12. package/src/graph/components/wiki/GraphLevelPicker.jsx +35 -0
  13. package/src/graph/components/wiki/graph/FilterChips.jsx +45 -0
  14. package/src/graph/components/wiki/graph/Omnibox.jsx +74 -0
  15. package/src/graph/components/wiki/graph/badges.js +46 -0
  16. package/src/graph/components/wiki/graph/bubbles3d.js +95 -0
  17. package/src/graph/components/wiki/graph/canvasBase.js +138 -0
  18. package/src/graph/components/wiki/graph/containment.js +49 -0
  19. package/src/graph/components/wiki/graph/overlay.js +134 -0
  20. package/src/graph/components/wiki/graph/pixiBase.js +210 -0
  21. package/src/graph/components/wiki/graph/threeBase.js +231 -0
  22. package/src/graph/hooks/useDebounced.js +11 -0
  23. package/src/graph/host.jsx +56 -0
  24. package/src/graph/index.d.ts +26 -0
  25. package/src/graph/index.js +4 -0
  26. package/src/graph/lib/areaFamilies.js +32 -0
  27. package/src/graph/lib/camFollow.js +30 -0
  28. package/src/graph/lib/canvasPort.js +93 -0
  29. package/src/graph/lib/cardVisibility.js +58 -0
  30. package/src/graph/lib/docCard.js +28 -0
  31. package/src/graph/lib/forceWorker.js +123 -0
  32. package/src/graph/lib/forceWorker3d.js +136 -0
  33. package/src/graph/lib/graph3d.js +330 -0
  34. package/src/graph/lib/graphArrows.js +108 -0
  35. package/src/graph/lib/graphExplore.js +546 -0
  36. package/src/graph/lib/graphFind.js +46 -0
  37. package/src/graph/lib/graphLasso.js +44 -0
  38. package/src/graph/lib/graphLevels.js +367 -0
  39. package/src/graph/lib/graphOrient.js +145 -0
  40. package/src/graph/lib/graphPath.js +86 -0
  41. package/src/graph/lib/graphPin.js +48 -0
  42. package/src/graph/lib/graphPress.js +27 -0
  43. package/src/graph/lib/graphShell.js +296 -0
  44. package/src/graph/lib/graphTheme.js +50 -0
  45. package/src/graph/lib/graphView.js +343 -0
  46. package/src/graph/lib/graphViews.js +103 -0
  47. package/src/graph/lib/groupBubbles.js +128 -0
  48. package/src/graph/lib/hubLines.js +51 -0
  49. package/src/graph/lib/idleTurn.js +31 -0
  50. package/src/graph/lib/labelDepth.js +59 -0
  51. package/src/graph/lib/layoutClient.js +69 -0
  52. package/src/graph/lib/layoutEngine.js +184 -0
  53. package/src/graph/lib/linkWeight.js +67 -0
  54. package/src/graph/lib/orphanPull.js +10 -0
  55. package/src/graph/lib/perf.js +14 -0
  56. package/src/graph/lib/reducedMotion.js +36 -0
  57. package/src/graph/lib/robustFit.js +62 -0
  58. package/src/graph/lib/wikiGraph.js +298 -0
  59. package/src/graph/pages/GraphPage.jsx +703 -0
  60. package/src/index.d.ts +11 -2
  61. package/src/index.js +4 -1
  62. package/src/lib.js +3 -0
  63. package/src/parts/LedgerTimeline.jsx +20 -0
  64. package/src/parts/Markdown.jsx +22 -0
@@ -0,0 +1,56 @@
1
+ import React, { createContext, useContext, useEffect } from "react";
2
+ import { useClientCall, useDataClient } from "../data-client";
3
+
4
+ // COMPOSE-WIKI-GRAPH-1a: what the moved Graph files took from the wiki app, its router, its query layer and the design
5
+ // system, under the same names and call shapes. The host (the wiki app's route wrapper, or Compose) gives the values once,
6
+ // through <GraphHost value={...}>; the files import these names from here instead of from the app.
7
+ // value: { workspace, useSearchParams, Link, useColorMode, requestTreeSync, aspectHint }
8
+ const Ctx = createContext(null);
9
+ let treeSync = null; // the mounted host's requestTreeSync: a plain function called from event handlers, outside React
10
+
11
+ export function GraphHost({ value, children }) {
12
+ useEffect(() => {
13
+ treeSync = value.requestTreeSync || null;
14
+ return () => { if (treeSync === (value.requestTreeSync || null)) treeSync = null; };
15
+ }, [value.requestTreeSync]);
16
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
17
+ }
18
+
19
+ function useHost() {
20
+ const host = useContext(Ctx);
21
+ if (!host) throw new Error("the wiki graph needs a <GraphHost value={...}> above it");
22
+ return host;
23
+ }
24
+
25
+ export const useWiki = () => ({ workspace: useHost().workspace });
26
+ export const useSearchParams = () => useHost().useSearchParams();
27
+ export const useColorMode = () => useHost().useColorMode();
28
+ export const useAspectHint = () => useHost().aspectHint;
29
+ export function Link(props) {
30
+ const HostLink = useHost().Link;
31
+ return <HostLink {...props} />;
32
+ }
33
+ /** Ask the host's folder tree to show a doc; nothing happens when the host has no tree. */
34
+ export const requestTreeSync = (req) => { if (treeSync) treeSync(req); };
35
+
36
+ /** react-query's `useQuery` for the one shape the graph uses: `{data, error, isLoading}`. `staleTime` is the host client's. */
37
+ export function useQuery({ queryKey, queryFn }) {
38
+ return useClientCall(queryKey, queryFn);
39
+ }
40
+
41
+ /** One note with its text, through the DataClient. */
42
+ export function useNote(workspace, id, { enabled = true } = {}) {
43
+ const client = useDataClient();
44
+ return useClientCall(["note", workspace, id], () => client.note({ workspace, id }), { enabled: enabled && !!id && !!workspace });
45
+ }
46
+
47
+ // Copied unchanged from ui/src/lib/treeSync.js (the app keeps its own copy for FolderTree).
48
+ const segmentsOf = (path) => String(path || "").split("/").filter(Boolean);
49
+
50
+ /** A doc of the graph that sits in `folder` or under it (the first by order), or null: a folder node has no source of its own,
51
+ * so the tree asks about one of its docs and reads the source from that note. `nodes` are the flat graph's `{id, folder, kind?}`. */
52
+ export function docUnder(nodes, folder) {
53
+ const f = segmentsOf(folder).join("/");
54
+ const hit = nodes.find((n) => n.kind !== "folder" && (f === "" ? true : n.folder === f || String(n.folder || "").startsWith(`${f}/`)));
55
+ return hit ? hit.id : null;
56
+ }
@@ -0,0 +1,26 @@
1
+ // Types for @smartmemory/wiki-components/graph. The DataClient calls the Graph makes: graphAll, graphPage, note (../data-client.d.ts).
2
+ import type { ComponentType, ReactElement, ReactNode } from "react";
3
+ import type { DataClient, GraphResult, Workspace } from "../data-client";
4
+
5
+ /** What the Graph takes from its host, given once through <GraphHost value={...}>. */
6
+ export interface GraphHostValue {
7
+ workspace: Workspace;
8
+ /** react-router's `useSearchParams` (or any hook returning `[URLSearchParams, setter]`): the graph keeps its whole view state in the URL. */
9
+ useSearchParams: () => [URLSearchParams, (next: URLSearchParams | ((prev: URLSearchParams) => URLSearchParams), opts?: { replace?: boolean }) => void];
10
+ /** A link component taking `to` (the host's own route). */
11
+ Link: ComponentType<{ to: string; className?: string; children?: ReactNode }>;
12
+ /** The design system's `useColorMode`: `{ resolved: "light" | "dark" }`. */
13
+ useColorMode: () => { resolved: string };
14
+ /** Ask the host's folder tree to show a doc (or a folder of the doc's source). Optional. */
15
+ requestTreeSync?: (req: { id: string; folder?: string | null }) => void;
16
+ /** The words for the greyed "aspect" options. */
17
+ aspectHint: string;
18
+ }
19
+
20
+ export function GraphHost(props: { value: GraphHostValue; children?: ReactNode }): ReactElement;
21
+ /** The Graph screen. Needs a <WikiDataProvider> and a <GraphHost> above it. */
22
+ export const GraphPage: () => ReactElement;
23
+ export const GRAPH_ASPECTS: string[];
24
+ export function resetWebGL2Probe(): void;
25
+ export function wholeGraph(workspace: Workspace, client: DataClient): Promise<{ items: Record<string, unknown>[]; edges: Record<string, unknown>[]; total: number; layout: unknown }>;
26
+ export type { GraphResult };
@@ -0,0 +1,4 @@
1
+ // @smartmemory/wiki-components/graph (COMPOSE-WIKI-GRAPH-1a): the wiki Graph screen, moved as it was. Its own entry so a host
2
+ // that never draws it does not load it (d3-force, d3-quadtree, react-markdown; pixi.js and three load lazily from inside).
3
+ export { default as GraphPage, GRAPH_ASPECTS, resetWebGL2Probe, wholeGraph } from "./pages/GraphPage";
4
+ export { GraphHost } from "./host";
@@ -0,0 +1,32 @@
1
+ // WIKI-GRAPH-AREA-FAMILIES-1: "Colour by: area" rolls the ~240 areas (feature-id prefixes, classifier_rules `_areas`) up into a few
2
+ // FAMILIES, one palette colour each, so the legend and the graph stay readable. Adding a prefix is one word in one list below.
3
+ // Order matters: a family's position is its PALETTE index, so a family keeps its colour in every graph. At most PALETTE.length families.
4
+ // An area in no list is UNMAPPED (its own legend row and swatch, never the grey): folder-derived containers that exist in several repos
5
+ // (root, docs, architecture, features, evidence, smartmemory) say nothing about a family and stay there on purpose.
6
+ export const UNMAPPED = "unmapped"; // the family name an area in no list gets
7
+
8
+ export const AREA_FAMILIES = {
9
+ // memory engine: store, graph, retrieval, ingestion, consolidation, the 2.0 alignment and local runtime
10
+ "core engine": ["core", "onto", "vec", "search", "memory", "graph", "falkor", "ingest", "pipe", "recall", "sleep", "self", "recon", "decision", "full", "sm2", "fwd", "code", "local", "mem", "decisions", "implicit", "zettel", "meta", "lite"],
11
+ // service and platform: API, accounts, tenancy, auth, billing, secrets, MCP, connectors, governance, observability
12
+ "service & platform": ["svc", "plat", "ws", "session", "api", "common", "auth", "clerk", "tenant", "bill", "secret", "mcp", "tier", "byoc", "conn", "gov", "obs", "admin", "scope", "sec", "secrets", "adm", "byok", "idp", "cross", "workspace"],
13
+ // apps and UI: web, studio, Maya, hub, insights, SDKs and clients, demos, video
14
+ "apps & UI": ["web", "studio", "maya", "client", "sdk", "ui", "hub", "insights", "vis", "demo", "video", "app", "cli", "ask", "wrapper", "render", "ds", "tour", "catalog"],
15
+ // the wiki itself, its sync and captures
16
+ wiki: ["wiki", "sync", "capture"],
17
+ // ops and process: units, batches, ledgers, status, tooling, deploys, landing, tests, follow-ups, tech debt, plus the bug, plan and archive
18
+ // folders (those exist per repo and hold work records, so they roll up here)
19
+ "ops & process": ["units", "batch", "ops", "fu", "status", "coordination", "claude", "deps", "infra", "deploy", "land", "e2e", "tick", "cc", "refactor", "tech", "test", "max", "compose", "stamps", "report", "coord", "crash", "port", "testsem", "us1", "rel", "stamp", "gate", "job", "seat", "kill", "load", "night", "nightly", "reboot", "sigkill", "statusline", "wake", "dry", "tab", "medium", "ultra", "techdebt", "prepush", "queue", "queues", "seat2", "detach", "jobwatch", "wait", "landed", "daemon", "quota", "retro", "estimate", "tools", "auto", "chain", "pre", "codex", "forward", "time", "fable", "gap", "error", "overlap", "briefs", "roadmap", "arch", "bugs", "plans", "archive"],
20
+ // bench and research: benchmarks, spikes, metrics, model A/B
21
+ "bench & research": ["bench", "spike", "metrics", "comp", "locomo"],
22
+ // go-to-market and product: distribution, launch, onboarding, support, pricing, grants, strategy
23
+ "go-to-market": ["gtm", "marketing", "product", "launch", "dist", "support", "onboarding", "upgrade", "strat", "grant", "dev", "cofounder", "beta", "interview", "enterprise", "soc2", "raindrop", "content", "prospect", "competitor", "yc", "onboard"],
24
+ };
25
+
26
+ /** area name -> family name, built once from the table; the first list that names an area wins (a completeness test forbids a second). */
27
+ const FAMILY_OF = new Map(Object.entries(AREA_FAMILIES).flatMap(([family, areas]) => areas.map((a) => [a, family])));
28
+
29
+ /** The family an area belongs to, or UNMAPPED. */
30
+ export function familyOf(area) {
31
+ return FAMILY_OF.get(area) ?? UNMAPPED;
32
+ }
@@ -0,0 +1,30 @@
1
+ // WIKI-GRAPH-DRIFT-LONG-EDGES-1: when a layout may move the camera, one rule for both canvases (GraphCanvas 2D, GraphCanvas3D).
2
+ // Before it, both refit the camera on EVERY worker tick (and 3D every 150 ms of a glide) until the reader moved it, so the view
3
+ // zoomed and slid for the whole settle and again after any reheat: the owner's "zoom drift".
4
+ //
5
+ // Now a view (a node set, and in 3D its cluster/focus/hops state) gets a follow budget: while its first layout runs the camera
6
+ // refits at most FOLLOW_MAX times, FOLLOW_MS apart (a cold layout grows out of its seed frame, and without a few early refits the
7
+ // graph sits half off screen for the whole settle), and once when that layout settles (or its glide lands). After that ticks never
8
+ // move the camera: a reheat, a slider, a pin or unpin, new weights all leave it where it is. Explicit refits (the fit button,
9
+ // fitTo, a focus, a level or view change, a resize, a click on empty space) call placeView directly and are not counted here.
10
+ export const FOLLOW_MAX = 3;
11
+ export const FOLLOW_MS = 1000;
12
+
13
+ /** The follow state for view `key`: `prev` when it is the same view (a reheat keeps a spent budget spent), else a fresh budget. */
14
+ export function followFor(prev, key) {
15
+ return prev && prev.key === key ? prev : { key, fits: 0, at: -Infinity, done: false };
16
+ }
17
+
18
+ /** A spent follow state for view `key`: a view that is already final (no layout ran, or no worker) never follows. */
19
+ export const followDone = (key) => ({ key, fits: FOLLOW_MAX, at: -Infinity, done: true });
20
+
21
+ /** Whether a layout `event` ("tick": a frame, "settled": the layout or its glide came to rest) refits the camera now, at `now` ms.
22
+ * Never after a user move or once the view's first layout has settled. Updates `f`. */
23
+ export function follows(f, event, now, userMoved) {
24
+ if (!f || f.done) return false;
25
+ if (event === "settled") { f.done = true; return !userMoved; }
26
+ if (userMoved || f.fits >= FOLLOW_MAX || now - f.at < FOLLOW_MS) return false;
27
+ f.fits++;
28
+ f.at = now;
29
+ return true;
30
+ }
@@ -0,0 +1,93 @@
1
+ // WIKI-GRAPH-3D-PORT-1: the pure parts of the canvas port (EXPLORE-1b REPORT handoff item 4, 3D-UX-1 REPORT §2 change table),
2
+ // kept out of the canvas so the 2D canvas can call the same helpers when it gets the port: the Alt-click depth cycle, the flash
3
+ // ring, the hover-card placement, the pivot (orbit about a point that is not the controls' target), the zoom step and the
4
+ // screen-position list the page reads (`screenPositions`). Nothing here touches the DOM or three.
5
+
6
+ export const FLASH_MS = 900; // `flash(id)`: an expanding ring for ~900 ms (1b handoff item 4)
7
+ export const FLASH_GROW_PX = 28;
8
+ export const CARD_GAP = 12; // the hover card sits 12 px below-right of the dot (3D-UX-1 §2 "hover card")
9
+
10
+ const ease = (t) => 1 - (1 - t) ** 3;
11
+
12
+ /** Alt-click depth cycle (3D-UX-1 §2 "occlusion": "Alt-click cycles one deeper per click"). `stack`: the node indices under the
13
+ * pointer, front to back (graph3d pickStack). `prev`: the state this returned last time, or null. The first Alt-click at a spot
14
+ * takes the hit one behind the front (a plain click already gives the front), each further Alt-click within `tol` px on the same
15
+ * stack goes one deeper and wraps to the front after the last. Returns {pick, state}; pick -1 and state null on an empty stack. */
16
+ export function depthCycle(prev, stack, x, y, tol = 3) {
17
+ if (!stack || !stack.length) return { pick: -1, state: null };
18
+ const same = !!prev && Math.hypot(x - prev.x, y - prev.y) <= tol && prev.stack.length === stack.length && prev.stack.every((v, i) => v === stack[i]);
19
+ const k = same ? (prev.k + 1) % stack.length : stack.length > 1 ? 1 : 0;
20
+ return { pick: stack[k], state: { x: same ? prev.x : x, y: same ? prev.y : y, stack: [...stack], k } };
21
+ }
22
+
23
+ /** The flash ring `t` ms after `flash(id)` round a dot of drawn radius `r` px: {radius, alpha}, or null once it is over.
24
+ * `still` (reduced motion): the same life and fade, but a constant radius (a static ring). */
25
+ export function flashRing(t, r, still = false) {
26
+ if (!(t >= 0) || t >= FLASH_MS) return null;
27
+ const u = t / FLASH_MS;
28
+ return { radius: r + 4 + (still ? 0 : FLASH_GROW_PX * ease(u)), alpha: 1 - u };
29
+ }
30
+
31
+ /** Where a `cw` x `ch` px hover card goes for a dot at (ax, ay) with drawn radius `ar` on a `w` x `h` surface: below-right with a
32
+ * CARD_GAP gap; flipped left when it would cross the right edge and up when it would cross the bottom (3D-UX-1 §2 "hover card"). */
33
+ export function placeCard(ax, ay, ar, cw, ch, w, h, gap = CARD_GAP) {
34
+ let left = ax + ar + gap, top = ay + ar + gap, flipX = false, flipY = false;
35
+ if (left + cw > w) { left = ax - ar - gap - cw; flipX = true; }
36
+ if (top + ch > h) { top = ay - ar - gap - ch; flipY = true; }
37
+ return { left, top, flipX, flipY };
38
+ }
39
+
40
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
41
+ const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
42
+ const len = (a) => Math.hypot(a[0], a[1], a[2]);
43
+ const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
44
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
45
+ /** `w` turned by `angle` about the unit axis `k` (Rodrigues). */
46
+ const turn = (w, k, angle) => {
47
+ const c = Math.cos(angle), s = Math.sin(angle), kw = cross(k, w), d = dot(k, w) * (1 - c);
48
+ return [w[0] * c + kw[0] * s + k[0] * d, w[1] * c + kw[1] * s + k[1] * d, w[2] * c + kw[2] * s + k[2] * d];
49
+ };
50
+ /** three's Spherical angles of `v` about +Y: theta = atan2(x, z), phi = the angle from +Y. */
51
+ const angles = (v) => { const r = len(v); return [Math.atan2(v[0], v[2]), Math.acos(Math.max(-1, Math.min(1, v[1] / r)))]; };
52
+
53
+ /** The pivot (3D-UX-1 §2: "the focused doc, else the centre of the visible set"). OrbitControls always turns the camera round its
54
+ * target. Given the camera and target before (`prevC`, `prevT`) and after (`C`, `T`) one of its updates, when that update was a
55
+ * pure turn (target unmoved, distance unchanged) this returns the camera and target for the same turn made round `P` instead:
56
+ * the turn is OrbitControls' own (theta about +Y, then phi about the camera's horizontal right axis), applied to the target's
57
+ * offset from P, so the look direction is OrbitControls' and P keeps its place on screen. Null when the update was not a pure
58
+ * turn (a zoom or a pan), or the turn is nil. Arrays [x, y, z] in, {c, t} out. */
59
+ export function orbitAbout(P, prevC, prevT, C, T, eps = 1e-6) {
60
+ const u = sub(prevC, prevT), v = sub(C, T), ru = len(u), rv = len(v);
61
+ if (ru < 1e-9 || len(sub(T, prevT)) > eps * Math.max(1, ru) || Math.abs(rv - ru) > eps * Math.max(1, ru)) return null;
62
+ const [tu, pu] = angles(u), [tv, pv] = angles(v);
63
+ const dTheta = tv - tu, dPhi = pv - pu;
64
+ if (Math.abs(dTheta) < 1e-12 && Math.abs(dPhi) < 1e-12) return null;
65
+ const right = cross([0, 1, 0], u), rl = len(right);
66
+ if (rl < 1e-9) return null; // straight above or below: OrbitControls never gets here (it clamps phi off the poles)
67
+ const k = [right[0] / rl, right[1] / rl, right[2] / rl];
68
+ const R = (w) => turn(turn(w, k, dPhi), [0, 1, 0], dTheta);
69
+ const t = add(P, R(sub(T, P)));
70
+ return { c: add(t, v), t };
71
+ }
72
+
73
+ /** The camera position after zooming by `f` toward the target `t` (f > 1 closer), the distance kept in [minD, maxD]. */
74
+ export function dollyBy(c, t, f, minD = 0, maxD = Infinity) {
75
+ const off = sub(c, t), d = len(off);
76
+ if (!(f > 0) || d < 1e-12) return [...c];
77
+ const nd = Math.min(maxD, Math.max(minD, d / f));
78
+ return add(t, [(off[0] * nd) / d, (off[1] * nd) / d, (off[2] * nd) / d]);
79
+ }
80
+
81
+ /** `screenPositions(ids?)` (1b handoff item 4): [{id, x, y, visible, degree, depth}] for the ids asked (every drawn node when `ids` is
82
+ * null), in the order asked; an id the canvas does not hold is skipped. `scr` holds x, y and depth (cw) per index (graph3d project;
83
+ * cw <= 0 is behind the camera, where x and y are NaN; `depth` is that cw, the view depth, so the lasso can tell behind the pivot); `visible` = drawn (`mask`), in front of the camera and on the w x h surface. */
84
+ export function screenList(ids, index, scr, mask, degree, w, h) {
85
+ const out = [];
86
+ const one = (id, i) => {
87
+ const front = scr[3 * i + 2] > 1e-6, x = front ? scr[3 * i] : NaN, y = front ? scr[3 * i + 1] : NaN;
88
+ out.push({ id, x, y, visible: !!mask?.[i] && front && x >= 0 && x <= w && y >= 0 && y <= h, degree: degree ? degree[i] : 0, depth: scr[3 * i + 2] });
89
+ };
90
+ if (ids == null) { for (const [id, i] of index) if (!mask || mask[i]) one(id, i); return out; }
91
+ for (const id of ids) { const i = index.get(id); if (i !== undefined) one(id, i); }
92
+ return out;
93
+ }
@@ -0,0 +1,58 @@
1
+ // The focused doc's card shows for a few seconds, then hides itself; Esc and an info button show/hide it (WIKI-GRAPH-CARD-AUTOHIDE-1).
2
+ // Pure rules only: the page owns the timer and the DOM.
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ export const CARD_AUTOHIDE_MS = 3000; // how long a freshly focused doc's card stays before it fades out
6
+ export const CARD_KEYS_HELP = "Esc show/hide doc info"; // the line in the 3D keys help (the gizmo title)
7
+ export const CARD_HINT = "Esc: show/hide info · click empty space: clear focus";
8
+
9
+ /** Card state for one focus: `shown`, `pinned` (shown by Esc: stays until Esc again or the focus changes), `pointerIn` (pointer over it), `focusIn` (keyboard focus inside),
10
+ * `held` (either one: the two are tracked apart so one leaving does not release the other). */
11
+ export const freshCard = (id) => ({ id, shown: true, pinned: false, held: false, pointerIn: false, focusIn: false });
12
+ const withIn = (st, o) => { const n = { ...st, ...o }; return { ...n, held: n.pointerIn || n.focusIn }; };
13
+
14
+ /** One event applied to a card state. Events: "hold" / "release" (pointer enters / leaves the card), "focusIn" / "focusOut" (keyboard focus enters / leaves it), "esc" (the toggle),
15
+ * "expire" (the auto-hide timer fired). A new focus is a new `freshCard`, not an event. */
16
+ export function cardReduce(st, ev) {
17
+ switch (ev) {
18
+ case "hold": return st.pointerIn ? st : withIn(st, { pointerIn: true });
19
+ case "release": return st.pointerIn ? withIn(st, { pointerIn: false }) : st;
20
+ case "focusIn": return st.focusIn ? st : withIn(st, { focusIn: true });
21
+ case "focusOut": return st.focusIn ? withIn(st, { focusIn: false }) : st;
22
+ case "esc": return st.shown ? { ...st, shown: false, pinned: false } : { ...st, shown: true, pinned: true };
23
+ case "expire": return st.held || st.pinned || !st.shown ? st : { ...st, shown: false };
24
+ default: return st;
25
+ }
26
+ }
27
+
28
+ /** True while the auto-hide timer should run: the card is up, nothing holds it, Esc has not pinned it. */
29
+ export const cardTimerArmed = (st, autoHide) => !!autoHide && st.shown && !st.held && !st.pinned;
30
+
31
+ /** What Esc does, one step per press, in order: a lasso selection clears, then an open pane closes, then (with a focused doc) the card toggles,
32
+ * then a level or drill steps up, then the path lens clears. Clearing the focus is not a step: it is a click on empty space or the clear control.
33
+ * `s`: { lasso, docOpen, focus, up (a level or drill step exists), pathLens } booleans. Returns the action name or null. */
34
+ export function escAction(s) {
35
+ if (s.lasso) return "clearLasso";
36
+ if (s.docOpen) return "closeDoc";
37
+ if (s.focus) return "toggleCard";
38
+ if (s.up) return "up";
39
+ if (s.pathLens) return "clearPath";
40
+ return null;
41
+ }
42
+
43
+ /** Card visibility for the focused doc `focusId`. `autoHide` false (2D: the side panel is not over the nodes) keeps it shown until Esc.
44
+ * Returns { shown, esc, hold, release, focusIn, focusOut }. Every change of focus starts a fresh 3 s, including a return to a doc focused before. */
45
+ export function useCardVisibility(focusId, autoHide) {
46
+ const [raw, setRaw] = useState(() => freshCard(focusId));
47
+ if (raw.id !== focusId) setRaw(freshCard(focusId)); // reset on the focus change itself, so a return to an earlier doc does not reuse its old hidden state
48
+ const st = raw.id === focusId ? raw : freshCard(focusId);
49
+ const send = useCallback((ev) => setRaw((r) => cardReduce(r.id === focusId ? r : freshCard(focusId), ev)), [focusId]);
50
+ const armed = !!focusId && cardTimerArmed(st, autoHide);
51
+ useEffect(() => {
52
+ if (!armed) return undefined;
53
+ const t = setTimeout(() => send("expire"), CARD_AUTOHIDE_MS);
54
+ return () => clearTimeout(t);
55
+ }, [armed, send, st.shown, st.held, st.pinned, focusId]);
56
+ return { shown: st.shown, esc: useCallback(() => send("esc"), [send]), hold: useCallback(() => send("hold"), [send]), release: useCallback(() => send("release"), [send]),
57
+ focusIn: useCallback(() => send("focusIn"), [send]), focusOut: useCallback(() => send("focusOut"), [send]) };
58
+ }
@@ -0,0 +1,28 @@
1
+ // The doc card at the focused node (WIKI-GRAPH-EXPLORE-1b-SHELL B1). Pure positioning: the page feeds it one `screenPositions` entry per
2
+ // frame and a card size, it says where the card goes or that it hides. Nothing here touches the DOM.
3
+ import { placeCard } from "./canvasPort.js";
4
+ import { drawnFor } from "./graphFind.js";
5
+
6
+ export const CARD_NODE_RADIUS = 6; // px the card stays clear of the dot (the dot's drawn radius is not in screenPositions)
7
+ export const CARD_EDGE = 8; // px the card keeps from the surface edge
8
+
9
+ /** True when `p` ({x, y, visible}) is a node on the surface: drawn, in front of the camera, finite coordinates. */
10
+ export function onScreen(p) {
11
+ return !!p && p.visible === true && Number.isFinite(p.x) && Number.isFinite(p.y);
12
+ }
13
+
14
+ /** Where a `cw` x `ch` card goes for the node `p` on a `w` x `h` surface: below-right of the dot, flipped at the right and bottom edges
15
+ * (canvasPort placeCard), then clamped inside the surface with CARD_EDGE. null = hide (no node, off-screen, behind the camera, or no surface). */
16
+ export function docCardAt(p, cw, ch, w, h) {
17
+ if (!onScreen(p) || !(w > 0) || !(h > 0)) return null;
18
+ const { left, top } = placeCard(p.x, p.y, CARD_NODE_RADIUS, cw, ch, w, h);
19
+ const clamp = (v, size, span) => Math.max(CARD_EDGE, Math.min(v, span - size - CARD_EDGE));
20
+ return { left: clamp(left, cw, w), top: clamp(top, ch, h) };
21
+ }
22
+
23
+ /** The drawn id the card hangs from: the focused doc itself when the canvas draws it, else the folder that stands for it (a level view collapses
24
+ * docs into folders: graphFind drawnFor, the helper the camera and the lit set use), else null (nothing draws it: the card stays hidden).
25
+ * `drawnIds`: the ids the canvas draws (null while the graph is not built). */
26
+ export function cardAnchor(focus, drawnIds) {
27
+ return focus && drawnIds ? drawnFor(focus, drawnIds) : null;
28
+ }
@@ -0,0 +1,123 @@
1
+ // WIKI-GRAPH-OBSIDIAN-1: the graph's d3-force layout, in a Web Worker so the main thread never runs a tick and panning
2
+ // stays smooth while the graph settles. Index-based: positions are a Float32Array [x0, y0, x1, y1, ...], links a
3
+ // Uint32Array of node index pairs, targets the per-node group centre the x/y forces pull towards (the clustering).
4
+ //
5
+ // Main -> worker: `init` {xy, links, targets, params, progressMs, maxTicks, alpha, big, radii?, pinned?, weights?}; `params` {params} (live, reheats
6
+ // to at least 0.3); `weights` {weights} (live, no reheat); `contain` {pairs, lead} (live, no reheat: the containment springs after the first `lead` links become `pairs`); `pause`; `resume`; `reheat`; `stop`. Worker -> main: `tick` {xy (transferred), alpha, ticks} every
7
+ // `progressMs` and once more when it settles; `state` {state: running|paused|settled, ticks, ms}.
8
+ // Ticks run in slices of `sliceMs`, then yield, so a message always gets through within one slice (or one tick, when a
9
+ // single tick is longer: ~600 ms at 50k docs).
10
+ // WIKI-GRAPH-P0-LEVEL0-ZOOM-1: `radii` (a level view with folders, graphView layoutRadii) makes link distance surface to
11
+ // surface and adds a collide force (up to COLLIDE_N nodes), so no two drawn circles overlap.
12
+ // WIKI-GRAPH-CLUSTER-REGROUP-1: `pinned` (optional Uint8Array, 1 per pinned node) fixes those nodes at their start position
13
+ // (d3 fx/fy), so a scoped re-layout (the focus's lit set) moves only the unpinned nodes and the dimmed rest never moves.
14
+ import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY } from "d3-force";
15
+ import { COLLIDE_N, collideRadiusFn, linkDistanceFn } from "./graphView";
16
+ import { linkStrength } from "./linkWeight";
17
+
18
+ export const ALPHA_MIN = 0.001, PARAMS_ALPHA = 0.3, REHEAT_ALPHA = 0.5;
19
+ import { ORPHAN_PULL, markFree } from "./orphanPull";
20
+ export { ORPHAN_PULL, markFree }; // r3 tests import them from here
21
+
22
+ const strengthOf = (l) => linkStrength(l.w); // WIKI-GRAPH-LEVEL-WEIGHTS-FU-1: 0.15 for a flat link, up to 0.45 for a heavy folder link
23
+
24
+ /** The message handler, with its clock and scheduler injected so the tests can drive it without a Worker. */
25
+ export function makeHandler({ post, now = () => performance.now(), defer = (f) => setTimeout(f, 0), sliceMs = 30 }) {
26
+ let sim = null, nodes = null, radii = null, linkList = null, state = "idle", ticks = 0, t0 = 0, lastPost = 0, progressMs = 100, gen = 0, pending = false;
27
+
28
+ const postState = (s) => { state = s; post({ type: "state", state: s, ticks, ms: now() - t0 }); };
29
+ const postTick = () => {
30
+ const xy = new Float32Array(2 * nodes.length);
31
+ for (let i = 0; i < nodes.length; i++) { xy[2 * i] = nodes[i].x; xy[2 * i + 1] = nodes[i].y; }
32
+ post({ type: "tick", xy, alpha: sim.alpha(), ticks }, [xy.buffer]);
33
+ };
34
+ const loop = (g) => {
35
+ if (g !== gen) return; // a newer `init` or a `stop` came in meanwhile
36
+ pending = false;
37
+ if (state !== "running") return;
38
+ const end = now() + sliceMs;
39
+ do { sim.tick(); ticks++; } while (now() < end && sim.alpha() >= ALPHA_MIN);
40
+ if (sim.alpha() < ALPHA_MIN) { postTick(); postState("settled"); return; }
41
+ if (now() - lastPost >= progressMs) { lastPost = now(); postTick(); }
42
+ schedule();
43
+ };
44
+ const schedule = () => {
45
+ if (pending) return;
46
+ pending = true;
47
+ const g = gen;
48
+ defer(() => loop(g));
49
+ };
50
+ let cur = null; // the params last applied: the centre pull is re-read when the springs change which nodes are free
51
+ const apply = (p) => {
52
+ cur = p;
53
+ sim.force("charge").strength(p.charge);
54
+ sim.force("link").distance(linkDistanceFn(p.linkDistance, radii));
55
+ const pull = (d) => (d.free ? p.centre * ORPHAN_PULL : p.centre);
56
+ sim.force("x").strength(pull);
57
+ sim.force("y").strength(pull);
58
+ };
59
+ const run = () => { if (state !== "running") postState("running"); schedule(); };
60
+
61
+ return (e) => {
62
+ const m = e.data || {};
63
+ if (m.type === "init") {
64
+ gen++; pending = false;
65
+ const { xy, links, targets, params, maxTicks = 300, alpha = 1, big = false } = m;
66
+ radii = m.radii || null; // a level view with folders: keep the spheres apart (graphView layoutRadii)
67
+ progressMs = m.progressMs ?? 100;
68
+ const n = xy.length / 2;
69
+ nodes = new Array(n);
70
+ const pinned = m.pinned || null;
71
+ for (let i = 0; i < n; i++) {
72
+ nodes[i] = { x: xy[2 * i], y: xy[2 * i + 1], tx: targets[2 * i], ty: targets[2 * i + 1] };
73
+ if (pinned && pinned[i]) { nodes[i].fx = nodes[i].x; nodes[i].fy = nodes[i].y; }
74
+ }
75
+ const ls = new Array(links.length / 2);
76
+ for (let i = 0; i < ls.length; i++) ls[i] = { source: links[2 * i], target: links[2 * i + 1], w: m.weights?.[i] ?? 1 }; // weights: one per leading link, the rest (containment springs) 1
77
+ linkList = ls;
78
+ markFree(nodes, ls);
79
+ sim = forceSimulation(nodes).stop()
80
+ .alpha(alpha).alphaMin(ALPHA_MIN).alphaDecay(1 - Math.pow(ALPHA_MIN, 1 / maxTicks))
81
+ .force("charge", forceManyBody().theta(0.9).distanceMax(big ? 220 : 400))
82
+ .force("link", forceLink(ls).strength(strengthOf))
83
+ .force("x", forceX((d) => d.tx))
84
+ .force("y", forceY((d) => d.ty));
85
+ if (radii && n <= COLLIDE_N) sim.force("collide", forceCollide(collideRadiusFn(radii)).iterations(2));
86
+ apply(params);
87
+ ticks = 0; t0 = now(); lastPost = t0; state = "idle";
88
+ if (n === 0) { postState("settled"); return; }
89
+ run();
90
+ return;
91
+ }
92
+ if (!sim) return;
93
+ if (m.type === "params") {
94
+ apply(m.params);
95
+ sim.alpha(Math.max(sim.alpha(), PARAMS_ALPHA));
96
+ if (state === "settled") run(); // a paused layout stays paused and moves with the new forces on resume
97
+ } else if (m.type === "weights") { // a same-topology refresh changed the weights: new strengths, no reheat (nothing moves until it next runs)
98
+ for (let i = 0; i < linkList.length; i++) linkList[i].w = m.weights?.[i] ?? 1;
99
+ sim.force("link").strength(strengthOf);
100
+ } else if (m.type === "contain") { // a same-topology refresh changed the containment pairs: swap the springs after the `lead` real links, no reheat
101
+ const ls = linkList.slice(0, m.lead), at = m.pairs;
102
+ for (let i = 0; i < at.length / 2; i++) ls.push({ source: at[2 * i], target: at[2 * i + 1], w: 1 });
103
+ linkList = ls;
104
+ sim.force("link").links(ls);
105
+ markFree(nodes, ls);
106
+ apply(cur);
107
+ } else if (m.type === "pause") {
108
+ if (state === "running") postState("paused");
109
+ } else if (m.type === "resume") {
110
+ if (state === "paused") run();
111
+ } else if (m.type === "reheat") {
112
+ sim.alpha(Math.max(sim.alpha(), REHEAT_ALPHA));
113
+ run();
114
+ } else if (m.type === "stop") {
115
+ gen++; pending = false; sim = null; nodes = null; radii = null; linkList = null; state = "idle";
116
+ }
117
+ };
118
+ }
119
+
120
+ /* global WorkerGlobalScope */
121
+ if (typeof WorkerGlobalScope !== "undefined" && self instanceof WorkerGlobalScope) {
122
+ self.onmessage = makeHandler({ post: (msg, transfer) => self.postMessage(msg, transfer || []) });
123
+ }
@@ -0,0 +1,136 @@
1
+ // WIKI-GRAPH-3D-1: the 3D twin of forceWorker.js. d3-force-3d in a Web Worker, so orbiting stays smooth while the graph
2
+ // settles. Same protocol and the same injected handler (tests drive it without a Worker); only the shapes differ: positions
3
+ // are [x0, y0, z0, x1, ...] and targets carry a z.
4
+ //
5
+ // Main -> worker: `init` {xyz, links, targets, params, progressMs, maxTicks, alpha, big, radii?, pinned?, weights?} (radii, pinned, weights: forceWorker.js); `params` {params} (live, reheats to
6
+ // at least 0.3); `pause`; `resume`; `reheat`; `pin` {i, x, y, z} / `unpin` {idx?} (hold a doc at a point, release some or all: fx/fy/fz); `stop`. Worker -> main: `tick` {xyz (transferred), alpha, ticks} every
7
+ // `progressMs` and once more when it settles; `state` {state: running|paused|settled, ticks, ms}.
8
+ //
9
+ // A 3D many-body tick (octree) costs ~2.5x a 2D one at the same settings (spike, units/WIKI-GRAPH-3D-1/spike-report.md), so
10
+ // the Barnes-Hut approximation is looser: theta 1.2 (3D real-sized graph settles in ~13 s instead of 24 s), and past
11
+ // HUGE_N docs theta 1.5 with a 120 cut-off (50k settles in ~54 s, not in 3 minutes). Every other force value is the 2D one.
12
+ import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY, forceZ } from "d3-force-3d";
13
+ import { COLLIDE_N, collideRadiusFn, linkDistanceFn } from "./graphView";
14
+ import { linkStrength } from "./linkWeight";
15
+ import { ORPHAN_PULL, markFree } from "./orphanPull";
16
+
17
+ export const ALPHA_MIN = 0.001, PARAMS_ALPHA = 0.3, REHEAT_ALPHA = 0.5;
18
+ export const HUGE_N = 15000;
19
+
20
+ /** The many-body settings for `n` docs: {theta, distanceMax}. `big` is the 2D view's big-graph flag (LOD_N). */
21
+ export function chargeFor(n, big) {
22
+ if (n > HUGE_N) return { theta: 1.5, distanceMax: 120 };
23
+ return { theta: 1.2, distanceMax: big ? 220 : 400 };
24
+ }
25
+
26
+ const strengthOf = (l) => linkStrength(l.w); // WIKI-GRAPH-LEVEL-WEIGHTS-FU-1: 0.15 for a flat link, up to 0.45 for a heavy folder link
27
+
28
+ /** The message handler, with its clock and scheduler injected so the tests can drive it without a Worker. */
29
+ export function makeHandler({ post, now = () => performance.now(), defer = (f) => setTimeout(f, 0), sliceMs = 30 }) {
30
+ let sim = null, nodes = null, radii = null, linkList = null, state = "idle", ticks = 0, t0 = 0, lastPost = 0, progressMs = 100, gen = 0, pending = false;
31
+
32
+ const postState = (s) => { state = s; post({ type: "state", state: s, ticks, ms: now() - t0 }); };
33
+ const postTick = () => {
34
+ const xyz = new Float32Array(3 * nodes.length);
35
+ for (let i = 0; i < nodes.length; i++) { const p = nodes[i]; xyz[3 * i] = p.x; xyz[3 * i + 1] = p.y; xyz[3 * i + 2] = p.z; }
36
+ post({ type: "tick", xyz, alpha: sim.alpha(), ticks }, [xyz.buffer]);
37
+ };
38
+ const loop = (g) => {
39
+ if (g !== gen) return; // a newer `init` or a `stop` came in meanwhile
40
+ pending = false;
41
+ if (state !== "running") return;
42
+ const end = now() + sliceMs;
43
+ do { sim.tick(); ticks++; } while (now() < end && sim.alpha() >= ALPHA_MIN);
44
+ if (sim.alpha() < ALPHA_MIN) { postTick(); postState("settled"); return; }
45
+ if (now() - lastPost >= progressMs) { lastPost = now(); postTick(); }
46
+ schedule();
47
+ };
48
+ const schedule = () => {
49
+ if (pending) return;
50
+ pending = true;
51
+ const g = gen;
52
+ defer(() => loop(g));
53
+ };
54
+ let cur = null; // the params last applied: the centre pull is re-read when the springs change which nodes are free
55
+ const apply = (p) => {
56
+ cur = p;
57
+ sim.force("charge").strength(p.charge);
58
+ sim.force("link").distance(linkDistanceFn(p.linkDistance, radii));
59
+ // WIKI-GRAPH-FIT-HIDDEN-1 r4: an unlinked doc is pulled ORPHAN_PULL x harder to its target (0 when unclustered, its group centre when clustered)
60
+ const pull = (d) => (d.free ? p.centre * ORPHAN_PULL : p.centre);
61
+ for (const f of ["x", "y", "z"]) sim.force(f).strength(pull);
62
+ };
63
+ const run = () => { if (state !== "running") postState("running"); schedule(); };
64
+
65
+ return (e) => {
66
+ const m = e.data || {};
67
+ if (m.type === "init") {
68
+ gen++; pending = false;
69
+ const { xyz, links, targets, params, maxTicks = 300, alpha = 1, big = false } = m;
70
+ radii = m.radii || null; // a level view with folders: keep the spheres apart (graphView layoutRadii)
71
+ progressMs = m.progressMs ?? 100;
72
+ const n = xyz.length / 3, pinned = m.pinned || null; // pinned: fixed at the start position (forceWorker.js)
73
+ nodes = new Array(n);
74
+ for (let i = 0; i < n; i++) {
75
+ nodes[i] = { x: xyz[3 * i], y: xyz[3 * i + 1], z: xyz[3 * i + 2], tx: targets[3 * i], ty: targets[3 * i + 1], tz: targets[3 * i + 2] };
76
+ if (pinned && pinned[i]) { nodes[i].fx = nodes[i].x; nodes[i].fy = nodes[i].y; nodes[i].fz = nodes[i].z; }
77
+ }
78
+ const ls = new Array(links.length / 2);
79
+ for (let i = 0; i < ls.length; i++) ls[i] = { source: links[2 * i], target: links[2 * i + 1], w: m.weights?.[i] ?? 1 }; // weights: one per leading link, the rest (containment springs) 1
80
+ linkList = ls;
81
+ markFree(nodes, ls);
82
+ const c = chargeFor(n, big);
83
+ sim = forceSimulation(nodes, 3).stop()
84
+ .alpha(alpha).alphaMin(ALPHA_MIN).alphaDecay(1 - Math.pow(ALPHA_MIN, 1 / maxTicks))
85
+ .force("charge", forceManyBody().theta(c.theta).distanceMax(c.distanceMax))
86
+ .force("link", forceLink(ls).strength(strengthOf))
87
+ .force("x", forceX((d) => d.tx))
88
+ .force("y", forceY((d) => d.ty))
89
+ .force("z", forceZ((d) => d.tz));
90
+ if (radii && n <= COLLIDE_N) sim.force("collide", forceCollide(collideRadiusFn(radii)).iterations(2));
91
+ apply(params);
92
+ ticks = 0; t0 = now(); lastPost = t0; state = "idle";
93
+ if (n === 0) { postState("settled"); return; }
94
+ run();
95
+ return;
96
+ }
97
+ if (!sim) return;
98
+ if (m.type === "params") {
99
+ apply(m.params);
100
+ sim.alpha(Math.max(sim.alpha(), PARAMS_ALPHA));
101
+ if (state === "settled") run(); // a paused layout stays paused and moves with the new forces on resume
102
+ } else if (m.type === "weights") { // a same-topology refresh changed the weights: new strengths, no reheat (nothing moves until it next runs)
103
+ for (let i = 0; i < linkList.length; i++) linkList[i].w = m.weights?.[i] ?? 1;
104
+ sim.force("link").strength(strengthOf);
105
+ } else if (m.type === "contain") { // a same-topology refresh changed the containment pairs: swap the springs after the `lead` real links, no reheat
106
+ const ls = linkList.slice(0, m.lead), at = m.pairs;
107
+ for (let i = 0; i < at.length / 2; i++) ls.push({ source: at[2 * i], target: at[2 * i + 1], w: 1 });
108
+ linkList = ls;
109
+ sim.force("link").links(ls);
110
+ markFree(nodes, ls);
111
+ apply(cur);
112
+ } else if (m.type === "pause") {
113
+ if (state === "running") postState("paused");
114
+ } else if (m.type === "resume") {
115
+ if (state === "paused") run();
116
+ } else if (m.type === "pin" || m.type === "unpin") { // drag-to-pin (EXPLORE-1c-UI r2): hold a doc at fx/fy/fz, or let it go; a paused layout stays paused
117
+ if (m.type === "pin") {
118
+ const d = nodes[m.i];
119
+ if (d) { d.x = d.fx = m.x; d.y = d.fy = m.y; d.z = d.fz = m.z; }
120
+ } else {
121
+ for (const i of m.idx || nodes.keys()) { const d = nodes[i]; if (d) { d.fx = d.fy = d.fz = null; } }
122
+ }
123
+ if (state !== "paused") { sim.alpha(Math.max(sim.alpha(), REHEAT_ALPHA)); run(); }
124
+ } else if (m.type === "reheat") {
125
+ sim.alpha(Math.max(sim.alpha(), REHEAT_ALPHA));
126
+ run();
127
+ } else if (m.type === "stop") {
128
+ gen++; pending = false; sim = null; nodes = null; radii = null; linkList = null; state = "idle";
129
+ }
130
+ };
131
+ }
132
+
133
+ /* global WorkerGlobalScope */
134
+ if (typeof WorkerGlobalScope !== "undefined" && self instanceof WorkerGlobalScope) {
135
+ self.onmessage = makeHandler({ post: (msg, transfer) => self.postMessage(msg, transfer || []) });
136
+ }