@kanzo-tech/graph 0.1.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 +15 -7
- package/dist/bounded.d.ts +119 -69
- package/dist/bounded.d.ts.map +1 -1
- package/dist/bounded.js +13 -34
- package/dist/bounded.js.map +1 -1
- package/dist/duck-source.d.ts +60 -72
- package/dist/duck-source.d.ts.map +1 -1
- package/dist/duck-source.js +185 -255
- package/dist/duck-source.js.map +1 -1
- package/dist/graph-canvas.d.ts +1 -1
- package/dist/graph-canvas.js.map +1 -1
- package/dist/graph-looks.d.ts +63 -18
- package/dist/graph-looks.d.ts.map +1 -1
- package/dist/graph-looks.js +37 -26
- package/dist/graph-looks.js.map +1 -1
- package/dist/graph-model.d.ts +2 -2
- package/dist/graph-model.d.ts.map +1 -1
- package/dist/graph-model.js +19 -14
- package/dist/graph-model.js.map +1 -1
- package/dist/graph-sim.d.ts +15 -1
- package/dist/graph-sim.d.ts.map +1 -1
- package/dist/graph-sim.js +17 -13
- package/dist/graph-sim.js.map +1 -1
- package/dist/index.d.ts +14 -14
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -48
- package/dist/index.js.map +1 -1
- package/dist/obligations.d.ts +1 -3
- package/dist/obligations.d.ts.map +1 -1
- package/dist/shape-glyph.d.ts +30 -0
- package/dist/shape-glyph.d.ts.map +1 -0
- package/dist/shape-glyph.js +15 -0
- package/dist/shape-glyph.js.map +1 -0
- package/dist/slice-client.d.ts.map +1 -1
- package/dist/slice-client.js +36 -36
- package/dist/slice-client.js.map +1 -1
- package/dist/use-graph-overlays.d.ts +21 -30
- package/dist/use-graph-overlays.d.ts.map +1 -1
- package/dist/use-graph-overlays.js +39 -37
- package/dist/use-graph-overlays.js.map +1 -1
- package/dist/use-graph.d.ts +38 -12
- package/dist/use-graph.d.ts.map +1 -1
- package/dist/use-graph.js +62 -61
- package/dist/use-graph.js.map +1 -1
- package/dist/use-query-loop.d.ts +8 -4
- package/dist/use-query-loop.d.ts.map +1 -1
- package/dist/use-query-loop.js +93 -96
- package/dist/use-query-loop.js.map +1 -1
- package/dist/use-renderer.d.ts.map +1 -1
- package/dist/use-renderer.js +69 -66
- package/dist/use-renderer.js.map +1 -1
- package/package.json +11 -5
- package/dist/memory-source.d.ts +0 -32
- package/dist/memory-source.d.ts.map +0 -1
- package/dist/memory-source.js +0 -134
- package/dist/memory-source.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-query-loop.js","sources":["../src/use-query-loop.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from \"react\";\nimport type { Graph } from \"@cosmos.gl/graph\";\nimport {\n BOUNDED_DEFAULTS,\n isSuperseded,\n shouldSlice,\n type BoundedSource,\n type ExploringSource,\n type Slice,\n type Viewport,\n} from \"./bounded\";\nimport { residentOf, type Resident, type VertexId } from \"./resident\";\nimport { whenReady } from \"./when-ready\";\n\n/**\n * The query loop: the camera moves, a bounded question is asked, the answer becomes the picture.\n *\n * This is the half the engine rule requires of anything with an engine — the canvas stays presentational\n * and this owns the asking. It debounces, cancels what the camera has already superseded, and pushes\n * each answer's geometry into the renderer. A host that already holds its arrays wraps them in a\n * source and gets the same path; there is no second one.\n *\n * **A graph that fits pays for nothing.** `total()` is asked first, and under the limit the source is\n * asked once for everything and never again — panning is then free exactly when it can be. Above it,\n * every camera move costs a query, which at 200,000 nodes is the trade that buys the ceiling away.\n */\n\nexport interface QueryLoopOptions {\n source: BoundedSource | null;\n /** The live renderer, for reading the camera and receiving each answer. */\n graphRef: RefObject<Graph | null>;\n /** The element the canvas is drawn into — its box is the screen rectangle. */\n hostRef: RefObject<HTMLElement | null>;\n /**\n * Vertices that must come back whatever the camera is looking at.\n *\n * Dragged, pinned, selected. Their drawn positions are a view-local overlay on coordinates that\n * never move, so the index cannot find them where they now appear.\n */\n pinned?: VertexId[];\n /**\n * Which column colours a point and which the size ramp is spent on — Plot's channel names.\n *\n * They arrive here rather than being baked into the source because a channel is part of the\n * **question**: colouring by another column is a new answer over the same bytes, and a source that\n * held them meant building a second source to change a colour. So this loop asks again when one\n * changes, which is the whole behaviour the move buys.\n *\n * Both optional, and the source decides what an omitted one means — it is the only thing that\n * knows what its corpus carries.\n */\n fill?: string;\n r?: string;\n limit?: number;\n /**\n * How long the camera has to be still before asking, in milliseconds.\n *\n * A pan is a stream of `onZoom` callbacks and every one of them would otherwise be a query. Long\n * enough that a gesture costs one question rather than sixty; short enough that letting go feels\n * like it answered immediately.\n */\n debounce?: number;\n onError?: (message: string) => void;\n}\n\nexport interface QueryLoopState {\n /** The answer currently drawn, or `null` before the first one. */\n slice: Slice | null;\n /**\n * Who is drawn, and where — rebuilt with every answer, which is what makes it correct.\n *\n * It lives here rather than in each hook because **this is where residency changes**. The map is\n * a function of the current answer and nothing else, so a consumer that built its own would be\n * building the same thing from the same input, one render later, with no way to notice it had\n * fallen behind the buffers on screen. Selection, overlays, pins and the greyout all read this\n * one.\n */\n resident: Resident;\n /** Whether a question is outstanding. */\n pending: boolean;\n /** How many vertices there are, when the source knows. */\n total: number | undefined;\n /**\n * Whether this graph is being asked in pieces at all.\n *\n * `false` means it fit under the limit and was taken whole — the camera is not observed, and\n * nothing is asked again.\n */\n sliced: boolean;\n /** Ask again. Wire it to the camera, and call it when the pinned set changes. */\n refresh: () => void;\n /** Ask a topological question instead of a spatial one, when the source supports one. */\n explore: (seeds: VertexId[], depth: number) => void;\n}\n\nconst EVERYTHING: Viewport = {\n xMin: Number.NEGATIVE_INFINITY,\n yMin: Number.NEGATIVE_INFINITY,\n xMax: Number.POSITIVE_INFINITY,\n yMax: Number.POSITIVE_INFINITY,\n};\n\n/**\n * What the camera is over, asked of the renderer rather than recomputed.\n *\n * cosmos.gl owns the screen↔space transform, so deriving the rectangle from `camera.x/y/k` and the\n * space size — which an earlier `viewportOf` did — is a second implementation of it, free to drift.\n * Screen y grows downward and space y does not, so the corners are sorted rather than assumed.\n */\nfunction cameraViewport(graph: Graph, host: HTMLElement): { view: Viewport; perPixel: number } {\n const { height, width } = host.getBoundingClientRect();\n const [ax, ay] = graph.screenToSpacePosition([0, 0]);\n const [bx, by] = graph.screenToSpacePosition([width, height]);\n const view = {\n xMin: Math.min(ax, bx),\n yMin: Math.min(ay, by),\n xMax: Math.max(ax, bx),\n yMax: Math.max(ay, by),\n };\n /**\n * How much space one pixel covers — asked of the same transform, not derived from the zoom.\n *\n * The rectangle came from mapping the host's own corners, so its width **is** `width` pixels of\n * space and the ratio is exact. Reading `camera.k` instead would be a second implementation of the\n * renderer's screen↔space maths, which is the mistake `viewportOf` already made once.\n *\n * Zero width — an unmounted or hidden host — gives no resolution rather than a division by zero,\n * and a source asked with none discards nothing.\n */\n const perPixel = width > 0 ? (view.xMax - view.xMin) / width : 0;\n return { view, perPixel };\n}\n\nexport function useQueryLoop(options: QueryLoopOptions): QueryLoopState {\n const {\n debounce = DEBOUNCE_MS,\n fill,\n graphRef,\n hostRef,\n limit = BOUNDED_DEFAULTS.limit,\n onError,\n pinned,\n r,\n source,\n } = options;\n\n const [slice, setSlice] = useState<Slice | null>(null);\n const [pending, setPending] = useState(false);\n const [total, setTotal] = useState<number | undefined>(undefined);\n const [sliced, setSliced] = useState(false);\n /** The source `total()` has already answered for — the gate the asking effect waits on. */\n const [counted, setCounted] = useState<BoundedSource | null>(null);\n\n /**\n * The request in flight, and the timer waiting to become one.\n *\n * Refs rather than state: aborting a superseded request is bookkeeping the render output never\n * shows, and putting it in state would re-render the canvas once per camera frame to no effect.\n */\n const inFlight = useRef<AbortController | null>(null);\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n /** Whether the camera is being observed at all, where `refresh` can read it without a rebuild. */\n const slicing = useRef(false);\n /** The live pinned set, so the query loop is not rebuilt every time a node is dragged. */\n const held = useRef(pinned);\n held.current = pinned;\n const report = useRef(onError);\n report.current = onError;\n\n const ask = useCallback(\n async (run: (from: BoundedSource, signal: AbortSignal) => Promise<Slice>) => {\n if (!source) return;\n inFlight.current?.abort();\n const controller = new AbortController();\n inFlight.current = controller;\n setPending(true);\n try {\n const answer = await run(source, controller.signal);\n if (controller.signal.aborted) return;\n setSlice(answer);\n } catch (error) {\n // An abort is the loop working, not a failure: the camera moved on before the answer landed.\n // `SUPERSEDED` is the source's half of the same event — it answers one question at a time, so\n // it settles the one the camera replaced rather than leaving this `finally` unrun.\n if (controller.signal.aborted || isSuperseded(error)) return;\n report.current?.(String(error));\n } finally {\n if (inFlight.current === controller) {\n inFlight.current = null;\n setPending(false);\n }\n }\n },\n [source],\n );\n\n /**\n * Put the camera over the corpus, once, before the first question is asked.\n *\n * **The order is the point, and it is why this is not `fitView()` after the first slice.** A\n * sliced graph's first question is *what is the camera over*, so framing afterwards means the\n * opening query was asked about wherever the renderer happened to start — which for a corpus\n * occupying a corner of the space is a first paint of nothing, followed by a second query once the\n * fit moves the camera. Framing first costs one query instead of two and shows the corpus instead\n * of the default box.\n *\n * A source that cannot say its extent is left alone rather than guessed at: the camera stays where\n * the renderer put it, which is today's behaviour and is correct for arrays with no layout.\n *\n * Once, tracked on a ref, because this is the *opening* view: a reader who has panned somewhere\n * and then changes a channel is not asking to be sent home.\n */\n const framed = useRef<BoundedSource | null>(null);\n const frame = useCallback(\n async (from: BoundedSource) => {\n if (!from.extent || framed.current === from) return;\n framed.current = from;\n let box;\n try {\n box = await from.extent();\n } catch (error) {\n // **Framing may not stop the drawing.** This is a camera placement, and a source that cannot\n // answer where it is still has a slice to give — but the first version let the rejection\n // escape the chain the ask was waiting on, so a failed extent left the canvas saying \"asking\n // for what is in view\" forever, with nothing in the console. A defect that silences the whole\n // canvas has to be louder than the thing it was helping.\n report.current?.(String(error));\n return;\n }\n const graph = graphRef.current;\n if (!graph) return;\n // The quiet half of the race `whenReady` describes: a fit that arrives before the device is\n // not applied and does not complain, so the camera stays on the default box while the corpus\n // sits outside it. Awaited rather than wrapped because this function is already async and\n // already the one place that waits.\n const ready = await graph.ready.then(() => graph);\n /**\n * **The renderer's coordinate box, from the corpus rather than from a constant of ours.**\n *\n * This is the one place that already waits for an `extent()`, so it is the only place that can\n * set the box without asking twice. `spaceSize` is not one of the three fields cosmos.gl\n * treats as init-only — `initialZoomLevel`, `randomSeed`, `attribution` — so it takes effect\n * here; the config change calls `adjustSpaceSize` and re-syncs the screen scales.\n *\n * **Before the fit, not after.** Changing the box shifts the space→screen origin by half the\n * difference — measured 2.4 px on the million at its fitted zoom — so a set that follows the\n * fit slides the picture the reader was just given. The fit absorbs it in this order.\n *\n * A box past the device's `maxTextureDimension2D` is halved by cosmos.gl with a console line\n * of its own; that is left visible rather than clamped here, and `/docs/design/graph` says\n * why.\n *\n * The larger side, because the box is a square. There used to be an exported `SPACE = 4096`\n * declaring it, hand-copied into both bench generators, and a corpus fossil wrote ignored it:\n * a million vertices span about x ∈ [−345, 645396]. That was survivable only because\n * `spaceSize` enters every render path as a pure translation — it changed nothing anyone could\n * see, which is exactly why nothing caught it.\n */\n ready.setConfigPartial({ spaceSize: Math.max(box.xMax - box.xMin, box.yMax - box.yMin) });\n // Two corners are enough: cosmos.gl fits the bounding box of whatever positions it is handed,\n // and a rectangle is its own bounding box. Duration zero — an opening view that flies in from\n // the default box is animation for its own sake, and the reader has not asked for anything yet.\n ready.fitViewByPointPositions([box.xMin, box.yMin, box.xMax, box.yMax], 0);\n },\n [graphRef],\n );\n\n /**\n * Ask about wherever the camera is now, after `debounce` of stillness.\n *\n * Wired to the renderer's `onZoom`, which fires per frame of a gesture. The timer collapses a pan\n * into one question; `ask` aborts anything the pan has already made stale.\n */\n const refresh = useCallback(() => {\n // A graph that fits was answered whole, and re-asking would replace that answer with whatever\n // rectangle the camera happens to be over — which is how a corpus of 582 nodes ends up empty\n // because the reader zoomed. The promise that panning is free exactly when it can be is kept\n // here rather than by asking every call site to remember not to wire this up.\n if (!slicing.current) return;\n if (timer.current) clearTimeout(timer.current);\n timer.current = setTimeout(() => {\n timer.current = null;\n const graph = graphRef.current;\n const host = hostRef.current;\n if (!graph || !host) return;\n const { perPixel, view } = cameraViewport(graph, host);\n void ask((s, signal) =>\n s.slice({ view, perPixel, pinned: held.current, fill, r, limit, signal }),\n );\n }, debounce);\n // The channels are dependencies rather than a ref, unlike `pinned`: a new one is a new question\n // and the effect below re-runs this the moment its identity changes. A pin is a gesture the host\n // reports, and it says when to ask again itself.\n }, [ask, debounce, fill, graphRef, hostRef, limit, r]);\n\n /**\n * Ask a topological question, when the source is one that can answer.\n *\n * `\"explore\" in source` is the narrowing, and it is the whole check — a source that cannot walk\n * edges does not carry the method, so this is the one place a caller pays for the distinction\n * instead of every source restating it in a predicate and a throw.\n */\n const explore = useCallback(\n (seeds: VertexId[], depth: number) => {\n if (!source || !(\"explore\" in source)) {\n report.current?.(\"this source answers regions only\");\n return;\n }\n if (timer.current) clearTimeout(timer.current);\n void ask((s, signal) =>\n (s as ExploringSource).explore({\n seeds,\n depth,\n pinned: held.current,\n fill,\n r,\n limit,\n signal,\n }),\n );\n },\n [ask, fill, limit, r, source],\n );\n\n /**\n * How big it is — asked once per source, and the answer decides whether there is a query loop at\n * all: under the limit one slice covers everything and the camera is never consulted again. A\n * source that cannot say cheaply is treated as large, because an unknown corpus is more likely to\n * be the kind that needs bounding than not.\n *\n * The *asking* is the effect below rather than the tail of this one, and the split is what lets a\n * channel change re-ask: how big a corpus is belongs to the source and does not change with what\n * you want drawn, so counting again on every colour would be paying a `count(*)` for a question\n * nobody asked.\n */\n useEffect(() => {\n if (!source) {\n setSlice(null);\n setTotal(undefined);\n setCounted(null);\n return;\n }\n let live = true;\n void (async () => {\n let count: number | undefined;\n try {\n count = await source.total?.();\n } catch {\n // A source that will not count is a source that gets sliced.\n }\n if (!live) return;\n setTotal(count);\n const bounded = shouldSlice(count, limit);\n slicing.current = bounded;\n setSliced(bounded);\n setCounted(source);\n })();\n return () => {\n live = false;\n };\n }, [limit, source]);\n\n /**\n * What to draw — the opening question, and every later one that is not the camera's.\n *\n * It runs when the count lands, and again whenever the question itself changes: a channel is part\n * of the question, so `refresh` and this both carry them and both re-run. The counted source is\n * compared rather than a boolean, so an answer that arrives for a source already replaced cannot\n * open a slice against the new one.\n */\n useEffect(() => {\n if (!source || counted !== source) return;\n void (async () => {\n await frame(source);\n if (slicing.current) refresh();\n else\n await ask((s, signal) =>\n s.slice({ view: EVERYTHING, pinned: held.current, fill, r, limit, signal }),\n );\n })();\n }, [ask, counted, fill, frame, limit, r, refresh, source]);\n\n /**\n * The other way an answer arrives: the page filtered something.\n *\n * A camera move is a *pull* — the loop asks, because only the loop knows where the camera is. A\n * filter change is a **push**: the coordinator re-runs the source's reads with the new predicate\n * the way it re-runs a histogram's, and what lands here is the finished slice. Re-asking instead\n * would issue those queries a second time to learn what is already in hand.\n *\n * The disposer is also the release, which is why this is wired even when the source is between\n * renders: `watch` is where a source lets go of a client registration, and a loop that calls it is\n * a loop that cannot leak one.\n */\n useEffect(() => source?.watch?.(setSlice), [source]);\n\n // A dropped canvas must not leave a timer holding a stale camera, or a request nobody will read.\n useEffect(\n () => () => {\n if (timer.current) clearTimeout(timer.current);\n inFlight.current?.abort();\n },\n [],\n );\n\n /**\n * The answer becomes the picture.\n *\n * Separate from the effect that builds the renderer, which is the whole point: a slice arrives on\n * every camera move, and rebuilding the instance for each one would destroy and recreate a WebGL\n * context sixty times a pan. Geometry is pushed; the instance outlives every slice it draws.\n */\n useEffect(() => {\n const graph = graphRef.current;\n if (!graph || !slice) return;\n /**\n * **Behind `ready`, because a non-null instance is not a usable one.**\n *\n * `whenReady` carries the whole argument; the cancel is what matters here. Slices arrive\n * faster than a frame during a pan, and every one of them but the last is superseded before it\n * could have been drawn.\n */\n return whenReady(graph, (ready) => {\n ready.setPointPositions(slice.positions);\n ready.setLinks(slice.links);\n ready.render();\n });\n }, [graphRef, slice]);\n\n // Memoised on the answer, because a fresh map per render would make every consumer that depends on\n // it re-run for a value that had not changed.\n const resident = useMemo(() => residentOf(slice), [slice]);\n\n return { slice, resident, pending, total, sliced, refresh, explore };\n}\n\n/**\n * The stillness a camera has to hold before it is asked about — 120 ms.\n *\n * Under a gesture's own frame budget it would be one query per frame; far above it and letting go of\n * a pan feels like the canvas is thinking. The measured slice at 200,000 nodes is 30 ms, so this is\n * the larger half of the latency a reader actually feels, and it is the half we chose.\n */\nconst DEBOUNCE_MS = 120;\n"],"names":[],"mappings":";;;;;AAiGA;AAA6B;AACd;AACA;AACA;AAEf;AASA;AACE;AAGa;AACU;AACA;AACA;AACA;AAavB;AACF;AAEO;AACL;AAAM;AACO;AACX;AACA;AACA;AACyB;AACzB;AACA;AACA;AACA;AAsBF;AACA;AACA;AAEA;AAAY;;AAER;AACA;AACA;AACA;AAEA;AACE;AACA;AACA;AAAe;AAKf;AACA;AAA6B;AAE7B;AAEkB;AAEpB;AACF;AACO;AAoBK;;AAEV;AACA;AACA;AACA;AACE;AAAiB;AAOjB;AACA;AAAA;AAEF;AACA;AAKA;AAuBA;AAIyE;AAC3E;AACS;AAcT;AAGE;AACA;AAEA;AACA;AACA;AAAK;AACqE;AAAA;AAEjE;AAaG;;AAEZ;AACE;AACA;AAAA;AAEF;AACK;AAC4B;AAC7B;AACA;AACa;AACb;AACA;AACA;AACA;AACD;AAAA;AAEL;AAC4B;AAc9B;AACE;AACE;AAGA;AAAA;AAEF;AACA;;AACE;AACA;AACE;AAAc;AACR;AAGR;AACA;AACA;AACA;AAEiB;AAGjB;AAAO;AACT;AAYA;AAKU;AACsE;AAAA;;AAiBlE;AAAgB;AAGhC;;AAEI;AACkB;AACpB;AACA;AAWA;AACA;AAQA;AACE;AAEM;AACP;AAKH;AAEA;AACF;AASA;;;;"}
|
|
1
|
+
{"version":3,"file":"use-query-loop.js","sources":["../src/use-query-loop.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from \"react\";\nimport type { Graph } from \"@cosmos.gl/graph\";\nimport {\n DEFAULT_LIMIT,\n DEFAULT_MIN_LINK_PIXELS,\n isAbort,\n shouldSlice,\n type BoundedSource,\n type Slice,\n type Viewport,\n} from \"./bounded\";\nimport { residentOf, type Resident, type VertexId } from \"./resident\";\nimport { whenReady } from \"./when-ready\";\n\n/**\n * The query loop: the camera moves, a bounded question is asked, the answer becomes the picture.\n *\n * This is the half the engine rule requires of anything with an engine — the canvas stays presentational\n * and this owns the asking. It debounces, cancels what the camera has already superseded, and pushes\n * each answer's geometry into the renderer. There is one source and it is `openCorpus`; this loop is\n * written against the contract rather than against it, which is what keeps a second one possible.\n *\n * **A graph that fits pays for nothing.** `total()` is asked first, and under the limit the source is\n * asked once for everything and never again — panning is then free exactly when it can be. Above it,\n * every camera move costs a query, which at 200,000 nodes is the trade that buys the ceiling away.\n *\n * **It is also where the question is finished.** A host's `limit` is optional and `minLinkPixels` is\n * not a host's business at all, and both are **required** on the `SliceRequest` a source receives —\n * resolved here, once, from `DEFAULT_LIMIT` and `DEFAULT_MIN_LINK_PIXELS`. They used to reach a\n * source as an exported `BOUNDED_DEFAULTS` table it looked them up in, which made a request\n * something a source had to *complete* rather than answer, in its own words, in two repositories.\n */\n\nexport interface QueryLoopOptions {\n source: BoundedSource | null;\n /** The live renderer, for reading the camera and receiving each answer. */\n graphRef: RefObject<Graph | null>;\n /** The element the canvas is drawn into — its box is the screen rectangle. */\n hostRef: RefObject<HTMLElement | null>;\n /**\n * Vertices that must come back whatever the camera is looking at.\n *\n * Dragged, pinned, selected. Their drawn positions are a view-local overlay on coordinates that\n * never move, so the index cannot find them where they now appear.\n */\n pinned?: VertexId[];\n /**\n * Which column colours a point and which the size ramp is spent on — Plot's channel names.\n *\n * They arrive here rather than being baked into the source because a channel is part of the\n * **question**: colouring by another column is a new answer over the same bytes, and a source that\n * held them meant building a second source to change a colour. So this loop asks again when one\n * changes, which is the whole behaviour the move buys.\n *\n * Both optional, and the source decides what an omitted one means — it is the only thing that\n * knows what its corpus carries.\n */\n fill?: string;\n r?: string;\n limit?: number;\n /**\n * How long the camera has to be still before asking, in milliseconds.\n *\n * A pan is a stream of `onZoom` callbacks and every one of them would otherwise be a query. Long\n * enough that a gesture costs one question rather than sixty; short enough that letting go feels\n * like it answered immediately.\n */\n debounce?: number;\n onError?: (message: string) => void;\n}\n\nexport interface QueryLoopState {\n /** The answer currently drawn, or `null` before the first one. */\n slice: Slice | null;\n /**\n * Who is drawn, and where — rebuilt with every answer, which is what makes it correct.\n *\n * It lives here rather than in each hook because **this is where residency changes**. The map is\n * a function of the current answer and nothing else, so a consumer that built its own would be\n * building the same thing from the same input, one render later, with no way to notice it had\n * fallen behind the buffers on screen. Selection, overlays, pins and the greyout all read this\n * one.\n */\n resident: Resident;\n /** Whether a question is outstanding. */\n pending: boolean;\n /** How many vertices there are, when the source knows. */\n total: number | undefined;\n /**\n * Whether this graph is being asked in pieces at all.\n *\n * `false` means it fit under the limit and was taken whole — the camera is not observed, and\n * nothing is asked again.\n */\n sliced: boolean;\n /** Ask again. Wire it to the camera, and call it when the pinned set changes. */\n refresh: () => void;\n}\n\nconst EVERYTHING: Viewport = {\n xMin: Number.NEGATIVE_INFINITY,\n yMin: Number.NEGATIVE_INFINITY,\n xMax: Number.POSITIVE_INFINITY,\n yMax: Number.POSITIVE_INFINITY,\n};\n\n/**\n * What the camera is over, asked of the renderer rather than recomputed.\n *\n * cosmos.gl owns the screen↔space transform, so deriving the rectangle from `camera.x/y/k` and the\n * space size — which an earlier `viewportOf` did — is a second implementation of it, free to drift.\n * Screen y grows downward and space y does not, so the corners are sorted rather than assumed.\n */\nfunction cameraViewport(graph: Graph, host: HTMLElement): { view: Viewport; perPixel: number } {\n const { height, width } = host.getBoundingClientRect();\n const [ax, ay] = graph.screenToSpacePosition([0, 0]);\n const [bx, by] = graph.screenToSpacePosition([width, height]);\n const view = {\n xMin: Math.min(ax, bx),\n yMin: Math.min(ay, by),\n xMax: Math.max(ax, bx),\n yMax: Math.max(ay, by),\n };\n /**\n * How much space one pixel covers — asked of the same transform, not derived from the zoom.\n *\n * The rectangle came from mapping the host's own corners, so its width **is** `width` pixels of\n * space and the ratio is exact. Reading `camera.k` instead would be a second implementation of the\n * renderer's screen↔space maths, which is the mistake `viewportOf` already made once.\n *\n * Zero width — an unmounted or hidden host — gives no resolution rather than a division by zero,\n * and a source asked with none discards nothing.\n */\n const perPixel = width > 0 ? (view.xMax - view.xMin) / width : 0;\n return { view, perPixel };\n}\n\nexport function useQueryLoop(options: QueryLoopOptions): QueryLoopState {\n const {\n debounce = DEBOUNCE_MS,\n fill,\n graphRef,\n hostRef,\n limit = DEFAULT_LIMIT,\n onError,\n pinned,\n r,\n source,\n } = options;\n\n const [slice, setSlice] = useState<Slice | null>(null);\n const [pending, setPending] = useState(false);\n const [total, setTotal] = useState<number | undefined>(undefined);\n const [sliced, setSliced] = useState(false);\n /** The source `total()` has already answered for — the gate the asking effect waits on. */\n const [counted, setCounted] = useState<BoundedSource | null>(null);\n\n /**\n * The request in flight, and the timer waiting to become one.\n *\n * Refs rather than state: aborting a superseded request is bookkeeping the render output never\n * shows, and putting it in state would re-render the canvas once per camera frame to no effect.\n */\n const inFlight = useRef<AbortController | null>(null);\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n /** Whether the camera is being observed at all, where `refresh` can read it without a rebuild. */\n const slicing = useRef(false);\n /** The live pinned set, so the query loop is not rebuilt every time a node is dragged. */\n const held = useRef(pinned);\n held.current = pinned;\n const report = useRef(onError);\n report.current = onError;\n\n const ask = useCallback(\n async (run: (from: BoundedSource, signal: AbortSignal) => Promise<Slice>) => {\n if (!source) return;\n inFlight.current?.abort();\n const controller = new AbortController();\n inFlight.current = controller;\n setPending(true);\n try {\n const answer = await run(source, controller.signal);\n if (controller.signal.aborted) return;\n setSlice(answer);\n } catch (error) {\n /**\n * An abort is the loop working, not a failure: the camera moved on before the answer landed.\n *\n * Two halves, and they are two because only one of them is this loop's own doing. The first\n * is the controller above — this loop aborted the request itself, so the signal says so\n * without anything having been thrown. The second is the **source's** half: a source that\n * holds one standing question settles the one a newer caller replaced, and that rejection\n * arrives here for a request whose signal was never aborted at all.\n *\n * `isAbort` reads `error.name`, which is what makes a source written over `fetch` — or over\n * `AbortSignal.timeout`, or a stream — correct here without importing anything from us. It\n * was `isSuperseded(error)` against an exported `Symbol`, and that symbol was the reason a\n * source doing the standard thing was reported to the host as a failure.\n */\n if (controller.signal.aborted || isAbort(error)) return;\n report.current?.(String(error));\n } finally {\n if (inFlight.current === controller) {\n inFlight.current = null;\n setPending(false);\n }\n }\n },\n [source],\n );\n\n /**\n * Put the camera over the corpus, once, before the first question is asked.\n *\n * **The order is the point, and it is why this is not `fitView()` after the first slice.** A\n * sliced graph's first question is *what is the camera over*, so framing afterwards means the\n * opening query was asked about wherever the renderer happened to start — which for a corpus\n * occupying a corner of the space is a first paint of nothing, followed by a second query once the\n * fit moves the camera. Framing first costs one query instead of two and shows the corpus instead\n * of the default box.\n *\n * A source that cannot say its extent is left alone rather than guessed at: the camera stays where\n * the renderer put it, which is today's behaviour and is correct for arrays with no layout.\n *\n * Once, tracked on a ref, because this is the *opening* view: a reader who has panned somewhere\n * and then changes a channel is not asking to be sent home.\n */\n const framed = useRef<BoundedSource | null>(null);\n const frame = useCallback(\n async (from: BoundedSource) => {\n if (!from.extent || framed.current === from) return;\n framed.current = from;\n let box;\n try {\n box = await from.extent();\n } catch (error) {\n // **Framing may not stop the drawing.** This is a camera placement, and a source that cannot\n // answer where it is still has a slice to give — but the first version let the rejection\n // escape the chain the ask was waiting on, so a failed extent left the canvas saying \"asking\n // for what is in view\" forever, with nothing in the console. A defect that silences the whole\n // canvas has to be louder than the thing it was helping.\n report.current?.(String(error));\n return;\n }\n const graph = graphRef.current;\n if (!graph) return;\n // The quiet half of the race `whenReady` describes: a fit that arrives before the device is\n // not applied and does not complain, so the camera stays on the default box while the corpus\n // sits outside it. Awaited rather than wrapped because this function is already async and\n // already the one place that waits.\n const ready = await graph.ready.then(() => graph);\n /**\n * **The renderer's coordinate box, from the corpus rather than from a constant of ours.**\n *\n * This is the one place that already waits for an `extent()`, so it is the only place that can\n * set the box without asking twice. `spaceSize` is not one of the three fields cosmos.gl\n * treats as init-only — `initialZoomLevel`, `randomSeed`, `attribution` — so it takes effect\n * here; the config change calls `adjustSpaceSize` and re-syncs the screen scales.\n *\n * **Before the fit, not after.** Changing the box shifts the space→screen origin by half the\n * difference — measured 2.4 px on the million at its fitted zoom — so a set that follows the\n * fit slides the picture the reader was just given. The fit absorbs it in this order.\n *\n * A box past the device's `maxTextureDimension2D` is halved by cosmos.gl with a console line\n * of its own; that is left visible rather than clamped here, and `/docs/design/graph` says\n * why.\n *\n * The larger side, because the box is a square. There used to be an exported `SPACE = 4096`\n * declaring it, hand-copied into both bench generators, and a corpus fossil wrote ignored it:\n * a million vertices span about x ∈ [−345, 645396]. That was survivable only because\n * `spaceSize` enters every render path as a pure translation — it changed nothing anyone could\n * see, which is exactly why nothing caught it.\n */\n ready.setConfigPartial({ spaceSize: Math.max(box.xMax - box.xMin, box.yMax - box.yMin) });\n // Two corners are enough: cosmos.gl fits the bounding box of whatever positions it is handed,\n // and a rectangle is its own bounding box. Duration zero — an opening view that flies in from\n // the default box is animation for its own sake, and the reader has not asked for anything yet.\n ready.fitViewByPointPositions([box.xMin, box.yMin, box.xMax, box.yMax], 0);\n },\n [graphRef],\n );\n\n /**\n * Ask about wherever the camera is now, after `debounce` of stillness.\n *\n * Wired to the renderer's `onZoom`, which fires per frame of a gesture. The timer collapses a pan\n * into one question; `ask` aborts anything the pan has already made stale.\n */\n const refresh = useCallback(() => {\n // A graph that fits was answered whole, and re-asking would replace that answer with whatever\n // rectangle the camera happens to be over — which is how a corpus of 582 nodes ends up empty\n // because the reader zoomed. The promise that panning is free exactly when it can be is kept\n // here rather than by asking every call site to remember not to wire this up.\n if (!slicing.current) return;\n if (timer.current) clearTimeout(timer.current);\n timer.current = setTimeout(() => {\n timer.current = null;\n const graph = graphRef.current;\n const host = hostRef.current;\n if (!graph || !host) return;\n const { perPixel, view } = cameraViewport(graph, host);\n void ask((s, signal) =>\n s.slice({\n view,\n perPixel,\n pinned: held.current,\n fill,\n r,\n limit,\n minLinkPixels: DEFAULT_MIN_LINK_PIXELS,\n signal,\n }),\n );\n }, debounce);\n // The channels are dependencies rather than a ref, unlike `pinned`: a new one is a new question\n // and the effect below re-runs this the moment its identity changes. A pin is a gesture the host\n // reports, and it says when to ask again itself.\n }, [ask, debounce, fill, graphRef, hostRef, limit, r]);\n\n /**\n * How big it is — asked once per source, and the answer decides whether there is a query loop at\n * all: under the limit one slice covers everything and the camera is never consulted again. A\n * source that cannot say cheaply is treated as large, because an unknown corpus is more likely to\n * be the kind that needs bounding than not.\n *\n * The *asking* is the effect below rather than the tail of this one, and the split is what lets a\n * channel change re-ask: how big a corpus is belongs to the source and does not change with what\n * you want drawn, so counting again on every colour would be paying a `count(*)` for a question\n * nobody asked.\n */\n useEffect(() => {\n if (!source) {\n setSlice(null);\n setTotal(undefined);\n setCounted(null);\n return;\n }\n let live = true;\n void (async () => {\n let count: number | undefined;\n try {\n count = await source.total?.();\n } catch {\n // A source that will not count is a source that gets sliced.\n }\n if (!live) return;\n setTotal(count);\n const bounded = shouldSlice(count, limit);\n slicing.current = bounded;\n setSliced(bounded);\n setCounted(source);\n })();\n return () => {\n live = false;\n };\n }, [limit, source]);\n\n /**\n * What to draw — the opening question, and every later one that is not the camera's.\n *\n * It runs when the count lands, and again whenever the question itself changes: a channel is part\n * of the question, so `refresh` and this both carry them and both re-run. The counted source is\n * compared rather than a boolean, so an answer that arrives for a source already replaced cannot\n * open a slice against the new one.\n */\n useEffect(() => {\n if (!source || counted !== source) return;\n void (async () => {\n await frame(source);\n if (slicing.current) refresh();\n else\n await ask((s, signal) =>\n s.slice({\n view: EVERYTHING,\n pinned: held.current,\n fill,\n r,\n limit,\n minLinkPixels: DEFAULT_MIN_LINK_PIXELS,\n signal,\n }),\n );\n })();\n }, [ask, counted, fill, frame, limit, r, refresh, source]);\n\n /**\n * The other way an answer arrives: the page filtered something.\n *\n * A camera move is a *pull* — the loop asks, because only the loop knows where the camera is. A\n * filter change is a **push**: the coordinator re-runs the source's reads with the new predicate\n * the way it re-runs a histogram's, and what lands here is the finished slice. Re-asking instead\n * would issue those queries a second time to learn what is already in hand.\n *\n * The disposer is also the release, which is why this is wired even when the source is between\n * renders: `watch` is where a source lets go of a client registration, and a loop that calls it is\n * a loop that cannot leak one.\n */\n useEffect(() => source?.watch?.(setSlice), [source]);\n\n // A dropped canvas must not leave a timer holding a stale camera, or a request nobody will read.\n useEffect(\n () => () => {\n if (timer.current) clearTimeout(timer.current);\n inFlight.current?.abort();\n },\n [],\n );\n\n /**\n * The answer becomes the picture.\n *\n * Separate from the effect that builds the renderer, which is the whole point: a slice arrives on\n * every camera move, and rebuilding the instance for each one would destroy and recreate a WebGL\n * context sixty times a pan. Geometry is pushed; the instance outlives every slice it draws.\n */\n useEffect(() => {\n const graph = graphRef.current;\n if (!graph || !slice) return;\n /**\n * **Behind `ready`, because a non-null instance is not a usable one.**\n *\n * `whenReady` carries the whole argument; the cancel is what matters here. Slices arrive\n * faster than a frame during a pan, and every one of them but the last is superseded before it\n * could have been drawn.\n */\n return whenReady(graph, (ready) => {\n ready.setPointPositions(slice.positions);\n ready.setLinks(slice.links);\n ready.render();\n });\n }, [graphRef, slice]);\n\n // Memoised on the answer, because a fresh map per render would make every consumer that depends on\n // it re-run for a value that had not changed.\n const resident = useMemo(() => residentOf(slice), [slice]);\n\n return { slice, resident, pending, total, sliced, refresh };\n}\n\n/**\n * The stillness a camera has to hold before it is asked about — 120 ms.\n *\n * Under a gesture's own frame budget it would be one query per frame; far above it and letting go of\n * a pan feels like the canvas is thinking. The measured slice at 200,000 nodes is 30 ms, so this is\n * the larger half of the latency a reader actually feels, and it is the half we chose.\n */\nconst DEBOUNCE_MS = 120;\n"],"names":[],"mappings":";;;;;AAqGA;AAA6B;AACd;AACA;AACA;AAEf;AASA;AACE;AAGa;AACU;AACA;AACA;AACA;AAavB;AACF;AAEO;AACL;AAAM;AACO;AACX;AACA;AACA;AACQ;AACR;AACA;AACA;AACA;AAsBF;AACA;AACA;AAEA;AAAY;;AAER;AACA;AACA;AACA;AAEA;AACE;AACA;AACA;AAAe;AAgBf;AACA;AAA6B;AAE7B;AAEkB;AAEpB;AACF;AACO;AAoBK;;AAEV;AACA;AACA;AACA;AACE;AAAiB;AAOjB;AACA;AAAA;AAEF;AACA;AAKA;AAuBA;AAIyE;AAC3E;AACS;AAcT;AAGE;AACA;AAEA;AACA;AACA;AAAK;AACK;AACN;AACA;AACa;AACb;AACA;AACA;AACe;AACf;AACD;AAAA;AAEM;AAiBb;AACE;AACE;AAGA;AAAA;AAEF;AACA;;AACE;AACA;AACE;AAAc;AACR;AAGR;AACA;AACA;AACA;AAEiB;AAGjB;AAAO;AACT;AAYA;AAKU;AACI;AACA;AACO;AACb;AACA;AACA;AACe;AACf;AACD;AAAA;;AAiBO;AAAgB;AAGhC;;AAEI;AACkB;AACpB;AACA;AAWA;AACA;AAQA;AACE;AAEM;AACP;AAKH;AAEA;AACF;AASA;;;;"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-renderer.d.ts","sourceRoot":"","sources":["../src/use-renderer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAkC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACvE,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAGzC,OAAO,EAAe,KAAK,GAAG,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAmEtC,MAAM,WAAW,eAAe;IAC9B,oDAAoD;IACpD,OAAO,EAAE,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IAC1C;;;;;;OAMG;IACH,QAAQ,EAAE,SAAS,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC;IAClC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,CAAC;IAClC,wFAAwF;IACxF,GAAG,CAAC,EAAE,GAAG,CAAC;IACV,kGAAkG;IAClG,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE;QACP,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;QACxC,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;QAC1B,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;QACrD,iBAAiB,CAAC,EAAE,MAAM,IAAI,CAAC;QAC/B;;;;;;;WAOG;QACH,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;QACpC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;QACpB,mGAAmG;QACnG,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;KACrB,CAAC;CACH;AAYD,wBAAgB,WAAW,CAAC,OAAO,EAAE,eAAe,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"use-renderer.d.ts","sourceRoot":"","sources":["../src/use-renderer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAkC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACvE,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAGzC,OAAO,EAAe,KAAK,GAAG,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAmEtC,MAAM,WAAW,eAAe;IAC9B,oDAAoD;IACpD,OAAO,EAAE,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IAC1C;;;;;;OAMG;IACH,QAAQ,EAAE,SAAS,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC;IAClC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,CAAC;IAClC,wFAAwF;IACxF,GAAG,CAAC,EAAE,GAAG,CAAC;IACV,kGAAkG;IAClG,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE;QACP,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;QACxC,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;QAC1B,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;QACrD,iBAAiB,CAAC,EAAE,MAAM,IAAI,CAAC;QAC/B;;;;;;;WAOG;QACH,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;QACpC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;QACpB,mGAAmG;QACnG,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;KACrB,CAAC;CACH;AAYD,wBAAgB,WAAW,CAAC,OAAO,EAAE,eAAe,GAAG,IAAI,CAoR1D;AAeD;;;;GAIG;AACH,eAAO,MAAM,MAAM,OAAO,CAAC"}
|
package/dist/use-renderer.js
CHANGED
|
@@ -2,60 +2,60 @@
|
|
|
2
2
|
import { useRef as d, useCallback as A, useEffect as w } from "react";
|
|
3
3
|
import { Graph as I } from "@cosmos.gl/graph";
|
|
4
4
|
import { clusterRing as M } from "./cluster-ring.js";
|
|
5
|
-
import { forces as
|
|
5
|
+
import { forces as L } from "./graph-model.js";
|
|
6
6
|
import { DEFAULT_SIM as V } from "./graph-sim.js";
|
|
7
7
|
import { whenReady as m } from "./when-ready.js";
|
|
8
8
|
function B() {
|
|
9
9
|
if (typeof document > "u") return !1;
|
|
10
10
|
try {
|
|
11
|
-
const
|
|
12
|
-
return
|
|
11
|
+
const r = document.createElement("canvas");
|
|
12
|
+
return r.getContext("webgl2") !== null || r.getContext("webgl") !== null;
|
|
13
13
|
} catch {
|
|
14
14
|
return !1;
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
-
function U(
|
|
17
|
+
function U(r) {
|
|
18
18
|
var g;
|
|
19
|
-
const
|
|
20
|
-
|
|
19
|
+
const n = (r == null ? void 0 : r.getContext("webgl2")) ?? (r == null ? void 0 : r.getContext("webgl"));
|
|
20
|
+
n && !n.isContextLost() && ((g = n.getExtension("WEBGL_lose_context")) == null || g.loseContext());
|
|
21
21
|
}
|
|
22
|
-
const
|
|
22
|
+
const O = () => {
|
|
23
23
|
}, W = Object.freeze({});
|
|
24
|
-
function
|
|
24
|
+
function X(r) {
|
|
25
25
|
const {
|
|
26
|
-
clusters:
|
|
26
|
+
clusters: n,
|
|
27
27
|
events: g = W,
|
|
28
|
-
graphRef:
|
|
28
|
+
graphRef: u,
|
|
29
29
|
hostRef: C,
|
|
30
30
|
onFailure: T,
|
|
31
|
-
report:
|
|
32
|
-
reportProgress: x =
|
|
31
|
+
report: b = O,
|
|
32
|
+
reportProgress: x = O,
|
|
33
33
|
sim: p = V,
|
|
34
34
|
simulate: a = !1
|
|
35
|
-
} =
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
const P = d(!1), R = d(-1),
|
|
40
|
-
const
|
|
41
|
-
|
|
35
|
+
} = r, S = d(p), s = d(g);
|
|
36
|
+
s.current = g;
|
|
37
|
+
const c = d({ onFailure: T, report: b, reportProgress: x });
|
|
38
|
+
c.current = { onFailure: T, report: b, reportProgress: x };
|
|
39
|
+
const P = d(!1), R = d(-1), k = A((i) => {
|
|
40
|
+
const o = Math.round(i * v);
|
|
41
|
+
o !== R.current && (R.current = o, c.current.reportProgress(o / v));
|
|
42
42
|
}, []);
|
|
43
43
|
w(() => {
|
|
44
|
-
const
|
|
45
|
-
if (!
|
|
44
|
+
const i = C.current;
|
|
45
|
+
if (!i) return;
|
|
46
46
|
if (P.current = !1, !B()) {
|
|
47
|
-
|
|
47
|
+
c.current.onFailure("This canvas renders on the GPU, and this browser offers no WebGL context.");
|
|
48
48
|
return;
|
|
49
49
|
}
|
|
50
|
-
const
|
|
50
|
+
const o = () => {
|
|
51
51
|
if (P.current) return;
|
|
52
52
|
P.current = !0;
|
|
53
|
-
const e =
|
|
54
|
-
e && m(e, (t) => t.fitView(
|
|
53
|
+
const e = u.current;
|
|
54
|
+
e && m(e, (t) => t.fitView(q, Y));
|
|
55
55
|
};
|
|
56
56
|
let E = null, h = null, f;
|
|
57
57
|
try {
|
|
58
|
-
f = new I(
|
|
58
|
+
f = new I(i, {
|
|
59
59
|
/**
|
|
60
60
|
* **No `spaceSize` here, and that is the point.**
|
|
61
61
|
*
|
|
@@ -71,7 +71,7 @@ function Q(o) {
|
|
|
71
71
|
* second way to answer a question one side already owns.
|
|
72
72
|
*/
|
|
73
73
|
enableSimulation: a,
|
|
74
|
-
...
|
|
74
|
+
...L(S.current),
|
|
75
75
|
/**
|
|
76
76
|
* Frames to convergence — not milliseconds.
|
|
77
77
|
*
|
|
@@ -102,84 +102,87 @@ function Q(o) {
|
|
|
102
102
|
fitViewPadding: 0.18,
|
|
103
103
|
hoveredPointCursor: "pointer",
|
|
104
104
|
attribution: "",
|
|
105
|
-
onSimulationStart: () =>
|
|
105
|
+
onSimulationStart: () => c.current.report("running"),
|
|
106
106
|
onSimulationEnd: () => {
|
|
107
107
|
var e, t;
|
|
108
|
-
|
|
108
|
+
c.current.report("settled"), k(1), o(), (t = (e = s.current).onTick) == null || t.call(e);
|
|
109
109
|
},
|
|
110
|
-
onSimulationPause: () =>
|
|
111
|
-
onSimulationUnpause: () =>
|
|
110
|
+
onSimulationPause: () => c.current.report("paused"),
|
|
111
|
+
onSimulationUnpause: () => c.current.report("running"),
|
|
112
112
|
onSimulationTick: () => {
|
|
113
113
|
var t, l;
|
|
114
|
-
const e =
|
|
115
|
-
e &&
|
|
114
|
+
const e = u.current;
|
|
115
|
+
e && k(e.progress), (l = (t = s.current).onTick) == null || l.call(t);
|
|
116
116
|
},
|
|
117
117
|
onZoom: () => {
|
|
118
118
|
var e, t;
|
|
119
|
-
return (t = (e =
|
|
119
|
+
return (t = (e = s.current).onZoom) == null ? void 0 : t.call(e);
|
|
120
120
|
},
|
|
121
121
|
onPointMouseOver: (e) => {
|
|
122
122
|
var t, l;
|
|
123
|
-
E = e, (l = (t =
|
|
123
|
+
E = e, (l = (t = s.current).onPointerOver) == null || l.call(t, e);
|
|
124
124
|
},
|
|
125
125
|
onPointMouseOut: () => {
|
|
126
126
|
var e, t;
|
|
127
|
-
E = null, (t = (e =
|
|
127
|
+
E = null, (t = (e = s.current).onPointerOut) == null || t.call(e);
|
|
128
128
|
},
|
|
129
129
|
onDragStart: () => {
|
|
130
130
|
h = E;
|
|
131
131
|
},
|
|
132
132
|
onDragEnd: () => {
|
|
133
133
|
var e, t;
|
|
134
|
-
h !== null && ((t = (e =
|
|
134
|
+
h !== null && ((t = (e = s.current).onDragEnd) == null || t.call(e, h)), h = null;
|
|
135
135
|
},
|
|
136
136
|
onPointClick: (e) => {
|
|
137
|
-
var l,
|
|
138
|
-
const t =
|
|
139
|
-
t && ((
|
|
137
|
+
var l, F;
|
|
138
|
+
const t = u.current;
|
|
139
|
+
t && ((F = (l = s.current).onPointClick) == null || F.call(l, t, e));
|
|
140
140
|
},
|
|
141
141
|
onBackgroundClick: () => {
|
|
142
142
|
var e, t;
|
|
143
|
-
return (t = (e =
|
|
143
|
+
return (t = (e = s.current).onBackgroundClick) == null ? void 0 : t.call(e);
|
|
144
144
|
}
|
|
145
145
|
});
|
|
146
146
|
} catch (e) {
|
|
147
|
-
|
|
147
|
+
c.current.onFailure(`The renderer failed to start. (${String(e)})`);
|
|
148
148
|
return;
|
|
149
149
|
}
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
e.preventDefault(),
|
|
150
|
+
u.current = f;
|
|
151
|
+
const y = m(f, (e) => e.render()), D = (e) => {
|
|
152
|
+
e.preventDefault(), c.current.onFailure(
|
|
153
153
|
"The graph's WebGL context was lost. A browser keeps a limited number of them and drops the oldest; reload the page to get one back."
|
|
154
154
|
);
|
|
155
|
-
},
|
|
155
|
+
}, G = m(f, () => {
|
|
156
156
|
var e;
|
|
157
|
-
(e =
|
|
157
|
+
(e = i.querySelector("canvas")) == null || e.addEventListener("webglcontextlost", D);
|
|
158
158
|
});
|
|
159
|
-
|
|
160
|
-
const
|
|
159
|
+
c.current.report(a && f.isSimulationRunning ? "running" : "settled");
|
|
160
|
+
const _ = setTimeout(o, Z);
|
|
161
161
|
return () => {
|
|
162
|
-
clearTimeout(
|
|
163
|
-
const e =
|
|
164
|
-
e == null || e.removeEventListener("webglcontextlost", D), f.destroy(), U(e),
|
|
162
|
+
clearTimeout(_), y(), G();
|
|
163
|
+
const e = i.querySelector("canvas");
|
|
164
|
+
e == null || e.removeEventListener("webglcontextlost", D), f.destroy(), U(e), u.current = null;
|
|
165
165
|
};
|
|
166
|
-
}, [
|
|
167
|
-
const
|
|
168
|
-
if (!(!
|
|
169
|
-
return m(
|
|
170
|
-
|
|
166
|
+
}, [u, C, k, a]), w(() => {
|
|
167
|
+
const i = u.current;
|
|
168
|
+
if (!(!i || !a || !n))
|
|
169
|
+
return m(i, (o) => {
|
|
170
|
+
o.setPointClusters(n), o.setClusterPositions(M(n, o.config.spaceSize)), o.render();
|
|
171
171
|
});
|
|
172
|
-
}, [
|
|
173
|
-
const
|
|
174
|
-
if (!(!
|
|
175
|
-
return
|
|
176
|
-
|
|
172
|
+
}, [n, u, a]), w(() => {
|
|
173
|
+
const i = u.current;
|
|
174
|
+
if (!(!i || !a || z(S.current, p)))
|
|
175
|
+
return S.current = p, m(i, (o) => {
|
|
176
|
+
o.setConfigPartial(L(p)), o.start(N);
|
|
177
177
|
});
|
|
178
|
-
}, [
|
|
178
|
+
}, [u, p, a]);
|
|
179
179
|
}
|
|
180
|
-
|
|
180
|
+
function z(r, n) {
|
|
181
|
+
return r === n || r.gravity === n.gravity && r.repulsion === n.repulsion && r.linkSpring === n.linkSpring && r.linkDistance === n.linkDistance && r.friction === n.friction && r.cluster === n.cluster;
|
|
182
|
+
}
|
|
183
|
+
const N = 0.35, q = 450, Y = 0.18, Z = 6e3, v = 20;
|
|
181
184
|
export {
|
|
182
|
-
|
|
183
|
-
|
|
185
|
+
N as REHEAT,
|
|
186
|
+
X as useRenderer
|
|
184
187
|
};
|
|
185
188
|
//# sourceMappingURL=use-renderer.js.map
|
package/dist/use-renderer.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-renderer.js","sources":["../src/use-renderer.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useRef, type RefObject } from \"react\";\nimport { Graph } from \"@cosmos.gl/graph\";\nimport { clusterRing } from \"./cluster-ring\";\nimport { forces } from \"./graph-model\";\nimport { DEFAULT_SIM, type Sim } from \"./graph-sim\";\nimport type { Motion } from \"./types\";\nimport { whenReady } from \"./when-ready\";\n\n/**\n * The renderer's whole life: built once, told what the forces are, destroyed on the way out.\n *\n * **It no longer knows anything about the data**, and that is the change ADR-0001 forced rather than\n * a tidy-up. Construction used to depend on a `Loaded`, which was harmless when the data arrived\n * once; under a bounded path a slice arrives on every camera move, and a construction effect keyed\n * on it would tear down and rebuild a WebGL context per pan. Geometry is pushed in by\n * `useQueryLoop`; the instance outlives every slice it draws.\n *\n * Everything about the picture — colours, sizes, shapes, the look, the camera, and since 3.0 even\n * whether a simulation runs at all — is a `setConfigPartial` somewhere else. **Three fields are\n * genuinely init-only**, and they are the three `preserveInitOnlyFields` restores after every config\n * write: `initialZoomLevel`, `randomSeed` and `attribution`.\n *\n * The callbacks are handed over exactly once, which is why every one of them reads the present\n * through a ref rather than a closure.\n */\n\n/**\n * Whether this browser can run the renderer at all.\n *\n * Asked rather than inferred, because the failure is silent in both directions. cosmos.gl draws its\n * own English message into the host element rather than throwing, so the `try/catch` around the\n * constructor was catching a case that cannot reach it. Worse under 3.x: device creation is\n * asynchronous and `graph.ready` has **no failure path** — when the device cannot be made it does\n * not reject, it simply never settles, so a caller awaiting it waits forever with nothing on screen.\n *\n * So the probe stays, and it answers the common case before any of that can happen. The catch stays\n * for real construction faults.\n */\nfunction hasWebGL(): boolean {\n if (typeof document === \"undefined\") return false;\n try {\n const probe = document.createElement(\"canvas\");\n return probe.getContext(\"webgl2\") !== null || probe.getContext(\"webgl\") !== null;\n } catch {\n return false;\n }\n}\n\n/**\n * **Give the WebGL context back, because cosmos.gl does not.**\n *\n * `destroy()` frees its own buffers and textures and leaves the context itself attached to the\n * canvas — `WEBGL_lose_context` and `loseContext` appear **zero times** in `@cosmos.gl/graph@3.4.0`.\n * A context released only by garbage collection is a context held for an unbounded time, and a\n * browser has a hard budget for them: Chrome keeps sixteen per renderer process and **evicts the\n * oldest** when a seventeenth is asked for. Eviction is not an error anywhere — the canvas simply\n * stops painting, `getPointPositions()` reads back empty, and `getZoomLevel()` answers zero.\n *\n * That is not a large-application problem. `/docs/graph` mounts four graphs, React's StrictMode runs\n * every effect setup → cleanup → setup, so eight contexts are created and four are orphaned on a\n * single page load — and three of the four canvases measured `isContextLost === true` while the\n * newest one drew. `/view/showcases/graph-bench` has one graph and has always looked fine, which is\n * how this survived: the bug is invisible until a page holds more than one.\n *\n * The extension is absent on some drivers and the context may already be lost, and neither is worth\n * reporting: this is a release, and a release that cannot happen has nothing to say.\n */\nfunction releaseContext(canvas: HTMLCanvasElement | null): void {\n const gl = canvas?.getContext(\"webgl2\") ?? canvas?.getContext(\"webgl\");\n if (gl && !gl.isContextLost()) gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n}\n\nexport interface RendererOptions {\n /** The element cosmos.gl mounts its canvas into. */\n hostRef: RefObject<HTMLDivElement | null>;\n /**\n * Where to put the instance.\n *\n * Given rather than returned, because the overlays, the gesture and the query loop all need a way\n * to reach the graph and this hook needs their callbacks — returning it would make the\n * declarations circular.\n */\n graphRef: RefObject<Graph | null>;\n /**\n * Whether to run a live layout, and it **defaults to off**.\n *\n * ADR-0001's premise: positions are authority. A bounded source hands back coordinates that the\n * next spatial query is expressed in, so a force that moves them moves the picture out from under\n * its own index — the camera drifts away from the corpus within a frame. Off is therefore the\n * correct default and not a conservative one.\n *\n * On is for the other host: arrays in hand, no precomputed layout, few enough points that a live\n * simulation is the cheapest way to get one. That host has no spatial index to disagree with.\n */\n simulate?: boolean;\n /**\n * Cluster assignment per drawn point, when a live layout should group them.\n *\n * Only meaningful under `simulate` — it is a force, not a colour. `undefined` at a position means\n * *no group*, which is not group zero: a vertex shared by every group belongs to none, and left\n * unclustered it drifts between the ones it joins.\n */\n clusters?: (number | undefined)[];\n /** Defaults to `DEFAULT_SIM`, which is the same table the exported constant carries. */\n sim?: Sim;\n /** Where to report what the layout is doing. Optional: a host with no motion badge wants none. */\n report?: (motion: Motion) => void;\n /**\n * How far through settling the layout is, `0`–`1`.\n *\n * cosmos.gl computes it every tick as `√(min(1, ALPHA_MIN / alpha))` and exposes it as\n * `graph.progress`, so a determinate badge costs nothing to compute — only to deliver. Quantised\n * before it is reported, because this is React state read through context and a write per\n * animation frame would re-render every consumer 60 times a second to move a number by half a\n * percent.\n */\n reportProgress?: (value: number) => void;\n onFailure: (message: string) => void;\n /**\n * Callbacks handed to cosmos.gl once, at construction — **all optional, and so is the block**.\n *\n * They were required, and a graph that only wants to be *looked at* had to write seven no-ops to\n * say so. A picture with no interaction is a legitimate picture, and the seven that report a\n * gesture nobody handles have exactly one sensible default. `onFailure` is the one that stays\n * required, deliberately: it is the only callback whose silence is a defect rather than a\n * choice — unhandled, a browser with no WebGL context shows an empty box and says nothing.\n */\n events?: {\n onPointerOver?: (index: number) => void;\n onPointerOut?: () => void;\n onPointClick?: (graph: Graph, index: number) => void;\n onBackgroundClick?: () => void;\n /**\n * A node has been let go of, by index.\n *\n * cosmos.gl does not say which one. Its drag subject is `{x, y}`, and `store.draggingPointIndex`\n * is cleared *before* `onDragEnd` is called — so the index is remembered from the hover that\n * made the drag possible in the first place: the drag behaviour's subject only answers while\n * `store.hoveredPoint` is set, and hover detection is skipped for the whole gesture.\n */\n onDragEnd?: (index: number) => void;\n onTick?: () => void;\n /** The camera moved. The query loop is wired here — this is how a bounded graph is asked again. */\n onZoom?: () => void;\n };\n}\n\n/**\n * The defaults for everything a picture-only host does not care about.\n *\n * Frozen module constants rather than object literals in the destructure: a fresh `{}` per render\n * would be a new identity for `live.current` and for `applied`, which is the class of bug the ref\n * indirection below exists to avoid in the first place.\n */\nconst noop = () => {};\nconst EMPTY_EVENTS: NonNullable<RendererOptions[\"events\"]> = Object.freeze({});\n\nexport function useRenderer(options: RendererOptions): void {\n const {\n clusters,\n events = EMPTY_EVENTS,\n graphRef,\n hostRef,\n onFailure,\n report = noop,\n reportProgress = noop,\n sim = DEFAULT_SIM,\n simulate = false,\n } = options;\n\n /**\n * The coefficients the graph is currently running with.\n *\n * The constructor takes them and the effect below re-applies them when they move — so this is not\n * a \"first render\" flag, it is the answer to *what does the simulation already have?*, which is\n * the question both places are asking.\n */\n const applied = useRef(sim);\n const live = useRef(events);\n live.current = events;\n\n /**\n * **The caller's callbacks, held rather than depended on.**\n *\n * `onFailure`, `report` and `reportProgress` used to sit in the construction effect's dependency\n * list, which made the renderer's lifetime a function of a caller's *render*. `onFailure` is\n * required by this package and every consumer writes it inline — `onFailure={(e) => setFailure(e)}`\n * is the obvious spelling — so every render was a new identity, and every new identity destroyed\n * the graph and built another.\n *\n * Measured on `/docs/graph`: **142 `destroy()` calls in five seconds** with nobody touching the\n * page, `setPointPositions` called **zero** times, and `getGraph()` answering a different instance\n * each time it was asked. Nothing painted, because no instance lived long enough to be given\n * geometry — and at roughly twenty-eight rebuilds a second it also burned the browser's\n * sixteen-context budget continuously, which is why the *other three* graphs on the page were\n * blank too. One example with an inline callback starved the page.\n *\n * A ref rather than `useCallback` at the call site: a rule that every consumer must memoise a\n * required callback is a rule nobody remembers, and its failure is silent.\n */\n const callbacks = useRef({ onFailure, report, reportProgress });\n callbacks.current = { onFailure, report, reportProgress };\n\n /**\n * Whether the camera has been put where the layout is.\n *\n * `fitViewOnInit` frames the graph at `fitViewDelay`, a second in, while a simulation is still\n * contracting — so by the time it converges the picture has shrunk to a blob in the middle of an\n * empty canvas. The early fit is still worth having, because a second of *something* beats a\n * second of nothing; it just is not the last word. This one is: once, and never again — re-framing\n * on later settles would yank the camera out from under whoever nudged a slider, and under a\n * bounded path it would fight the reader's own panning.\n */\n const framed = useRef(false);\n\n /** The last bucket handed on, so a tick that has not moved the badge costs nothing. */\n const reported = useRef(-1);\n const progress = useCallback((value: number) => {\n const bucket = Math.round(value * PROGRESS_STEPS);\n if (bucket === reported.current) return;\n reported.current = bucket;\n callbacks.current.reportProgress(bucket / PROGRESS_STEPS);\n }, []);\n\n useEffect(() => {\n const host = hostRef.current;\n if (!host) return;\n framed.current = false;\n if (!hasWebGL()) {\n callbacks.current.onFailure(\"This canvas renders on the GPU, and this browser offers no WebGL context.\");\n return;\n }\n\n /** Put the camera where the layout ended up — once, whoever gets here first. */\n const frameOnce = () => {\n if (framed.current) return;\n framed.current = true;\n const graph = graphRef.current;\n if (graph) whenReady(graph, (ready) => ready.fitView(FIT_DURATION, FIT_PADDING));\n };\n\n // Which node the pointer is on, and which one the current gesture picked up. Locals rather than\n // refs: they are read and written only by callbacks this closure owns, and they die with the\n // instance those callbacks belong to.\n let hovering: number | null = null;\n let dragging: number | null = null;\n\n let graph: Graph;\n try {\n graph = new Graph(host, {\n /**\n * **No `spaceSize` here, and that is the point.**\n *\n * The coordinate box belongs to whatever wrote the positions, not to the thing drawing\n * them. We used to declare it — one exported `SPACE = 4096`, copied by hand into both bench\n * generators — and a corpus fossil writes ignored it completely: a million vertices span\n * about x ∈ [−345, 645396], 157× the box the renderer was announcing. Nothing announced the\n * disagreement, because `spaceSize` enters every render path as a translation and the camera\n * is fitted from the extent anyway; the box was simply a false statement.\n *\n * So the box arrives with the first `extent()` — `useQueryLoop` sets it where it already\n * awaits one — and until then cosmos.gl's own default stands. A default of ours would be a\n * second way to answer a question one side already owns.\n */\n enableSimulation: simulate,\n ...forces(applied.current),\n /**\n * Frames to convergence — not milliseconds.\n *\n * `store.alphaDecay` is `α ⇒ 1 − 0.001^(1/α)` with `alphaTarget` 0, and one tick is one\n * rendered frame (`runSimulationStep` is called from `renderFrame`), so alpha decays as\n * `0.001^(n/α)` and reaches the `1e-3` floor after exactly `simulationDecay` frames — 400 ≈\n * 6.7 s at 60 fps, against cosmos.gl's default of 5,000 ≈ 83 s.\n */\n simulationDecay: 400,\n /**\n * Same seed, same picture. cosmos.gl's own randomness is not otherwise deterministic: the\n * per-link distance variation and the ±1e-5 jitter the force programs sow read\n * `store.random`, which is unseeded unless this is set. Init-only; `setConfig` cannot change\n * it.\n */\n randomSeed: \"kanzo-discovery\",\n /**\n * The device's, not cosmos.gl's literal `2`.\n *\n * It sizes the drawing buffer (`canvas.width = width * pixelRatio`) and divides the hardware\n * point-size limit into `maxPointSize`. Left at the default, a 1× display supersamples 4× for\n * nothing and a 3× display draws a canvas softer than the page it sits in.\n */\n pixelRatio: window.devicePixelRatio || 1,\n enableDrag: true,\n fitViewOnInit: true,\n fitViewDelay: 900,\n fitViewPadding: 0.18,\n hoveredPointCursor: \"pointer\",\n attribution: \"\",\n onSimulationStart: () => callbacks.current.report(\"running\"),\n onSimulationEnd: () => {\n callbacks.current.report(\"settled\");\n progress(1);\n frameOnce();\n live.current.onTick?.();\n },\n onSimulationPause: () => callbacks.current.report(\"paused\"),\n onSimulationUnpause: () => callbacks.current.report(\"running\"),\n onSimulationTick: () => {\n const instance = graphRef.current;\n if (instance) progress(instance.progress);\n live.current.onTick?.();\n },\n onZoom: () => live.current.onZoom?.(),\n onPointMouseOver: (index) => {\n hovering = index;\n live.current.onPointerOver?.(index);\n },\n onPointMouseOut: () => {\n hovering = null;\n live.current.onPointerOut?.();\n },\n onDragStart: () => {\n dragging = hovering;\n },\n onDragEnd: () => {\n if (dragging !== null) live.current.onDragEnd?.(dragging);\n dragging = null;\n },\n onPointClick: (index) => {\n const instance = graphRef.current;\n if (instance) live.current.onPointClick?.(instance, index);\n },\n onBackgroundClick: () => live.current.onBackgroundClick?.(),\n });\n } catch (error) {\n callbacks.current.onFailure(`The renderer failed to start. (${String(error)})`);\n return;\n }\n graphRef.current = graph;\n const painted = whenReady(graph, (ready) => ready.render());\n /**\n * **A lost context is silent, permanent, and indistinguishable from a graph with no data.**\n *\n * Losing one is not an exception: the canvas stops painting, `getPointPositions()` reads back\n * empty and `getZoomLevel()` answers zero, while every setter keeps accepting arrays. Measured\n * on `/docs/graph`, where three of four canvases sat at `isContextLost === true` with a badge\n * beside each reporting a full slice — and the renderer had **fifteen free slots at the time**,\n * because the loss happened during the load and nothing brings a context back.\n *\n * `preventDefault()` is what asks the browser to try a restore at all; without it there is no\n * `webglcontextrestored` event to hear. We do not rebuild on it yet — that means re-uploading\n * every buffer from a slice this hook does not hold — so the honest thing is to say so through\n * the one callback this package makes required, and `/docs/design/graph` carries what a rebuild\n * would take. An empty box that explains itself is the floor, not the ceiling.\n */\n const onLost = (event: Event) => {\n event.preventDefault();\n callbacks.current.onFailure(\n \"The graph's WebGL context was lost. A browser keeps a limited number of them and drops the oldest; reload the page to get one back.\",\n );\n };\n // **Attached inside `whenReady`, and the first version of this was attached outside it and did\n // nothing.** cosmos.gl creates its canvas with the device, which is asynchronous — so\n // `host.querySelector(\"canvas\")` in this line's position answers `null`, the listener goes on\n // nothing, and the release below frees nothing. It looked correct and changed no behaviour at\n // all, which is the second time on this hook that a device call has been written as though the\n // instance were ready.\n const listening = whenReady(graph, () => {\n host.querySelector(\"canvas\")?.addEventListener(\"webglcontextlost\", onLost);\n });\n // Without a simulation there is nothing to settle and nothing to wait for, so the badge starts\n // where it ends. With one, construction fires no `onSimulationStart` — the graph is already\n // turning by the time we get here, and without this the transport opens showing Play over a\n // moving graph.\n callbacks.current.report(simulate && graph.isSimulationRunning ? \"running\" : \"settled\");\n const floor = setTimeout(frameOnce, FRAME_BY);\n return () => {\n clearTimeout(floor);\n painted();\n listening();\n // Read here rather than remembered from construction, and before `destroy()` rather than\n // after: the element does not exist until the device does, and it goes away with the graph.\n const canvas = host.querySelector(\"canvas\");\n canvas?.removeEventListener(\"webglcontextlost\", onLost);\n graph.destroy();\n releaseContext(canvas);\n graphRef.current = null;\n };\n // **`onFailure` and `report` are deliberately absent**, and the ref above says why: a renderer\n // whose lifetime follows a caller's render identity is a renderer that never lives long enough\n // to be given anything. `simulate` stays, because it is a construction option — and `progress`\n // stays because it is a `useCallback` over an empty list that reads the ref itself, so it is\n // stable by construction rather than by a caller remembering to make it so.\n }, [graphRef, hostRef, progress, simulate]);\n\n /**\n * Cluster seeding, and only under a live layout — it is a force, not a colour.\n *\n * Both calls or neither is worth having: clusters without positions is a pull toward a centroid\n * that moves with its own group. Neither flushes anything on its own; the next `render()` does,\n * and the query loop renders on every slice.\n */\n useEffect(() => {\n const graph = graphRef.current;\n if (!graph || !simulate || !clusters) return;\n return whenReady(graph, (ready) => {\n ready.setPointClusters(clusters);\n // The box the ring is placed in is the renderer's live one — `graph.config` is always fully\n // populated, so this reads either cosmos.gl's default or the extent `useQueryLoop` set.\n // Read after `ready` for the same reason it is written after it: before the device, the\n // config is cosmos.gl's default and the ring would be sized for a box nobody is drawing in.\n ready.setClusterPositions(clusterRing(clusters, ready.config.spaceSize));\n ready.render();\n });\n }, [clusters, graphRef, simulate]);\n\n // A change in the forces re-heats: the point of a live layout is that you can feel the parameter.\n // Equality on mount is what keeps a re-render from disturbing a settled graph.\n useEffect(() => {\n const graph = graphRef.current;\n if (!graph || !simulate || applied.current === sim) return;\n applied.current = sim;\n return whenReady(graph, (ready) => {\n ready.setConfigPartial(forces(sim));\n ready.start(REHEAT);\n });\n }, [graphRef, sim, simulate]);\n}\n\n/**\n * The energy a wake puts back into a converged layout — enough to reorganise around a changed force,\n * not so much that the picture you were reading is thrown away. A full `start(1)` is what Re-run is\n * for.\n */\nexport const REHEAT = 0.35;\n\n/** The settle fit: long enough to read as the camera moving, and the same air the init fit leaves. */\nconst FIT_DURATION = 450;\nconst FIT_PADDING = 0.18;\n\n/**\n * When the camera frames the view — 6 s after the graph is built.\n *\n * Late enough that a layout has done its spreading and contracting, early enough that nobody has\n * started reading the wrong framing. It is a wall clock, so it is also the answer for a display that\n * is not running at 60 fps, where a settle at `simulationDecay` frames arrives late.\n */\nconst FRAME_BY = 6000;\n\n/**\n * How finely settling progress is reported: twentieths, so a settle costs 20 renders of everything\n * reading the graph's context rather than one per frame.\n */\nconst PROGRESS_STEPS = 20;\n"],"names":[],"mappings":";;;;;;;AAwCA;AACE;AACA;AACE;AACA;AAA4E;AAE5E;AAAO;AAEX;AAqBA;;AACE;AACA;AACF;AAoFA;AAAoB;AAGb;AACL;AAAM;AACJ;AACS;AACT;AACA;AACA;AACS;AACQ;AACX;AACK;AAYb;AAqBA;AACA;AAYA;AAKE;AACA;AAEwD;AAG1D;AACE;AACA;AAEA;AACE;AACA;AAAA;AAIF;AACE;AACA;AACA;AACA;AAA+E;AAMjF;AAIA;AACE;AAAwB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAeJ;AACO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASR;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOL;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQ2B;AAC3B;AACG;AACD;AACE;AACI;AACP;AAC8C;;AAEzD;AAGA;AACF;AAC0D;AACG;;AAE3D;AACA;AACA;AACF;;AACc;AAAA;AAAA;;AAEZ;AAC6B;AAC/B;;AAEE;AACA;AACF;AAEE;AAAW;AACb;;AAEE;AACW;AACb;;AAEE;AACA;AAAoD;AACtD;;AACyB;AAAA;AAAA;AAC1B;AAED;AACA;AAAA;AAEF;AACA;AAiBE;AACkB;AAChB;AAAA;;AAUF;AAAmE;AAMrE;AACA;AACA;AACE;AAKA;AACA;AAGmB;AACrB;AAgBA;AACA;AACA;AACE;AAMM;AACP;AAMD;AACA;AACA;AAEE;AACkB;AACnB;AAEL;AAOO;;;;;"}
|
|
1
|
+
{"version":3,"file":"use-renderer.js","sources":["../src/use-renderer.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useRef, type RefObject } from \"react\";\nimport { Graph } from \"@cosmos.gl/graph\";\nimport { clusterRing } from \"./cluster-ring\";\nimport { forces } from \"./graph-model\";\nimport { DEFAULT_SIM, type Sim } from \"./graph-sim\";\nimport type { Motion } from \"./types\";\nimport { whenReady } from \"./when-ready\";\n\n/**\n * The renderer's whole life: built once, told what the forces are, destroyed on the way out.\n *\n * **It no longer knows anything about the data**, and that is the change ADR-0001 forced rather than\n * a tidy-up. Construction used to depend on a `Loaded`, which was harmless when the data arrived\n * once; under a bounded path a slice arrives on every camera move, and a construction effect keyed\n * on it would tear down and rebuild a WebGL context per pan. Geometry is pushed in by\n * `useQueryLoop`; the instance outlives every slice it draws.\n *\n * Everything about the picture — colours, sizes, shapes, the look, the camera, and since 3.0 even\n * whether a simulation runs at all — is a `setConfigPartial` somewhere else. **Three fields are\n * genuinely init-only**, and they are the three `preserveInitOnlyFields` restores after every config\n * write: `initialZoomLevel`, `randomSeed` and `attribution`.\n *\n * The callbacks are handed over exactly once, which is why every one of them reads the present\n * through a ref rather than a closure.\n */\n\n/**\n * Whether this browser can run the renderer at all.\n *\n * Asked rather than inferred, because the failure is silent in both directions. cosmos.gl draws its\n * own English message into the host element rather than throwing, so the `try/catch` around the\n * constructor was catching a case that cannot reach it. Worse under 3.x: device creation is\n * asynchronous and `graph.ready` has **no failure path** — when the device cannot be made it does\n * not reject, it simply never settles, so a caller awaiting it waits forever with nothing on screen.\n *\n * So the probe stays, and it answers the common case before any of that can happen. The catch stays\n * for real construction faults.\n */\nfunction hasWebGL(): boolean {\n if (typeof document === \"undefined\") return false;\n try {\n const probe = document.createElement(\"canvas\");\n return probe.getContext(\"webgl2\") !== null || probe.getContext(\"webgl\") !== null;\n } catch {\n return false;\n }\n}\n\n/**\n * **Give the WebGL context back, because cosmos.gl does not.**\n *\n * `destroy()` frees its own buffers and textures and leaves the context itself attached to the\n * canvas — `WEBGL_lose_context` and `loseContext` appear **zero times** in `@cosmos.gl/graph@3.4.0`.\n * A context released only by garbage collection is a context held for an unbounded time, and a\n * browser has a hard budget for them: Chrome keeps sixteen per renderer process and **evicts the\n * oldest** when a seventeenth is asked for. Eviction is not an error anywhere — the canvas simply\n * stops painting, `getPointPositions()` reads back empty, and `getZoomLevel()` answers zero.\n *\n * That is not a large-application problem. `/docs/graph` mounts four graphs, React's StrictMode runs\n * every effect setup → cleanup → setup, so eight contexts are created and four are orphaned on a\n * single page load — and three of the four canvases measured `isContextLost === true` while the\n * newest one drew. `/view/showcases/graph-bench` has one graph and has always looked fine, which is\n * how this survived: the bug is invisible until a page holds more than one.\n *\n * The extension is absent on some drivers and the context may already be lost, and neither is worth\n * reporting: this is a release, and a release that cannot happen has nothing to say.\n */\nfunction releaseContext(canvas: HTMLCanvasElement | null): void {\n const gl = canvas?.getContext(\"webgl2\") ?? canvas?.getContext(\"webgl\");\n if (gl && !gl.isContextLost()) gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n}\n\nexport interface RendererOptions {\n /** The element cosmos.gl mounts its canvas into. */\n hostRef: RefObject<HTMLDivElement | null>;\n /**\n * Where to put the instance.\n *\n * Given rather than returned, because the overlays, the gesture and the query loop all need a way\n * to reach the graph and this hook needs their callbacks — returning it would make the\n * declarations circular.\n */\n graphRef: RefObject<Graph | null>;\n /**\n * Whether to run a live layout, and it **defaults to off**.\n *\n * ADR-0001's premise: positions are authority. A bounded source hands back coordinates that the\n * next spatial query is expressed in, so a force that moves them moves the picture out from under\n * its own index — the camera drifts away from the corpus within a frame. Off is therefore the\n * correct default and not a conservative one.\n *\n * On is for the other host: arrays in hand, no precomputed layout, few enough points that a live\n * simulation is the cheapest way to get one. That host has no spatial index to disagree with.\n */\n simulate?: boolean;\n /**\n * Cluster assignment per drawn point, when a live layout should group them.\n *\n * Only meaningful under `simulate` — it is a force, not a colour. `undefined` at a position means\n * *no group*, which is not group zero: a vertex shared by every group belongs to none, and left\n * unclustered it drifts between the ones it joins.\n */\n clusters?: (number | undefined)[];\n /** Defaults to `DEFAULT_SIM`, which is the same table the exported constant carries. */\n sim?: Sim;\n /** Where to report what the layout is doing. Optional: a host with no motion badge wants none. */\n report?: (motion: Motion) => void;\n /**\n * How far through settling the layout is, `0`–`1`.\n *\n * cosmos.gl computes it every tick as `√(min(1, ALPHA_MIN / alpha))` and exposes it as\n * `graph.progress`, so a determinate badge costs nothing to compute — only to deliver. Quantised\n * before it is reported, because this is React state read through context and a write per\n * animation frame would re-render every consumer 60 times a second to move a number by half a\n * percent.\n */\n reportProgress?: (value: number) => void;\n onFailure: (message: string) => void;\n /**\n * Callbacks handed to cosmos.gl once, at construction — **all optional, and so is the block**.\n *\n * They were required, and a graph that only wants to be *looked at* had to write seven no-ops to\n * say so. A picture with no interaction is a legitimate picture, and the seven that report a\n * gesture nobody handles have exactly one sensible default. `onFailure` is the one that stays\n * required, deliberately: it is the only callback whose silence is a defect rather than a\n * choice — unhandled, a browser with no WebGL context shows an empty box and says nothing.\n */\n events?: {\n onPointerOver?: (index: number) => void;\n onPointerOut?: () => void;\n onPointClick?: (graph: Graph, index: number) => void;\n onBackgroundClick?: () => void;\n /**\n * A node has been let go of, by index.\n *\n * cosmos.gl does not say which one. Its drag subject is `{x, y}`, and `store.draggingPointIndex`\n * is cleared *before* `onDragEnd` is called — so the index is remembered from the hover that\n * made the drag possible in the first place: the drag behaviour's subject only answers while\n * `store.hoveredPoint` is set, and hover detection is skipped for the whole gesture.\n */\n onDragEnd?: (index: number) => void;\n onTick?: () => void;\n /** The camera moved. The query loop is wired here — this is how a bounded graph is asked again. */\n onZoom?: () => void;\n };\n}\n\n/**\n * The defaults for everything a picture-only host does not care about.\n *\n * Frozen module constants rather than object literals in the destructure: a fresh `{}` per render\n * would be a new identity for `live.current` and for `applied`, which is the class of bug the ref\n * indirection below exists to avoid in the first place.\n */\nconst noop = () => {};\nconst EMPTY_EVENTS: NonNullable<RendererOptions[\"events\"]> = Object.freeze({});\n\nexport function useRenderer(options: RendererOptions): void {\n const {\n clusters,\n events = EMPTY_EVENTS,\n graphRef,\n hostRef,\n onFailure,\n report = noop,\n reportProgress = noop,\n sim = DEFAULT_SIM,\n simulate = false,\n } = options;\n\n /**\n * The coefficients the graph is currently running with.\n *\n * The constructor takes them and the effect below re-applies them when they move — so this is not\n * a \"first render\" flag, it is the answer to *what does the simulation already have?*, which is\n * the question both places are asking.\n */\n const applied = useRef(sim);\n const live = useRef(events);\n live.current = events;\n\n /**\n * **The caller's callbacks, held rather than depended on.**\n *\n * `onFailure`, `report` and `reportProgress` used to sit in the construction effect's dependency\n * list, which made the renderer's lifetime a function of a caller's *render*. `onFailure` is\n * required by this package and every consumer writes it inline — `onFailure={(e) => setFailure(e)}`\n * is the obvious spelling — so every render was a new identity, and every new identity destroyed\n * the graph and built another.\n *\n * Measured on `/docs/graph`: **142 `destroy()` calls in five seconds** with nobody touching the\n * page, `setPointPositions` called **zero** times, and `getGraph()` answering a different instance\n * each time it was asked. Nothing painted, because no instance lived long enough to be given\n * geometry — and at roughly twenty-eight rebuilds a second it also burned the browser's\n * sixteen-context budget continuously, which is why the *other three* graphs on the page were\n * blank too. One example with an inline callback starved the page.\n *\n * A ref rather than `useCallback` at the call site: a rule that every consumer must memoise a\n * required callback is a rule nobody remembers, and its failure is silent.\n */\n const callbacks = useRef({ onFailure, report, reportProgress });\n callbacks.current = { onFailure, report, reportProgress };\n\n /**\n * Whether the camera has been put where the layout is.\n *\n * `fitViewOnInit` frames the graph at `fitViewDelay`, a second in, while a simulation is still\n * contracting — so by the time it converges the picture has shrunk to a blob in the middle of an\n * empty canvas. The early fit is still worth having, because a second of *something* beats a\n * second of nothing; it just is not the last word. This one is: once, and never again — re-framing\n * on later settles would yank the camera out from under whoever nudged a slider, and under a\n * bounded path it would fight the reader's own panning.\n */\n const framed = useRef(false);\n\n /** The last bucket handed on, so a tick that has not moved the badge costs nothing. */\n const reported = useRef(-1);\n const progress = useCallback((value: number) => {\n const bucket = Math.round(value * PROGRESS_STEPS);\n if (bucket === reported.current) return;\n reported.current = bucket;\n callbacks.current.reportProgress(bucket / PROGRESS_STEPS);\n }, []);\n\n useEffect(() => {\n const host = hostRef.current;\n if (!host) return;\n framed.current = false;\n if (!hasWebGL()) {\n callbacks.current.onFailure(\"This canvas renders on the GPU, and this browser offers no WebGL context.\");\n return;\n }\n\n /** Put the camera where the layout ended up — once, whoever gets here first. */\n const frameOnce = () => {\n if (framed.current) return;\n framed.current = true;\n const graph = graphRef.current;\n if (graph) whenReady(graph, (ready) => ready.fitView(FIT_DURATION, FIT_PADDING));\n };\n\n // Which node the pointer is on, and which one the current gesture picked up. Locals rather than\n // refs: they are read and written only by callbacks this closure owns, and they die with the\n // instance those callbacks belong to.\n let hovering: number | null = null;\n let dragging: number | null = null;\n\n let graph: Graph;\n try {\n graph = new Graph(host, {\n /**\n * **No `spaceSize` here, and that is the point.**\n *\n * The coordinate box belongs to whatever wrote the positions, not to the thing drawing\n * them. We used to declare it — one exported `SPACE = 4096`, copied by hand into both bench\n * generators — and a corpus fossil writes ignored it completely: a million vertices span\n * about x ∈ [−345, 645396], 157× the box the renderer was announcing. Nothing announced the\n * disagreement, because `spaceSize` enters every render path as a translation and the camera\n * is fitted from the extent anyway; the box was simply a false statement.\n *\n * So the box arrives with the first `extent()` — `useQueryLoop` sets it where it already\n * awaits one — and until then cosmos.gl's own default stands. A default of ours would be a\n * second way to answer a question one side already owns.\n */\n enableSimulation: simulate,\n ...forces(applied.current),\n /**\n * Frames to convergence — not milliseconds.\n *\n * `store.alphaDecay` is `α ⇒ 1 − 0.001^(1/α)` with `alphaTarget` 0, and one tick is one\n * rendered frame (`runSimulationStep` is called from `renderFrame`), so alpha decays as\n * `0.001^(n/α)` and reaches the `1e-3` floor after exactly `simulationDecay` frames — 400 ≈\n * 6.7 s at 60 fps, against cosmos.gl's default of 5,000 ≈ 83 s.\n */\n simulationDecay: 400,\n /**\n * Same seed, same picture. cosmos.gl's own randomness is not otherwise deterministic: the\n * per-link distance variation and the ±1e-5 jitter the force programs sow read\n * `store.random`, which is unseeded unless this is set. Init-only; `setConfig` cannot change\n * it.\n */\n randomSeed: \"kanzo-discovery\",\n /**\n * The device's, not cosmos.gl's literal `2`.\n *\n * It sizes the drawing buffer (`canvas.width = width * pixelRatio`) and divides the hardware\n * point-size limit into `maxPointSize`. Left at the default, a 1× display supersamples 4× for\n * nothing and a 3× display draws a canvas softer than the page it sits in.\n */\n pixelRatio: window.devicePixelRatio || 1,\n enableDrag: true,\n fitViewOnInit: true,\n fitViewDelay: 900,\n fitViewPadding: 0.18,\n hoveredPointCursor: \"pointer\",\n attribution: \"\",\n onSimulationStart: () => callbacks.current.report(\"running\"),\n onSimulationEnd: () => {\n callbacks.current.report(\"settled\");\n progress(1);\n frameOnce();\n live.current.onTick?.();\n },\n onSimulationPause: () => callbacks.current.report(\"paused\"),\n onSimulationUnpause: () => callbacks.current.report(\"running\"),\n onSimulationTick: () => {\n const instance = graphRef.current;\n if (instance) progress(instance.progress);\n live.current.onTick?.();\n },\n onZoom: () => live.current.onZoom?.(),\n onPointMouseOver: (index) => {\n hovering = index;\n live.current.onPointerOver?.(index);\n },\n onPointMouseOut: () => {\n hovering = null;\n live.current.onPointerOut?.();\n },\n onDragStart: () => {\n dragging = hovering;\n },\n onDragEnd: () => {\n if (dragging !== null) live.current.onDragEnd?.(dragging);\n dragging = null;\n },\n onPointClick: (index) => {\n const instance = graphRef.current;\n if (instance) live.current.onPointClick?.(instance, index);\n },\n onBackgroundClick: () => live.current.onBackgroundClick?.(),\n });\n } catch (error) {\n callbacks.current.onFailure(`The renderer failed to start. (${String(error)})`);\n return;\n }\n graphRef.current = graph;\n const painted = whenReady(graph, (ready) => ready.render());\n /**\n * **A lost context is silent, permanent, and indistinguishable from a graph with no data.**\n *\n * Losing one is not an exception: the canvas stops painting, `getPointPositions()` reads back\n * empty and `getZoomLevel()` answers zero, while every setter keeps accepting arrays. Measured\n * on `/docs/graph`, where three of four canvases sat at `isContextLost === true` with a badge\n * beside each reporting a full slice — and the renderer had **fifteen free slots at the time**,\n * because the loss happened during the load and nothing brings a context back.\n *\n * `preventDefault()` is what asks the browser to try a restore at all; without it there is no\n * `webglcontextrestored` event to hear. We do not rebuild on it yet — that means re-uploading\n * every buffer from a slice this hook does not hold — so the honest thing is to say so through\n * the one callback this package makes required, and `/docs/design/graph` carries what a rebuild\n * would take. An empty box that explains itself is the floor, not the ceiling.\n */\n const onLost = (event: Event) => {\n event.preventDefault();\n callbacks.current.onFailure(\n \"The graph's WebGL context was lost. A browser keeps a limited number of them and drops the oldest; reload the page to get one back.\",\n );\n };\n // **Attached inside `whenReady`, and the first version of this was attached outside it and did\n // nothing.** cosmos.gl creates its canvas with the device, which is asynchronous — so\n // `host.querySelector(\"canvas\")` in this line's position answers `null`, the listener goes on\n // nothing, and the release below frees nothing. It looked correct and changed no behaviour at\n // all, which is the second time on this hook that a device call has been written as though the\n // instance were ready.\n const listening = whenReady(graph, () => {\n host.querySelector(\"canvas\")?.addEventListener(\"webglcontextlost\", onLost);\n });\n // Without a simulation there is nothing to settle and nothing to wait for, so the badge starts\n // where it ends. With one, construction fires no `onSimulationStart` — the graph is already\n // turning by the time we get here, and without this the transport opens showing Play over a\n // moving graph.\n callbacks.current.report(simulate && graph.isSimulationRunning ? \"running\" : \"settled\");\n const floor = setTimeout(frameOnce, FRAME_BY);\n return () => {\n clearTimeout(floor);\n painted();\n listening();\n // Read here rather than remembered from construction, and before `destroy()` rather than\n // after: the element does not exist until the device does, and it goes away with the graph.\n const canvas = host.querySelector(\"canvas\");\n canvas?.removeEventListener(\"webglcontextlost\", onLost);\n graph.destroy();\n releaseContext(canvas);\n graphRef.current = null;\n };\n // **`onFailure` and `report` are deliberately absent**, and the ref above says why: a renderer\n // whose lifetime follows a caller's render identity is a renderer that never lives long enough\n // to be given anything. `simulate` stays, because it is a construction option — and `progress`\n // stays because it is a `useCallback` over an empty list that reads the ref itself, so it is\n // stable by construction rather than by a caller remembering to make it so.\n }, [graphRef, hostRef, progress, simulate]);\n\n /**\n * Cluster seeding, and only under a live layout — it is a force, not a colour.\n *\n * Both calls or neither is worth having: clusters without positions is a pull toward a centroid\n * that moves with its own group. Neither flushes anything on its own; the next `render()` does,\n * and the query loop renders on every slice.\n */\n useEffect(() => {\n const graph = graphRef.current;\n if (!graph || !simulate || !clusters) return;\n return whenReady(graph, (ready) => {\n ready.setPointClusters(clusters);\n // The box the ring is placed in is the renderer's live one — `graph.config` is always fully\n // populated, so this reads either cosmos.gl's default or the extent `useQueryLoop` set.\n // Read after `ready` for the same reason it is written after it: before the device, the\n // config is cosmos.gl's default and the ring would be sized for a box nobody is drawing in.\n ready.setClusterPositions(clusterRing(clusters, ready.config.spaceSize));\n ready.render();\n });\n }, [clusters, graphRef, simulate]);\n\n /**\n * A change in the forces re-heats: the point of a live layout is that you can feel the parameter.\n * Equality on mount is what keeps a re-render from disturbing a settled graph.\n *\n * **By value, not by reference**, and the reason is what `sim` became. It takes a `Partial<Sim>` at\n * the top of the stack now, which invites the literal a host writes without thinking —\n * `sim={{ gravity: 0.2 }}` — and a fresh object per render against a reference comparison is a\n * `start()` per render into a layout nobody touched. `useGraph` memoises the merge, so this only\n * bites where the memo cannot help; six numbers is a cheap thing to be certain about.\n */\n useEffect(() => {\n const graph = graphRef.current;\n if (!graph || !simulate || sameForces(applied.current, sim)) return;\n applied.current = sim;\n return whenReady(graph, (ready) => {\n ready.setConfigPartial(forces(sim));\n ready.start(REHEAT);\n });\n }, [graphRef, sim, simulate]);\n}\n\n/** Whether two sets of coefficients would produce the same simulation. Six numbers, all of them. */\nfunction sameForces(a: Sim, b: Sim): boolean {\n return (\n a === b ||\n (a.gravity === b.gravity &&\n a.repulsion === b.repulsion &&\n a.linkSpring === b.linkSpring &&\n a.linkDistance === b.linkDistance &&\n a.friction === b.friction &&\n a.cluster === b.cluster)\n );\n}\n\n/**\n * The energy a wake puts back into a converged layout — enough to reorganise around a changed force,\n * not so much that the picture you were reading is thrown away. A full `start(1)` is what Re-run is\n * for.\n */\nexport const REHEAT = 0.35;\n\n/** The settle fit: long enough to read as the camera moving, and the same air the init fit leaves. */\nconst FIT_DURATION = 450;\nconst FIT_PADDING = 0.18;\n\n/**\n * When the camera frames the view — 6 s after the graph is built.\n *\n * Late enough that a layout has done its spreading and contracting, early enough that nobody has\n * started reading the wrong framing. It is a wall clock, so it is also the answer for a display that\n * is not running at 60 fps, where a settle at `simulationDecay` frames arrives late.\n */\nconst FRAME_BY = 6000;\n\n/**\n * How finely settling progress is reported: twentieths, so a settle costs 20 renders of everything\n * reading the graph's context rather than one per frame.\n */\nconst PROGRESS_STEPS = 20;\n"],"names":[],"mappings":";;;;;;;AAwCA;AACE;AACA;AACE;AACA;AAA4E;AAE5E;AAAO;AAEX;AAqBA;;AACE;AACA;AACF;AAoFA;AAAoB;AAGb;AACL;AAAM;AACJ;AACS;AACT;AACA;AACA;AACS;AACQ;AACX;AACK;AAYb;AAqBA;AACA;AAYA;AAKE;AACA;AAEwD;AAG1D;AACE;AACA;AAEA;AACE;AACA;AAAA;AAIF;AACE;AACA;AACA;AACA;AAA+E;AAMjF;AAIA;AACE;AAAwB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAeJ;AACO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASR;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOL;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQ2B;AAC3B;AACG;AACD;AACE;AACI;AACP;AAC8C;;AAEzD;AAGA;AACF;AAC0D;AACG;;AAE3D;AACA;AACA;AACF;;AACc;AAAA;AAAA;;AAEZ;AAC6B;AAC/B;;AAEE;AACA;AACF;AAEE;AAAW;AACb;;AAEE;AACW;AACb;;AAEE;AACA;AAAoD;AACtD;;AACyB;AAAA;AAAA;AAC1B;AAED;AACA;AAAA;AAEF;AACA;AAiBE;AACkB;AAChB;AAAA;;AAUF;AAAmE;AAMrE;AACA;AACA;AACE;AAKA;AACA;AAGmB;AACrB;AAgBA;AACA;AACA;AACE;AAMM;AACP;AAcD;AACA;AACA;AAEE;AACkB;AACnB;AAEL;AAGA;AACE;AASF;AAOO;;;;;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kanzo-tech/graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Kanzo graph rendering — a GPU force layout over cosmos.gl, the DuckDB relation that feeds it, and the Mosaic client that keeps it inside a crossfilter. Sibling of @kanzo-tech/ui, not part of it: the admission rules exclude graphs from the generic vocabulary by name.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,20 +26,26 @@
|
|
|
26
26
|
},
|
|
27
27
|
"./package.json": "./package.json"
|
|
28
28
|
},
|
|
29
|
-
"//peers": "cosmos.gl is a real, required peer — this package is a renderer and there is nothing left of it without one. The Mosaic half is optional and lives entirely on `./duckdb
|
|
29
|
+
"//peers": "cosmos.gl is a real, required peer — this package is a renderer and there is nothing left of it without one. The Mosaic half is optional and lives entirely on `./duckdb`, and the reason moved when the second source left: it used to be `a consumer drawing a graph from arrays it already has should not pay for a database`, which stopped being true the day `memorySource` was deleted — there is no arrays-in-hand consumer to protect any more. What the split protects now is the ROOT BARREL, which is a rendering surface: hooks, looks, identity, the buffers a slice implies, and no source at all. A host embedding a canvas whose slices arrive from somewhere else — its own `BoundedSource`, a worker, a test — installs cosmos.gl and nothing else, and `openCorpus` is what costs a database. That half is now ONE entry, `@kanzo-tech/mosaic`, and the list is shorter than the thing it replaced for a reason worth keeping: this package used to reach the coordinator through `@kanzo-tech/ui/analytics`, whose barrel re-exports the React charts and `@uwdata/vgplot` with them. The import is static, so a host that installed the two peers we documented — mosaic-core and mosaic-sql — still could not open `./duckdb`, and vgplot had to be declared here to paper over a dependency this package never names. It names none of the three now: they are `@kanzo-tech/mosaic`'s peers, declared once, which is also why their version ranges can no longer disagree. @duckdb/duckdb-wasm stays absent for the reason it always was — nothing in any of our dist names it, and a host that boots its own DuckDB depends on it directly. `scripts/smoke-install.mjs` installs no optional peer and imports the root barrel, which is the check that reads this list rather than trusting it.",
|
|
30
|
+
"//fossil": "The reader is on npm, so the range is a range. This entry said LOCAL LINK, NOT A RELEASE STATE while `@fossil-lang/corpus` existed only in a checkout beside this one; it was published as `0.3.0-alpha.5` and the `link:` and the `*` went with the reason for them. The peer itself is not temporary: fossil's reader sits beside `@kanzo-tech/mosaic` on `./duckdb` and is wanted for the same reason — a host reading a corpus brings its own, and a host that only renders pays for neither. The residual cost this entry used to name is GONE rather than mitigated: `duckBoundedSource` shared the subpath and did not need this peer, so a host on it either installed the reader or the entry had to be split. There is nothing on `./duckdb` now that does not want fossil, so the subpath and the peer are the same decision and there is no entry to split.",
|
|
30
31
|
"peerDependencies": {
|
|
31
32
|
"@cosmos.gl/graph": "^3.4.0",
|
|
33
|
+
"@fossil-lang/corpus": "^0.3.0-alpha.5",
|
|
32
34
|
"react": "^19.0.0",
|
|
33
|
-
"@kanzo-tech/
|
|
34
|
-
"@kanzo-tech/
|
|
35
|
+
"@kanzo-tech/ui": "0.3.0",
|
|
36
|
+
"@kanzo-tech/mosaic": "0.3.0"
|
|
35
37
|
},
|
|
36
38
|
"peerDependenciesMeta": {
|
|
39
|
+
"@fossil-lang/corpus": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
37
42
|
"@kanzo-tech/mosaic": {
|
|
38
43
|
"optional": true
|
|
39
44
|
}
|
|
40
45
|
},
|
|
41
46
|
"devDependencies": {
|
|
42
47
|
"@cosmos.gl/graph": "^3.4.0",
|
|
48
|
+
"@fossil-lang/corpus": "^0.3.0-alpha.5",
|
|
43
49
|
"@testing-library/dom": "^10.4.1",
|
|
44
50
|
"@testing-library/react": "^16.3.2",
|
|
45
51
|
"@types/react": "^19.0.0",
|
|
@@ -50,7 +56,7 @@
|
|
|
50
56
|
"react": "^19.0.0",
|
|
51
57
|
"react-dom": "^19.0.0",
|
|
52
58
|
"rollup-plugin-preserve-directives": "^0.4.0",
|
|
53
|
-
"@kanzo-tech/mosaic": "0.
|
|
59
|
+
"@kanzo-tech/mosaic": "0.3.0"
|
|
54
60
|
},
|
|
55
61
|
"repository": {
|
|
56
62
|
"type": "git",
|
package/dist/memory-source.d.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { ExploringSource } from './bounded';
|
|
2
|
-
/**
|
|
3
|
-
* The trivial source: a host that already holds its arrays.
|
|
4
|
-
*
|
|
5
|
-
* ADR-0001 deletes `load()`, which means every consumer needs a source — including the ones for
|
|
6
|
-
* which bounding buys nothing, because their graph fits. This is that source, and it exists so that
|
|
7
|
-
* "wrap your arrays" is one import rather than a hundred lines each call site writes differently.
|
|
8
|
-
*
|
|
9
|
-
* It is also the only source we ship that is an [`ExploringSource`]. A rectangle is a map question
|
|
10
|
-
* and any relation with a spatial predicate can answer it; a neighbourhood is the graph question,
|
|
11
|
-
* and answering it needs adjacency. Having the links in hand, this one does.
|
|
12
|
-
*
|
|
13
|
-
* Everything derived is built on first use and kept: a graph small enough to hold is small enough
|
|
14
|
-
* that an adjacency list is cheap, but a host that only ever pans should not pay to build one.
|
|
15
|
-
*/
|
|
16
|
-
export interface MemoryGraph {
|
|
17
|
-
/** Who each point is, parallel to the position pairs — `vertexId(type, dense)` per point. */
|
|
18
|
-
vertices: BigUint64Array;
|
|
19
|
-
/** `[x0, y0, x1, y1, …]`. */
|
|
20
|
-
positions: Float32Array;
|
|
21
|
-
/** `[src, dst, …]` as indices into `positions`. */
|
|
22
|
-
links: Float32Array;
|
|
23
|
-
/**
|
|
24
|
-
* The subject IRI of each vertex, parallel to `vertices` — optional, and the same opt-in the SQL
|
|
25
|
-
* source makes for the same reason: a host that never names a vertex should not carry the names.
|
|
26
|
-
*/
|
|
27
|
-
subjects?: string[];
|
|
28
|
-
categories?: Uint16Array;
|
|
29
|
-
sizes?: Float32Array;
|
|
30
|
-
}
|
|
31
|
-
export declare function memorySource(graph: MemoryGraph): ExploringSource;
|
|
32
|
-
//# sourceMappingURL=memory-source.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"memory-source.d.ts","sourceRoot":"","sources":["../src/memory-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,eAAe,EAIrB,MAAM,WAAW,CAAC;AAGnB;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,WAAW;IAC1B,6FAA6F;IAC7F,QAAQ,EAAE,cAAc,CAAC;IACzB,6BAA6B;IAC7B,SAAS,EAAE,YAAY,CAAC;IACxB,mDAAmD;IACnD,KAAK,EAAE,YAAY,CAAC;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,UAAU,CAAC,EAAE,WAAW,CAAC;IACzB,KAAK,CAAC,EAAE,YAAY,CAAC;CACtB;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,WAAW,GAAG,eAAe,CA2FhE"}
|