@kanzo-tech/graph 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +94 -0
  2. package/dist/adaptive.d.ts +27 -0
  3. package/dist/adaptive.d.ts.map +1 -0
  4. package/dist/adaptive.js +25 -0
  5. package/dist/adaptive.js.map +1 -0
  6. package/dist/bounded.d.ts +314 -0
  7. package/dist/bounded.d.ts.map +1 -0
  8. package/dist/bounded.js +39 -0
  9. package/dist/bounded.js.map +1 -0
  10. package/dist/cluster-ring.d.ts +25 -0
  11. package/dist/cluster-ring.d.ts.map +1 -0
  12. package/dist/cluster-ring.js +16 -0
  13. package/dist/cluster-ring.js.map +1 -0
  14. package/dist/css-color.d.ts +11 -0
  15. package/dist/css-color.d.ts.map +1 -0
  16. package/dist/css-color.js +23 -0
  17. package/dist/css-color.js.map +1 -0
  18. package/dist/duck-source.d.ts +182 -0
  19. package/dist/duck-source.d.ts.map +1 -0
  20. package/dist/duck-source.js +413 -0
  21. package/dist/duck-source.js.map +1 -0
  22. package/dist/graph-canvas.d.ts +50 -0
  23. package/dist/graph-canvas.d.ts.map +1 -0
  24. package/dist/graph-canvas.js +35 -0
  25. package/dist/graph-canvas.js.map +1 -0
  26. package/dist/graph-looks.d.ts +146 -0
  27. package/dist/graph-looks.d.ts.map +1 -0
  28. package/dist/graph-looks.js +44 -0
  29. package/dist/graph-looks.js.map +1 -0
  30. package/dist/graph-model.d.ts +130 -0
  31. package/dist/graph-model.d.ts.map +1 -0
  32. package/dist/graph-model.js +99 -0
  33. package/dist/graph-model.js.map +1 -0
  34. package/dist/graph-sim.d.ts +40 -0
  35. package/dist/graph-sim.d.ts.map +1 -0
  36. package/dist/graph-sim.js +20 -0
  37. package/dist/graph-sim.js.map +1 -0
  38. package/dist/index.d.ts +72 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +53 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/memory-source.d.ts +32 -0
  43. package/dist/memory-source.d.ts.map +1 -0
  44. package/dist/memory-source.js +134 -0
  45. package/dist/memory-source.js.map +1 -0
  46. package/dist/obligations.d.ts +62 -0
  47. package/dist/obligations.d.ts.map +1 -0
  48. package/dist/resident.d.ts +79 -0
  49. package/dist/resident.d.ts.map +1 -0
  50. package/dist/resident.js +44 -0
  51. package/dist/resident.js.map +1 -0
  52. package/dist/section.d.ts +82 -0
  53. package/dist/section.d.ts.map +1 -0
  54. package/dist/section.js +142 -0
  55. package/dist/section.js.map +1 -0
  56. package/dist/slice-client.d.ts +78 -0
  57. package/dist/slice-client.d.ts.map +1 -0
  58. package/dist/slice-client.js +98 -0
  59. package/dist/slice-client.js.map +1 -0
  60. package/dist/types.d.ts +57 -0
  61. package/dist/types.d.ts.map +1 -0
  62. package/dist/use-graph-look.d.ts +28 -0
  63. package/dist/use-graph-look.d.ts.map +1 -0
  64. package/dist/use-graph-look.js +28 -0
  65. package/dist/use-graph-look.js.map +1 -0
  66. package/dist/use-graph-overlays.d.ts +53 -0
  67. package/dist/use-graph-overlays.d.ts.map +1 -0
  68. package/dist/use-graph-overlays.js +96 -0
  69. package/dist/use-graph-overlays.js.map +1 -0
  70. package/dist/use-graph-selection.d.ts +59 -0
  71. package/dist/use-graph-selection.d.ts.map +1 -0
  72. package/dist/use-graph-selection.js +101 -0
  73. package/dist/use-graph-selection.js.map +1 -0
  74. package/dist/use-graph.d.ts +177 -0
  75. package/dist/use-graph.d.ts.map +1 -0
  76. package/dist/use-graph.js +101 -0
  77. package/dist/use-graph.js.map +1 -0
  78. package/dist/use-query-loop.d.ts +84 -0
  79. package/dist/use-query-loop.d.ts.map +1 -0
  80. package/dist/use-query-loop.js +151 -0
  81. package/dist/use-query-loop.js.map +1 -0
  82. package/dist/use-renderer.d.ts +86 -0
  83. package/dist/use-renderer.d.ts.map +1 -0
  84. package/dist/use-renderer.js +185 -0
  85. package/dist/use-renderer.js.map +1 -0
  86. package/dist/when-ready.d.ts +35 -0
  87. package/dist/when-ready.d.ts.map +1 -0
  88. package/dist/when-ready.js +17 -0
  89. package/dist/when-ready.js.map +1 -0
  90. package/package.json +69 -0
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @kanzo-tech/graph
2
+
3
+ A GPU graph view over [cosmos.gl](https://cosmosgl.github.io/graph): the DuckDB relation that feeds
4
+ it, the buffers that colour it from the page's own theme, and the hooks that own the renderer's
5
+ lifetime.
6
+
7
+ ## What it is not
8
+
9
+ **There is no `<GraphCanvas>`.** That is the shape of the package rather than a gap in it. A graph
10
+ canvas is a toolbar, a legend, an inspector, a hover card and a search box wired to one renderer,
11
+ and every one of those answers differently per product. What is genuinely shared sits underneath —
12
+ reading a relation into typed arrays, turning a look and the live theme into GPU buffers, owning the
13
+ renderer across React's lifecycle, and keeping a lasso inside a Mosaic crossfilter. Those are here.
14
+ The arrangement belongs at the call site; `docs/showcases/workspace/graph-canvas.tsx` is one, and it
15
+ is not the only possible one.
16
+
17
+ ## Why a package, and not part of `@kanzo-tech/ui`
18
+
19
+ The [first admission rule](/docs/philosophy#admission) is *domain-free — nothing about RDF / SHACL / fossil / **graphs**
20
+ / auth*. Graphs are excluded by name, deliberately: `ui` is the generic vocabulary every product
21
+ shares. A sibling package is also the only honest home for a **required** WebGL peer — as an
22
+ optional peer of `ui` it would have been a lie about what the package is.
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ pnpm add @kanzo-tech/graph @cosmos.gl/graph
28
+ ```
29
+
30
+ `@cosmos.gl/graph` is a required peer: this is a renderer, and there is nothing left of it without
31
+ one. The Mosaic peers are **optional** — `duckBoundedSource()` and `openCorpus()` need them, the
32
+ rendering hooks do not, and a host drawing arrays it already has should not pay for a database.
33
+
34
+ ## The two halves
35
+
36
+ **Data.** A **source** answers one question — *what should I draw* — and `useQueryLoop` asks it.
37
+ The answer is a `Slice`: at most `limit` points as parallel typed arrays, whose size follows the
38
+ question rather than the corpus. Moving the camera re-asks, and a window holding more than `limit`
39
+ is **sampled** rather than truncated — one row every `ceil(matched / limit)` over the corpus'
40
+ Morton-ordered `dense_id`, which spreads the marks over the window instead of drawing a corner of
41
+ it. So a view of everything is still a few thousand marks, and they are still everywhere.
42
+
43
+ `duckBoundedSource` — on `@kanzo-tech/graph/duckdb`, because that is the half that needs Mosaic —
44
+ answers over two DuckDB relations and takes the column names as options, so pointing it at another
45
+ corpus is a change of argument, not of code. A host that already holds its arrays takes
46
+ `memorySource` and pays for no database; under `limit` either one is asked once and never again, so
47
+ a graph that fits pays for nothing.
48
+
49
+ **A point is addressed by index and identified by pair.** cosmos.gl numbers points by their position
50
+ in the arrays it was last handed, so index 7 is whatever the current answer put seventh. A vertex is
51
+ therefore `vertexId(type, dense)` — a `bigint`, and `Slice.vertices` a `BigUint64Array`, because the
52
+ pair is 64 bits and a `number` holds 53. Anything that outlives one answer is held as a `VertexId`
53
+ and resolved through the `Resident` that `useQueryLoop` rebuilds per answer. Do not build a second
54
+ map: a copy assembled beside it is the same value one render later, with no way to notice it has
55
+ fallen behind the buffers on screen.
56
+
57
+ **Appearance.** `buffers(slice, look, host)` turns a look and the *live theme* into per-point
58
+ colours, sizes and shapes. A `Look` carries **geometry only** — colour comes from the page's
59
+ categorical scale (`categoricalColor`, on the **root** barrel of `@kanzo-tech/ui` — not
60
+ `/analytics`, so reaching it costs nobody the DuckDB peer set), because a scale a graph invents is a
61
+ scale that disagrees with the legend explaining it.
62
+
63
+ `appearance()` is the other side of that line and it matters for performance: everything that is
64
+ *one number for the whole canvas* is a cosmos.gl **uniform**, read fresh on every draw. Multiply a
65
+ slider into 500,000 sizes and every tick re-uploads the array; put it in a uniform and it costs
66
+ nothing.
67
+
68
+ ## Scale
69
+
70
+ Measured, not asserted — see `BENCHMARKS.md` at the repository root, and
71
+ `/view/showcases/graph-bench` to re-run it.
72
+
73
+ A live simulation is comfortable to about **50,000** points and finished by **200,000** (a step
74
+ costs 62 ms there). A million points render, upload and simulate without failing, but at 440 ms a
75
+ step. **Past 200,000 the honest design is positions computed once and stored as a column** — which
76
+ is why a source names `xField` / `yField`, and why a simulation is off by default: the coordinates a
77
+ source hands back are the index the next spatial question is asked against, and a force that moves
78
+ them moves the picture out from under its own index.
79
+
80
+ Bounded, over a corpus compiled once, first paint is **253 ms at a million** against the 1,225 ms it
81
+ used to cost to hold two hundred thousand. What is drawn and transferred follows the window — the
82
+ upload is flat at 23–30 ms and the redraw ceiling stays in the thousands of frames per second. What
83
+ is *scanned* does not: the pan grows from 77 ms at a million to 256 ms at five, and the term that
84
+ grows is the edge join, over one file on a DuckDB-WASM that gets a single thread. That is the next
85
+ thing to fix, and it is the corpus layout, not the renderer.
86
+
87
+ ## Gotchas the source will not tell you twice
88
+
89
+ - **`setConfig` resets everything.** cosmos.gl 3.x resets the whole configuration to defaults and
90
+ then applies the argument. Use `setConfigPartial`. This typechecks either way.
91
+ - **`getConnectedLinkIndices` is not a neighbourhood.** It returns only links whose *other* endpoint
92
+ is also in the argument — an induced subgraph. Use `neighboursOf(graph, index)`.
93
+ - **`requestAnimationFrame` never fires in a hidden tab**, and cosmos.gl drives its simulation from
94
+ rendered frames. A backgrounded graph is stopped, not slow.
@@ -0,0 +1,27 @@
1
+ import { Sim } from './graph-sim';
2
+ /**
3
+ * The coefficients and the drawing options a graph this size wants.
4
+ *
5
+ * Returned together because they are one judgement asked of one number, and splitting them into two
6
+ * exports that must be called with the same argument is how they drift apart. The caller still keeps
7
+ * them apart downstream — a `Sim` rebuilds nothing and `links` is a uniform — which is the split
8
+ * that actually costs something.
9
+ *
10
+ * **The mark scale left with `Display`.** This used to answer a `pointScale` too, interpolated from
11
+ * 1.5 to 0.5 with the corpus, and nothing ever called it: the field it fed was a reader's multiplier
12
+ * over the radius ramp `lookFrom` computes from `marks`, and two ways to size a mark is one too
13
+ * many. What a host with a large corpus wants is the tenant policy — *start your users here* — over
14
+ * the declared axes, which is where a computed fit belongs.
15
+ *
16
+ * **`spaceSize` is deliberately not here.** The original scaled the simulation box from 2,048 to
17
+ * 8,192 with the corpus, which is coherent when a layout is computed on the fly and incoherent once
18
+ * positions are authority: the box is the coordinate space a source's positions are expressed in and
19
+ * a spatial query is asked against, so resizing it by node count would move the index under the
20
+ * camera. It is not a constant of ours either, for the same reason turned around — it is the
21
+ * source's `extent()`, and `useQueryLoop` sets it from there.
22
+ */
23
+ export declare function adaptive(nodes: number): {
24
+ sim: Sim;
25
+ links: boolean;
26
+ };
27
+ //# sourceMappingURL=adaptive.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adaptive.d.ts","sourceRoot":"","sources":["../src/adaptive.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,aAAa,CAAC;AAiCvC;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,GAAG,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAgBpE"}
@@ -0,0 +1,25 @@
1
+ const r = (t, n, a) => Math.min(a, Math.max(n, t)), i = (t, n, a) => t + (n - t) * r(a, 0, 1);
2
+ function c(t) {
3
+ return r((Math.log10(Math.max(t, 1)) - 1) / 4, 0, 1);
4
+ }
5
+ function e(t) {
6
+ const n = c(t);
7
+ return {
8
+ sim: {
9
+ // Bigger graphs need less push and more damping, or they never settle.
10
+ repulsion: i(1.2, 0.4, n),
11
+ friction: i(0.7, 0.92, n),
12
+ linkSpring: i(0.5, 0.25, n),
13
+ linkDistance: i(30, 12, n),
14
+ gravity: i(0.35, 0.08, n),
15
+ cluster: 0.15
16
+ },
17
+ // Past a quarter of a million links the edge layer is fog, and fog costs a draw call per frame
18
+ // to render. Below it, links are most of what a reader is actually looking at.
19
+ links: t < 25e4
20
+ };
21
+ }
22
+ export {
23
+ e as adaptive
24
+ };
25
+ //# sourceMappingURL=adaptive.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adaptive.js","sources":["../src/adaptive.ts"],"sourcesContent":["import type { Sim } from \"./graph-sim\";\n\n/**\n * How a graph of *this size* should be drawn — force coefficients and two render\n * switches, interpolated continuously against node count.\n *\n * Absorbed from `@fossil-lang/viewer`'s `getAdaptiveConfig` per ADR-0040, which files it under\n * \"level-of-detail policy\". Reading it, that is not quite what it is, and the difference matters:\n * almost all of it is **simulation tuning** — repulsion, friction, spring, gravity — plus two\n * genuinely render-side switches. Level of detail in the bounded sense is the sample a source takes\n * when a window holds more than the limit; it lives in `duck-source.ts` and is a different\n * mechanism entirely.\n *\n * That means most of this only applies under `simulate: true`, which ADR-0001 made the opt-in case.\n * It is still worth having: the host with arrays in hand and no precomputed layout is exactly the\n * host that runs a live simulation, and one set of production-tuned numbers beats each call site\n * inventing its own.\n *\n * **The continuous interpolation is the design**, not an implementation detail. Breakpoints snap —\n * a graph crossing 10,000 nodes would visibly jump — and Cosmograph 1.x is the cautionary example.\n * The constants are tuned across 10 to 100,000 nodes and divergence from them is a defect.\n */\n\nconst clamp = (value: number, lo: number, hi: number): number =>\n Math.min(hi, Math.max(lo, value));\n\nconst lerp = (a: number, b: number, t: number): number => a + (b - a) * clamp(t, 0, 1);\n\n/** `0` at about ten nodes, `1` at about a hundred thousand — log₁₀, because node counts are. */\nfunction scaleOfCount(nodes: number): number {\n return clamp((Math.log10(Math.max(nodes, 1)) - 1) / 4, 0, 1);\n}\n\n/**\n * The coefficients and the drawing options a graph this size wants.\n *\n * Returned together because they are one judgement asked of one number, and splitting them into two\n * exports that must be called with the same argument is how they drift apart. The caller still keeps\n * them apart downstream — a `Sim` rebuilds nothing and `links` is a uniform — which is the split\n * that actually costs something.\n *\n * **The mark scale left with `Display`.** This used to answer a `pointScale` too, interpolated from\n * 1.5 to 0.5 with the corpus, and nothing ever called it: the field it fed was a reader's multiplier\n * over the radius ramp `lookFrom` computes from `marks`, and two ways to size a mark is one too\n * many. What a host with a large corpus wants is the tenant policy — *start your users here* — over\n * the declared axes, which is where a computed fit belongs.\n *\n * **`spaceSize` is deliberately not here.** The original scaled the simulation box from 2,048 to\n * 8,192 with the corpus, which is coherent when a layout is computed on the fly and incoherent once\n * positions are authority: the box is the coordinate space a source's positions are expressed in and\n * a spatial query is asked against, so resizing it by node count would move the index under the\n * camera. It is not a constant of ours either, for the same reason turned around — it is the\n * source's `extent()`, and `useQueryLoop` sets it from there.\n */\nexport function adaptive(nodes: number): { sim: Sim; links: boolean } {\n const t = scaleOfCount(nodes);\n return {\n sim: {\n // Bigger graphs need less push and more damping, or they never settle.\n repulsion: lerp(1.2, 0.4, t),\n friction: lerp(0.7, 0.92, t),\n linkSpring: lerp(0.5, 0.25, t),\n linkDistance: lerp(30, 12, t),\n gravity: lerp(0.35, 0.08, t),\n cluster: 0.15,\n },\n // Past a quarter of a million links the edge layer is fog, and fog costs a draw call per frame\n // to render. Below it, links are most of what a reader is actually looking at.\n links: nodes < 250_000,\n };\n}\n"],"names":["clamp","value","lo","hi","lerp","a","b","t","scaleOfCount","nodes","adaptive"],"mappings":"AAuBA,MAAMA,IAAQ,CAACC,GAAeC,GAAYC,MACxC,KAAK,IAAIA,GAAI,KAAK,IAAID,GAAID,CAAK,CAAC,GAE5BG,IAAO,CAACC,GAAWC,GAAWC,MAAsBF,KAAKC,IAAID,KAAKL,EAAMO,GAAG,GAAG,CAAC;AAGrF,SAASC,EAAaC,GAAuB;AAC3C,SAAOT,GAAO,KAAK,MAAM,KAAK,IAAIS,GAAO,CAAC,CAAC,IAAI,KAAK,GAAG,GAAG,CAAC;AAC7D;AAuBO,SAASC,EAASD,GAA6C;AACpE,QAAMF,IAAIC,EAAaC,CAAK;AAC5B,SAAO;AAAA,IACL,KAAK;AAAA;AAAA,MAEH,WAAWL,EAAK,KAAK,KAAKG,CAAC;AAAA,MAC3B,UAAUH,EAAK,KAAK,MAAMG,CAAC;AAAA,MAC3B,YAAYH,EAAK,KAAK,MAAMG,CAAC;AAAA,MAC7B,cAAcH,EAAK,IAAI,IAAIG,CAAC;AAAA,MAC5B,SAASH,EAAK,MAAM,MAAMG,CAAC;AAAA,MAC3B,SAAS;AAAA,IAAA;AAAA;AAAA;AAAA,IAIX,OAAOE,IAAQ;AAAA,EAAA;AAEnB;"}
@@ -0,0 +1,314 @@
1
+ import { VertexId } from './resident';
2
+ /**
3
+ * What the camera is looking at, in the graph's own coordinate space.
4
+ *
5
+ * A rectangle and nothing else. It carried a `zoom` for one reader — the level-of-detail threshold
6
+ * a source compared it against to decide whether to answer with super-nodes — and that branch is
7
+ * gone, so the field went with it rather than staying as a number every caller has to invent and
8
+ * nothing reads.
9
+ */
10
+ export interface Viewport {
11
+ xMin: number;
12
+ yMin: number;
13
+ xMax: number;
14
+ yMax: number;
15
+ }
16
+ /**
17
+ * One answer. Every array is parallel and indexed densely from zero.
18
+ *
19
+ * `n` is what *matched*, before `limit` cut it — the difference between the two is how a view says
20
+ * "there is more here than I am showing you", which is the one honest thing a bounded renderer owes
21
+ * its reader. When a window holds more than `limit`, what comes back is a **sample** of it rather
22
+ * than its first `limit` rows; `n` reports the window either way.
23
+ *
24
+ * **A struct, and it used to be a tagged union.** `mode` picked between points and super-nodes and
25
+ * only the second branch carried `weights`. Both are gone — see `/docs/design/graph` — and with one
26
+ * branch left a discriminant is a
27
+ * field with one legal value.
28
+ */
29
+ export interface Slice {
30
+ n: number;
31
+ /**
32
+ * How many of `positions` are **marks** — points a reader can see. Everything past it is an
33
+ * *anchor*.
34
+ *
35
+ * An anchor is a real vertex at its real coordinates that the window did not return: it is in the
36
+ * buffers so that an edge leaving the window has somewhere to end. It is never drawn — `buffers`
37
+ * gives it radius zero and alpha zero, and `residentOf` stops here, so nothing hovers, selects or
38
+ * frames one.
39
+ *
40
+ * **Why the far end is the vertex rather than a point on the border.** A stub clipped to the
41
+ * viewport carries the right direction and *lies about the distance*, and a reader cannot tell a
42
+ * stub that ends 1.1 window-widths out from one that ends 47 — measured over all the far ends a
43
+ * window loses, 20–22% are past four semi-widths and the worst is 47.1
44
+ * (`.planning/FAR-VIEW-AND-EDGES.md`). Drawing the vertex where it is cannot lie, and it is also
45
+ * the cheaper of the two: a clipped stub is one point *per edge*, an anchor is one point per far
46
+ * *vertex*.
47
+ *
48
+ * Equal to `positions.length / 2` for a source that answers with marks only, which is why it is
49
+ * required rather than optional — a caller that reads it always gets the count it meant.
50
+ */
51
+ marks: number;
52
+ /**
53
+ * Who each returned point *is*, parallel to `positions` — the `(type_idx, dense_id)` pair packed
54
+ * by `vertexId`.
55
+ *
56
+ * Present because a buffer index is **not stable across answers**: index 7 is a different vertex
57
+ * after a pan. Anything that outlives one answer — a selection, a focused node, a pinned set — has
58
+ * to be held as an identity and re-resolved through `residentOf` each time. Leaving this out was
59
+ * how the first draft would have shipped a selection that silently pointed at the wrong nodes.
60
+ *
61
+ * `BigUint64Array` because the pair is 64 bits exactly — a `Uint32Array` cannot hold it at all,
62
+ * and a `Float64Array` holds it only while the type index stays under 2²¹, which is a ceiling
63
+ * nobody would find until they crossed it. Still a typed array, so it is still one allocation and
64
+ * still transferable; only what it carries changed. A dense id on its own is not an identity: it
65
+ * numbers within one vertex type, so a union of two types repeats every value.
66
+ */
67
+ vertices: BigUint64Array;
68
+ /**
69
+ * The subject IRI of each returned point, parallel to `vertices` — **opt-in, and absent by
70
+ * default.**
71
+ *
72
+ * `vertices` says where a point *is*; this says which vertex it *is*. They are not the same thing
73
+ * and the corpus is explicit about it: redoing a layout renumbers every vertex, so a `dense_id`
74
+ * held outside the corpus names a different vertex after the next write. Anything that has to
75
+ * survive a recompile — a bookmark, a link out, a row in somebody else's database — keys on the
76
+ * IRI. A selection held as `VertexId` survives a pan and does not survive a rebuild.
77
+ *
78
+ * **Absent by default because it costs 1.87× the tile, measured on the corpus side.** Compressed
79
+ * bytes per row at five million: `subject` 8.016 against `dense_id` 4.000, `x` 2.717, `y` 2.501.
80
+ * The four drawing columns are 9.23 B/row and become 17.25 with it. So the drawing path carries
81
+ * addresses, and a host asks for names when something has to be *named* rather than painted.
82
+ *
83
+ * A `string[]` rather than a typed array, because that is what an IRI is. It is the one thing in a
84
+ * `Slice` that does not go to the GPU, which is exactly why it is optional: a host that never
85
+ * names a vertex should not pay to move it.
86
+ */
87
+ subjects?: string[];
88
+ /** `[x0, y0, x1, y1, …]`, one pair per returned point — `marks` of them, then the anchors. */
89
+ positions: Float32Array;
90
+ /** `[src, dst, …]` as indices into `positions`. */
91
+ links: Float32Array;
92
+ /** Per-point category ordinal, for colour. */
93
+ categories: Uint16Array;
94
+ /**
95
+ * What the size ramp is spent on, per point — a degree, a count, whatever the corpus ranks by.
96
+ *
97
+ * Optional because a source may not have one, and a graph drawn at one radius is a legitimate
98
+ * picture. But without it a look's `form.size` range has only one end, so a source that can afford
99
+ * the column should send it: it is the difference between seeing a hub and counting one.
100
+ */
101
+ sizes?: Float32Array;
102
+ }
103
+ /**
104
+ * A **region** is a map question: what is inside this rectangle. It suits an overview, a minimap, a
105
+ * reader panning across a laid-out corpus, and it is what every source can answer.
106
+ *
107
+ * A **neighbourhood** is the graph question, and it lives on [`ExploringSource`] rather than here.
108
+ * A network has no spatial "near"; it has topological near, and a rectangle cannot express "two hops
109
+ * from this node" no matter how it is positioned. Leaving it out of the render contract was a real
110
+ * design error — a contract that only spoke rectangles imposed a map metaphor on a network — and
111
+ * putting it back as a *variant of the same call* was a second one, which is what this shape fixes.
112
+ *
113
+ * **The three ways a source used to say "not that question".** A member of a query union, a
114
+ * `supports(kind)` predicate, and a `throw` at the top of `slice`. Three spellings of one idea, and
115
+ * the only one a caller could act on before making the call was the middle one — so asking a
116
+ * relational source for a neighbourhood was a runtime error that typechecked. It is a separate,
117
+ * optional method now: a source that cannot walk edges does not have it, and asking is a compile
118
+ * error rather than a promise that rejects.
119
+ */
120
+ export interface SliceRequest {
121
+ /** The rectangle. */
122
+ view: Viewport;
123
+ /**
124
+ * Which column colours a point — Plot's channel name, and Plot's meaning.
125
+ *
126
+ * **On the request rather than on the source, and that is the whole shape.** A source says *where
127
+ * the bytes are*; a request says *what I want to draw*, and which column colours is the second.
128
+ * Baked into a source at construction — which is where it used to live — changing what a graph is
129
+ * coloured by meant building a new source, and the two are not the same question.
130
+ *
131
+ * It is the reference's own arrangement: in Plot the **mark** carries the channels and the mark is
132
+ * what produces the query, while the data source only says where rows come from.
133
+ *
134
+ * Defaults to `community`, which every corpus has because the layout pass writes it.
135
+ */
136
+ fill?: string;
137
+ /**
138
+ * Which column the size ramp is spent on — Plot's `r`.
139
+ *
140
+ * Omitted, every point is drawn at one radius, which is a legitimate picture: without a ramp a
141
+ * look's `form.size` range has only one end.
142
+ */
143
+ r?: string;
144
+ /**
145
+ * Vertices that must come back whatever the query says.
146
+ *
147
+ * The set a reader has taken hold of — dragged, pinned, selected, focused. Their drawn positions
148
+ * are a view-local overlay on coordinates that never move, so the index cannot find them where
149
+ * they now appear. Carrying them explicitly is cheaper and more honest than making the index
150
+ * mutable: it is a handful of identities, and the alternative is a spatial structure that has to be
151
+ * rewritten every time somebody drags something.
152
+ */
153
+ pinned?: VertexId[];
154
+ /**
155
+ * The most points the source may return.
156
+ *
157
+ * **Above it a source samples the window; it does not take the front of it.** Which is the second
158
+ * half of `n`'s honesty: `n` says how many matched, and this says the answer is a *sample* of
159
+ * those rather than whichever ones an `ORDER BY` happened to put first.
160
+ */
161
+ limit: number;
162
+ /**
163
+ * How much of the graph's own space one screen pixel covers — the resolution the answer is going
164
+ * to be looked at.
165
+ *
166
+ * **What it buys: an edge shorter than three pixels is not sent.** Two of every three edges a
167
+ * five-million-node window draws are under one pixel long — they are a dot on top of their own
168
+ * endpoints, which the point layer has already drawn. Discarding everything under 3 px sends
169
+ * 27.5–35.3% of the rows and leaves 99.9–100% of the inked pixels identical
170
+ * (`.planning/FAR-VIEW-AND-EDGES.md`, measured 2026-08-17).
171
+ *
172
+ * **A row discard, not a fade.** cosmos.gl's `linkVisibilityDistanceRange` already dims a short
173
+ * link, and dimming happens after the row has been joined, returned, uploaded and rasterised. This
174
+ * is the same picture without the work.
175
+ *
176
+ * The rectangle alone cannot say it: the same rectangle over a 400-pixel canvas and a 4,000-pixel
177
+ * one are different questions, and only the caller knows which. Omitted — a source is asked for
178
+ * everything, or by something with no canvas — nothing is discarded, because a threshold in pixels
179
+ * with no pixels is not a threshold.
180
+ */
181
+ perPixel?: number;
182
+ signal?: AbortSignal;
183
+ }
184
+ /**
185
+ * Anything that can answer "what is in this rectangle, at this zoom, in at most this many marks".
186
+ *
187
+ * One method, one question. A source that also wants search, aggregation or paths is describing a
188
+ * query surface rather than a render path, and there is already one of those — fossil's verbs. The
189
+ * line: this answers *what should I draw*, and nothing about *what does it mean*.
190
+ */
191
+ export interface BoundedSource {
192
+ slice(request: SliceRequest): Promise<Slice>;
193
+ /**
194
+ * How many vertices there are in total, if the source knows cheaply.
195
+ *
196
+ * The reason this exists is the one real cost of bounding: **a graph that fits pays for nothing.**
197
+ * Unbounded loads once and then pans on the GPU for free; bounded issues a query per camera move.
198
+ * At 200,000 nodes that trade is overwhelmingly worth it — 1,017 ms of first paint against roughly
199
+ * 130 ms — but at 2,000 it is pure loss, tens of milliseconds of latency per pan buying a ceiling
200
+ * nobody was near.
201
+ *
202
+ * So a consumer asks first. Under the limit, take one slice covering everything and never ask
203
+ * again: same code path, and panning is free exactly when it can be.
204
+ */
205
+ total?(): Promise<number>;
206
+ /**
207
+ * The rectangle the corpus occupies, when the source can say cheaply.
208
+ *
209
+ * **Framing the opening view is not the host's job, and treating it as one is measured.** The
210
+ * archive occupies `x ∈ [1843, 2253]` of a 4,096-wide space — 1% of its area — and a camera that
211
+ * opens on the space rather than on the data draws 1,543 points into a tenth of the viewport, at
212
+ * two to nine pixels each, under a fog of links. Every point is uploaded and none is legible, which
213
+ * a reader reports as *the nodes are not rendering*.
214
+ *
215
+ * It matters more for a bounded source than for a whole one: the first question a sliced graph
216
+ * asks is *what is the camera over*, so a camera pointing at empty space is a first paint of
217
+ * nothing. Framing before asking is the difference between one query and none.
218
+ *
219
+ * Optional, because a source over an unlaid-out relation has no answer — and cheap where it
220
+ * exists: a corpus reads it off tile footers it was going to read anyway, and a relation with
221
+ * `x`/`y` gets it from four aggregates.
222
+ */
223
+ extent?(): Promise<Viewport>;
224
+ /**
225
+ * Say when the answer changes for a reason the camera cannot see, and hold the source's resources
226
+ * for as long as anybody is listening.
227
+ *
228
+ * **A whole slice rather than a nudge to ask again, and that is what makes it worth having.** The
229
+ * reason a bounded answer changes on its own is that the page filtered something — somebody
230
+ * brushed a histogram, a panel picked a value — and a source that lives inside a crossfilter is
231
+ * *told* by the coordinator, which has already re-run the reads with the new predicate by the time
232
+ * this fires. Asking again would issue the same two queries a second time to learn what is in hand.
233
+ *
234
+ * The returned function is also the release: it is where a source lets go of whatever it holds —
235
+ * a client registration, a connection, a cache — so a loop that calls this is a loop that cannot
236
+ * leak one. Optional, because a source over arrays holds nothing and changes for nothing.
237
+ */
238
+ watch?(answered: (slice: Slice) => void): () => void;
239
+ }
240
+ /**
241
+ * What a superseded question rejects with.
242
+ *
243
+ * A source may answer one question at a time — a shared connection, one client, one in-flight read —
244
+ * so a camera that moves faster than the database answers leaves a promise with a caller awaiting
245
+ * it. Dropping it leaves that caller's `finally` unrun and the loop reporting a query in flight for
246
+ * the rest of the session, so it is *settled*, and this is what with.
247
+ *
248
+ * **A caller treats it as its own abort, never as a failure.** A sentinel rather than a message,
249
+ * because "you moved on" and "the database said no" are the two things a query loop must tell apart,
250
+ * and a string comparison against a thrown value goes stale with nothing failing.
251
+ */
252
+ export declare const SUPERSEDED: unique symbol;
253
+ export declare function isSuperseded(error: unknown): boolean;
254
+ /**
255
+ * A source that can also be asked a topological question.
256
+ *
257
+ * Separate from [`BoundedSource`] rather than optional on it, because *can you walk edges* is a fact
258
+ * about a source that a caller should learn from the type rather than from a predicate. A relation
259
+ * with `x`/`y` and a spatial index answers regions and nothing else; one that holds adjacency — or
260
+ * that can reach fossil's `expand` — answers both.
261
+ *
262
+ * `useQueryLoop` narrows with `"explore" in source`, which is the check a caller writes once.
263
+ */
264
+ export interface ExploringSource extends BoundedSource {
265
+ explore(request: ExploreRequest): Promise<Slice>;
266
+ }
267
+ /** Where to start and how far out. Everything else is the same bounding as a region. */
268
+ export interface ExploreRequest extends Omit<SliceRequest, "view"> {
269
+ /** Where to start, as identities — not buffer indices, which do not survive an answer. */
270
+ seeds: VertexId[];
271
+ /** How many hops out. One is the ego network; beyond three is usually the whole graph. */
272
+ depth: number;
273
+ }
274
+ /**
275
+ * Whether this graph should be sliced at all.
276
+ *
277
+ * `undefined` when the source cannot say cheaply — in which case slice, because an unknown corpus is
278
+ * more likely to be the large kind than not.
279
+ */
280
+ export declare function shouldSlice(total: number | undefined, limit: number): boolean;
281
+ /**
282
+ * Sensible defaults, and the reason each one is that number.
283
+ *
284
+ * The camera→rectangle conversion that used to live here is gone: cosmos.gl owns the screen↔space
285
+ * transform and answers it through `screenToSpacePosition`, so deriving the rectangle from the
286
+ * camera and the space size was a second implementation of the renderer's own maths, free to drift
287
+ * from it. `useQueryLoop` asks the renderer instead.
288
+ */
289
+ export declare const BOUNDED_DEFAULTS: {
290
+ /**
291
+ * Twenty thousand marks.
292
+ *
293
+ * Above about 50,000 points a live layout stops being comfortable and the edge layer is already
294
+ * fog well before that, so a limit far below the renderer's ceiling is not a compromise — it is
295
+ * the legibility ceiling, which arrives first and is the one a reader actually meets.
296
+ */
297
+ readonly limit: 20000;
298
+ /**
299
+ * The shortest edge worth a row — three screen pixels.
300
+ *
301
+ * Measured over the drawn edges of five windows per corpus, 2026-08-17: the median edge is 1.60 px
302
+ * at 200k, 1.47 at a million and **0.52 at five million**, where 64.6% are under one pixel. An edge
303
+ * that short is a dot on top of two dots the point layer has already drawn. Discarding under 3 px
304
+ * sends 27.5–35.3% of the rows and leaves 99.9–100% of the inked pixels identical — the working is
305
+ * in `.planning/FAR-VIEW-AND-EDGES.md`.
306
+ *
307
+ * Three rather than two because both were measured against the same five windows of
308
+ * `docs/public/bench/1000000`: 2 px sends 5.6–25.8% more rows, mean 19%, for the same 0.1% of
309
+ * image. Not on `SliceRequest`, because there is one call site and a knob with one call site is a
310
+ * knob nobody has an opinion about — it moves when a second reader disagrees with the measurement.
311
+ */
312
+ readonly minLinkPixels: 3;
313
+ };
314
+ //# sourceMappingURL=bounded.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bounded.d.ts","sourceRoot":"","sources":["../src/bounded.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,KAAK;IACpB,CAAC,EAAE,MAAM,CAAC;IACV;;;;;;;;;;;;;;;;;;;OAmBG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;;;;;;OAcG;IACH,QAAQ,EAAE,cAAc,CAAC;IACzB;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,8FAA8F;IAC9F,SAAS,EAAE,YAAY,CAAC;IACxB,mDAAmD;IACnD,KAAK,EAAE,YAAY,CAAC;IACpB,8CAA8C;IAC9C,UAAU,EAAE,WAAW,CAAC;IACxB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,YAAY,CAAC;CACtB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,YAAY;IAC3B,qBAAqB;IACrB,IAAI,EAAE,QAAQ,CAAC;IACf;;;;;;;;;;;;OAYG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,CAAC,CAAC,EAAE,MAAM,CAAC;IACX;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAC;IACpB;;;;;;OAMG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;IAC7C;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAC1B;;;;;;;;;;;;;;;;OAgBG;IACH,MAAM,CAAC,IAAI,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC7B;;;;;;;;;;;;;OAaG;IACH,KAAK,CAAC,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;CACtD;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,eAAuB,CAAC;AAE/C,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEpD;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,eAAgB,SAAQ,aAAa;IACpD,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;CAClD;AAED,wFAAwF;AACxF,MAAM,WAAW,cAAe,SAAQ,IAAI,CAAC,YAAY,EAAE,MAAM,CAAC;IAChE,0FAA0F;IAC1F,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAE7E;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB;IAC3B;;;;;;OAMG;;IAEH;;;;;;;;;;;;;OAaG;;CAEK,CAAC"}
@@ -0,0 +1,39 @@
1
+ const n = Symbol("superseded");
2
+ function s(e) {
3
+ return e === n;
4
+ }
5
+ function o(e, i) {
6
+ return e === void 0 || e > i;
7
+ }
8
+ const r = {
9
+ /**
10
+ * Twenty thousand marks.
11
+ *
12
+ * Above about 50,000 points a live layout stops being comfortable and the edge layer is already
13
+ * fog well before that, so a limit far below the renderer's ceiling is not a compromise — it is
14
+ * the legibility ceiling, which arrives first and is the one a reader actually meets.
15
+ */
16
+ limit: 2e4,
17
+ /**
18
+ * The shortest edge worth a row — three screen pixels.
19
+ *
20
+ * Measured over the drawn edges of five windows per corpus, 2026-08-17: the median edge is 1.60 px
21
+ * at 200k, 1.47 at a million and **0.52 at five million**, where 64.6% are under one pixel. An edge
22
+ * that short is a dot on top of two dots the point layer has already drawn. Discarding under 3 px
23
+ * sends 27.5–35.3% of the rows and leaves 99.9–100% of the inked pixels identical — the working is
24
+ * in `.planning/FAR-VIEW-AND-EDGES.md`.
25
+ *
26
+ * Three rather than two because both were measured against the same five windows of
27
+ * `docs/public/bench/1000000`: 2 px sends 5.6–25.8% more rows, mean 19%, for the same 0.1% of
28
+ * image. Not on `SliceRequest`, because there is one call site and a knob with one call site is a
29
+ * knob nobody has an opinion about — it moves when a second reader disagrees with the measurement.
30
+ */
31
+ minLinkPixels: 3
32
+ };
33
+ export {
34
+ r as BOUNDED_DEFAULTS,
35
+ n as SUPERSEDED,
36
+ s as isSuperseded,
37
+ o as shouldSlice
38
+ };
39
+ //# sourceMappingURL=bounded.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bounded.js","sources":["../src/bounded.ts"],"sourcesContent":["/**\n * A graph you never hold all of.\n *\n * `load()` is the other kind: it reads a relation whole, turns it into typed arrays, and hands the\n * lot to the renderer. Measured, that costs 389 ms at 200,000 nodes and stops being viable somewhere\n * short of a million — the working set is N, so the ceiling is whatever N the machine can hold.\n *\n * This is the shape that has no such ceiling: something asks a bounded question — a rectangle, or a\n * neighbourhood a few hops wide — and the source answers with **at most `limit` points**. The\n * answer's size follows the question rather than the corpus. Moving re-asks. A window holding more\n * than `limit` is *sampled* rather than truncated, so a view of everything is still a few thousand\n * marks and they are still spread over everything — see `sampled` in `duck-source.ts`.\n *\n * **Positions are authority, not suggestion.** The corpus is written once and read many — OLAP, which\n * is what GraphAr is for — so the coordinates the batch emits are the index every spatial question\n * is asked against. That settles a tension an earlier draft of this file waved at rather than\n * resolved: re-laying-out a slice live would move points out from under the very coordinates the\n * next query is expressed in, and the camera would drift away from the index within one frame of\n * the first force. **Do not re-lay-out a slice.** If a layout is wrong, it is wrong upstream, and it\n * is fixed by recompiling — the graph is a compiler's output and so is its geometry.\n *\n * **Dragging is the exception, and it is a local overlay.** A reader can move a node; that changes\n * where it is *drawn*, never where it is *indexed*. The consequence is small and real: drag a node\n * far away, pan to where you dropped it, and the spatial query does not know it is there. Which is\n * why `pinned` exists below — the few points a reader has taken hold of ride along with every\n * slice, regardless of the rectangle.\n *\n * **Deliberately not a format.** A source is anything that can answer that question: Parquet\n * fetched by address, a plain relation with `x`/`y` columns and a spatial predicate, or an\n * in-memory index. This package renders; it does not learn a storage layout. The\n * wiring between a particular source and this contract belongs at the call site.\n *\n * **Dense indices, not database ids.** `links` refers to positions in `positions`, so a consumer\n * never pays for an id→index map — the 148 ms that mapping costs at 200,000 nodes is not optimised\n * here, it is designed away. Sources that already number their vertices densely (GraphAr's\n * `dense_id` does) hand this over for free.\n */\n\nimport type { VertexId } from \"./resident\";\n\n/**\n * What the camera is looking at, in the graph's own coordinate space.\n *\n * A rectangle and nothing else. It carried a `zoom` for one reader — the level-of-detail threshold\n * a source compared it against to decide whether to answer with super-nodes — and that branch is\n * gone, so the field went with it rather than staying as a number every caller has to invent and\n * nothing reads.\n */\nexport interface Viewport {\n xMin: number;\n yMin: number;\n xMax: number;\n yMax: number;\n}\n\n/**\n * One answer. Every array is parallel and indexed densely from zero.\n *\n * `n` is what *matched*, before `limit` cut it — the difference between the two is how a view says\n * \"there is more here than I am showing you\", which is the one honest thing a bounded renderer owes\n * its reader. When a window holds more than `limit`, what comes back is a **sample** of it rather\n * than its first `limit` rows; `n` reports the window either way.\n *\n * **A struct, and it used to be a tagged union.** `mode` picked between points and super-nodes and\n * only the second branch carried `weights`. Both are gone — see `/docs/design/graph` — and with one\n * branch left a discriminant is a\n * field with one legal value.\n */\nexport interface Slice {\n n: number;\n /**\n * How many of `positions` are **marks** — points a reader can see. Everything past it is an\n * *anchor*.\n *\n * An anchor is a real vertex at its real coordinates that the window did not return: it is in the\n * buffers so that an edge leaving the window has somewhere to end. It is never drawn — `buffers`\n * gives it radius zero and alpha zero, and `residentOf` stops here, so nothing hovers, selects or\n * frames one.\n *\n * **Why the far end is the vertex rather than a point on the border.** A stub clipped to the\n * viewport carries the right direction and *lies about the distance*, and a reader cannot tell a\n * stub that ends 1.1 window-widths out from one that ends 47 — measured over all the far ends a\n * window loses, 20–22% are past four semi-widths and the worst is 47.1\n * (`.planning/FAR-VIEW-AND-EDGES.md`). Drawing the vertex where it is cannot lie, and it is also\n * the cheaper of the two: a clipped stub is one point *per edge*, an anchor is one point per far\n * *vertex*.\n *\n * Equal to `positions.length / 2` for a source that answers with marks only, which is why it is\n * required rather than optional — a caller that reads it always gets the count it meant.\n */\n marks: number;\n /**\n * Who each returned point *is*, parallel to `positions` — the `(type_idx, dense_id)` pair packed\n * by `vertexId`.\n *\n * Present because a buffer index is **not stable across answers**: index 7 is a different vertex\n * after a pan. Anything that outlives one answer — a selection, a focused node, a pinned set — has\n * to be held as an identity and re-resolved through `residentOf` each time. Leaving this out was\n * how the first draft would have shipped a selection that silently pointed at the wrong nodes.\n *\n * `BigUint64Array` because the pair is 64 bits exactly — a `Uint32Array` cannot hold it at all,\n * and a `Float64Array` holds it only while the type index stays under 2²¹, which is a ceiling\n * nobody would find until they crossed it. Still a typed array, so it is still one allocation and\n * still transferable; only what it carries changed. A dense id on its own is not an identity: it\n * numbers within one vertex type, so a union of two types repeats every value.\n */\n vertices: BigUint64Array;\n /**\n * The subject IRI of each returned point, parallel to `vertices` — **opt-in, and absent by\n * default.**\n *\n * `vertices` says where a point *is*; this says which vertex it *is*. They are not the same thing\n * and the corpus is explicit about it: redoing a layout renumbers every vertex, so a `dense_id`\n * held outside the corpus names a different vertex after the next write. Anything that has to\n * survive a recompile — a bookmark, a link out, a row in somebody else's database — keys on the\n * IRI. A selection held as `VertexId` survives a pan and does not survive a rebuild.\n *\n * **Absent by default because it costs 1.87× the tile, measured on the corpus side.** Compressed\n * bytes per row at five million: `subject` 8.016 against `dense_id` 4.000, `x` 2.717, `y` 2.501.\n * The four drawing columns are 9.23 B/row and become 17.25 with it. So the drawing path carries\n * addresses, and a host asks for names when something has to be *named* rather than painted.\n *\n * A `string[]` rather than a typed array, because that is what an IRI is. It is the one thing in a\n * `Slice` that does not go to the GPU, which is exactly why it is optional: a host that never\n * names a vertex should not pay to move it.\n */\n subjects?: string[];\n /** `[x0, y0, x1, y1, …]`, one pair per returned point — `marks` of them, then the anchors. */\n positions: Float32Array;\n /** `[src, dst, …]` as indices into `positions`. */\n links: Float32Array;\n /** Per-point category ordinal, for colour. */\n categories: Uint16Array;\n /**\n * What the size ramp is spent on, per point — a degree, a count, whatever the corpus ranks by.\n *\n * Optional because a source may not have one, and a graph drawn at one radius is a legitimate\n * picture. But without it a look's `form.size` range has only one end, so a source that can afford\n * the column should send it: it is the difference between seeing a hub and counting one.\n */\n sizes?: Float32Array;\n}\n\n/**\n * A **region** is a map question: what is inside this rectangle. It suits an overview, a minimap, a\n * reader panning across a laid-out corpus, and it is what every source can answer.\n *\n * A **neighbourhood** is the graph question, and it lives on [`ExploringSource`] rather than here.\n * A network has no spatial \"near\"; it has topological near, and a rectangle cannot express \"two hops\n * from this node\" no matter how it is positioned. Leaving it out of the render contract was a real\n * design error — a contract that only spoke rectangles imposed a map metaphor on a network — and\n * putting it back as a *variant of the same call* was a second one, which is what this shape fixes.\n *\n * **The three ways a source used to say \"not that question\".** A member of a query union, a\n * `supports(kind)` predicate, and a `throw` at the top of `slice`. Three spellings of one idea, and\n * the only one a caller could act on before making the call was the middle one — so asking a\n * relational source for a neighbourhood was a runtime error that typechecked. It is a separate,\n * optional method now: a source that cannot walk edges does not have it, and asking is a compile\n * error rather than a promise that rejects.\n */\nexport interface SliceRequest {\n /** The rectangle. */\n view: Viewport;\n /**\n * Which column colours a point — Plot's channel name, and Plot's meaning.\n *\n * **On the request rather than on the source, and that is the whole shape.** A source says *where\n * the bytes are*; a request says *what I want to draw*, and which column colours is the second.\n * Baked into a source at construction — which is where it used to live — changing what a graph is\n * coloured by meant building a new source, and the two are not the same question.\n *\n * It is the reference's own arrangement: in Plot the **mark** carries the channels and the mark is\n * what produces the query, while the data source only says where rows come from.\n *\n * Defaults to `community`, which every corpus has because the layout pass writes it.\n */\n fill?: string;\n /**\n * Which column the size ramp is spent on — Plot's `r`.\n *\n * Omitted, every point is drawn at one radius, which is a legitimate picture: without a ramp a\n * look's `form.size` range has only one end.\n */\n r?: string;\n /**\n * Vertices that must come back whatever the query says.\n *\n * The set a reader has taken hold of — dragged, pinned, selected, focused. Their drawn positions\n * are a view-local overlay on coordinates that never move, so the index cannot find them where\n * they now appear. Carrying them explicitly is cheaper and more honest than making the index\n * mutable: it is a handful of identities, and the alternative is a spatial structure that has to be\n * rewritten every time somebody drags something.\n */\n pinned?: VertexId[];\n /**\n * The most points the source may return.\n *\n * **Above it a source samples the window; it does not take the front of it.** Which is the second\n * half of `n`'s honesty: `n` says how many matched, and this says the answer is a *sample* of\n * those rather than whichever ones an `ORDER BY` happened to put first.\n */\n limit: number;\n /**\n * How much of the graph's own space one screen pixel covers — the resolution the answer is going\n * to be looked at.\n *\n * **What it buys: an edge shorter than three pixels is not sent.** Two of every three edges a\n * five-million-node window draws are under one pixel long — they are a dot on top of their own\n * endpoints, which the point layer has already drawn. Discarding everything under 3 px sends\n * 27.5–35.3% of the rows and leaves 99.9–100% of the inked pixels identical\n * (`.planning/FAR-VIEW-AND-EDGES.md`, measured 2026-08-17).\n *\n * **A row discard, not a fade.** cosmos.gl's `linkVisibilityDistanceRange` already dims a short\n * link, and dimming happens after the row has been joined, returned, uploaded and rasterised. This\n * is the same picture without the work.\n *\n * The rectangle alone cannot say it: the same rectangle over a 400-pixel canvas and a 4,000-pixel\n * one are different questions, and only the caller knows which. Omitted — a source is asked for\n * everything, or by something with no canvas — nothing is discarded, because a threshold in pixels\n * with no pixels is not a threshold.\n */\n perPixel?: number;\n signal?: AbortSignal;\n}\n\n/**\n * Anything that can answer \"what is in this rectangle, at this zoom, in at most this many marks\".\n *\n * One method, one question. A source that also wants search, aggregation or paths is describing a\n * query surface rather than a render path, and there is already one of those — fossil's verbs. The\n * line: this answers *what should I draw*, and nothing about *what does it mean*.\n */\nexport interface BoundedSource {\n slice(request: SliceRequest): Promise<Slice>;\n /**\n * How many vertices there are in total, if the source knows cheaply.\n *\n * The reason this exists is the one real cost of bounding: **a graph that fits pays for nothing.**\n * Unbounded loads once and then pans on the GPU for free; bounded issues a query per camera move.\n * At 200,000 nodes that trade is overwhelmingly worth it — 1,017 ms of first paint against roughly\n * 130 ms — but at 2,000 it is pure loss, tens of milliseconds of latency per pan buying a ceiling\n * nobody was near.\n *\n * So a consumer asks first. Under the limit, take one slice covering everything and never ask\n * again: same code path, and panning is free exactly when it can be.\n */\n total?(): Promise<number>;\n /**\n * The rectangle the corpus occupies, when the source can say cheaply.\n *\n * **Framing the opening view is not the host's job, and treating it as one is measured.** The\n * archive occupies `x ∈ [1843, 2253]` of a 4,096-wide space — 1% of its area — and a camera that\n * opens on the space rather than on the data draws 1,543 points into a tenth of the viewport, at\n * two to nine pixels each, under a fog of links. Every point is uploaded and none is legible, which\n * a reader reports as *the nodes are not rendering*.\n *\n * It matters more for a bounded source than for a whole one: the first question a sliced graph\n * asks is *what is the camera over*, so a camera pointing at empty space is a first paint of\n * nothing. Framing before asking is the difference between one query and none.\n *\n * Optional, because a source over an unlaid-out relation has no answer — and cheap where it\n * exists: a corpus reads it off tile footers it was going to read anyway, and a relation with\n * `x`/`y` gets it from four aggregates.\n */\n extent?(): Promise<Viewport>;\n /**\n * Say when the answer changes for a reason the camera cannot see, and hold the source's resources\n * for as long as anybody is listening.\n *\n * **A whole slice rather than a nudge to ask again, and that is what makes it worth having.** The\n * reason a bounded answer changes on its own is that the page filtered something — somebody\n * brushed a histogram, a panel picked a value — and a source that lives inside a crossfilter is\n * *told* by the coordinator, which has already re-run the reads with the new predicate by the time\n * this fires. Asking again would issue the same two queries a second time to learn what is in hand.\n *\n * The returned function is also the release: it is where a source lets go of whatever it holds —\n * a client registration, a connection, a cache — so a loop that calls this is a loop that cannot\n * leak one. Optional, because a source over arrays holds nothing and changes for nothing.\n */\n watch?(answered: (slice: Slice) => void): () => void;\n}\n\n/**\n * What a superseded question rejects with.\n *\n * A source may answer one question at a time — a shared connection, one client, one in-flight read —\n * so a camera that moves faster than the database answers leaves a promise with a caller awaiting\n * it. Dropping it leaves that caller's `finally` unrun and the loop reporting a query in flight for\n * the rest of the session, so it is *settled*, and this is what with.\n *\n * **A caller treats it as its own abort, never as a failure.** A sentinel rather than a message,\n * because \"you moved on\" and \"the database said no\" are the two things a query loop must tell apart,\n * and a string comparison against a thrown value goes stale with nothing failing.\n */\nexport const SUPERSEDED = Symbol(\"superseded\");\n\nexport function isSuperseded(error: unknown): boolean {\n return error === SUPERSEDED;\n}\n\n/**\n * A source that can also be asked a topological question.\n *\n * Separate from [`BoundedSource`] rather than optional on it, because *can you walk edges* is a fact\n * about a source that a caller should learn from the type rather than from a predicate. A relation\n * with `x`/`y` and a spatial index answers regions and nothing else; one that holds adjacency — or\n * that can reach fossil's `expand` — answers both.\n *\n * `useQueryLoop` narrows with `\"explore\" in source`, which is the check a caller writes once.\n */\nexport interface ExploringSource extends BoundedSource {\n explore(request: ExploreRequest): Promise<Slice>;\n}\n\n/** Where to start and how far out. Everything else is the same bounding as a region. */\nexport interface ExploreRequest extends Omit<SliceRequest, \"view\"> {\n /** Where to start, as identities — not buffer indices, which do not survive an answer. */\n seeds: VertexId[];\n /** How many hops out. One is the ego network; beyond three is usually the whole graph. */\n depth: number;\n}\n\n/**\n * Whether this graph should be sliced at all.\n *\n * `undefined` when the source cannot say cheaply — in which case slice, because an unknown corpus is\n * more likely to be the large kind than not.\n */\nexport function shouldSlice(total: number | undefined, limit: number): boolean {\n return total === undefined || total > limit;\n}\n\n/**\n * Sensible defaults, and the reason each one is that number.\n *\n * The camera→rectangle conversion that used to live here is gone: cosmos.gl owns the screen↔space\n * transform and answers it through `screenToSpacePosition`, so deriving the rectangle from the\n * camera and the space size was a second implementation of the renderer's own maths, free to drift\n * from it. `useQueryLoop` asks the renderer instead.\n */\nexport const BOUNDED_DEFAULTS = {\n /**\n * Twenty thousand marks.\n *\n * Above about 50,000 points a live layout stops being comfortable and the edge layer is already\n * fog well before that, so a limit far below the renderer's ceiling is not a compromise — it is\n * the legibility ceiling, which arrives first and is the one a reader actually meets.\n */\n limit: 20_000,\n /**\n * The shortest edge worth a row — three screen pixels.\n *\n * Measured over the drawn edges of five windows per corpus, 2026-08-17: the median edge is 1.60 px\n * at 200k, 1.47 at a million and **0.52 at five million**, where 64.6% are under one pixel. An edge\n * that short is a dot on top of two dots the point layer has already drawn. Discarding under 3 px\n * sends 27.5–35.3% of the rows and leaves 99.9–100% of the inked pixels identical — the working is\n * in `.planning/FAR-VIEW-AND-EDGES.md`.\n *\n * Three rather than two because both were measured against the same five windows of\n * `docs/public/bench/1000000`: 2 px sends 5.6–25.8% more rows, mean 19%, for the same 0.1% of\n * image. Not on `SliceRequest`, because there is one call site and a knob with one call site is a\n * knob nobody has an opinion about — it moves when a second reader disagrees with the measurement.\n */\n minLinkPixels: 3,\n} as const;\n"],"names":["SUPERSEDED","isSuperseded","error","shouldSlice","total","limit","BOUNDED_DEFAULTS"],"mappings":"AAsSO,MAAMA,IAAa,OAAO,YAAY;AAEtC,SAASC,EAAaC,GAAyB;AACpD,SAAOA,MAAUF;AACnB;AA8BO,SAASG,EAAYC,GAA2BC,GAAwB;AAC7E,SAAOD,MAAU,UAAaA,IAAQC;AACxC;AAUO,MAAMC,IAAmB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQ9B,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeP,eAAe;AACjB;"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Where each cluster sits, as a ring around the middle of the simulation box.
3
+ *
4
+ * `setPointClusters` on its own does not separate anything. cosmos.gl's cluster force reads a
5
+ * cluster's position from `clusterPositionsTexture` and falls back to the group's **centre of
6
+ * mass** when that position is negative — so without this the force pulls every node toward its own
7
+ * group's centroid, which is cohesion toward a target that moves with the thing it is pulling.
8
+ * Groups that start overlapped have overlapping centroids, and nothing in a purely attractive force
9
+ * asks them to move apart. Given explicit positions the same force becomes a positional constraint,
10
+ * and the separation is a fixed geometry the simulation converges onto rather than an emergent
11
+ * property it might not find.
12
+ *
13
+ * That separation is the one thing the CPU seed in `lib/force-layout` cannot supply here. A `hall`
14
+ * is an attribute, not a community: members, tags, beasts and regions are shared across the halls by
15
+ * construction — a party borrows, a beast ranges, a tag is the board's and not one hall's — so the
16
+ * link structure genuinely crosses them and no link-driven layout can pull them apart. Measured on
17
+ * the archive corpus (1,543 nodes, 4,280 edges, five halls), the seed's own angular hint decays from
18
+ * a 2.53 between/within centroid ratio at tick 0 to 2.73 at tick 5 and **0.71 by tick 50**, where it
19
+ * stays: 0.67 at the shipped 200 ticks and 0.68 at 400. On the layout as shipped, 8-nearest-
20
+ * neighbour purity is 27.0% against a 20.1% chance floor for these five group sizes. The seed buys
21
+ * short edges — mean edge length 0.111 of the layout's width, against 0.516 for an unseeded start —
22
+ * and only this buys communities.
23
+ */
24
+ export declare function clusterRing(clusters: readonly (number | undefined)[], space: number): number[];
25
+ //# sourceMappingURL=cluster-ring.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cluster-ring.d.ts","sourceRoot":"","sources":["../src/cluster-ring.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,SAAS,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAY9F"}
@@ -0,0 +1,16 @@
1
+ function f(r, n) {
2
+ let o = 0;
3
+ for (const t of r)
4
+ t !== void 0 && t + 1 > o && (o = t + 1);
5
+ const s = [];
6
+ for (let t = 0; t < o; t++) {
7
+ const i = t / o * 2 * Math.PI, c = n * l;
8
+ s.push(n / 2 + Math.cos(i) * c, n / 2 + Math.sin(i) * c);
9
+ }
10
+ return s;
11
+ }
12
+ const l = 0.12;
13
+ export {
14
+ f as clusterRing
15
+ };
16
+ //# sourceMappingURL=cluster-ring.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cluster-ring.js","sources":["../src/cluster-ring.ts"],"sourcesContent":["/**\n * Where each cluster sits, as a ring around the middle of the simulation box.\n *\n * `setPointClusters` on its own does not separate anything. cosmos.gl's cluster force reads a\n * cluster's position from `clusterPositionsTexture` and falls back to the group's **centre of\n * mass** when that position is negative — so without this the force pulls every node toward its own\n * group's centroid, which is cohesion toward a target that moves with the thing it is pulling.\n * Groups that start overlapped have overlapping centroids, and nothing in a purely attractive force\n * asks them to move apart. Given explicit positions the same force becomes a positional constraint,\n * and the separation is a fixed geometry the simulation converges onto rather than an emergent\n * property it might not find.\n *\n * That separation is the one thing the CPU seed in `lib/force-layout` cannot supply here. A `hall`\n * is an attribute, not a community: members, tags, beasts and regions are shared across the halls by\n * construction — a party borrows, a beast ranges, a tag is the board's and not one hall's — so the\n * link structure genuinely crosses them and no link-driven layout can pull them apart. Measured on\n * the archive corpus (1,543 nodes, 4,280 edges, five halls), the seed's own angular hint decays from\n * a 2.53 between/within centroid ratio at tick 0 to 2.73 at tick 5 and **0.71 by tick 50**, where it\n * stays: 0.67 at the shipped 200 ticks and 0.68 at 400. On the layout as shipped, 8-nearest-\n * neighbour purity is 27.0% against a 20.1% chance floor for these five group sizes. The seed buys\n * short edges — mean edge length 0.111 of the layout's width, against 0.516 for an unseeded start —\n * and only this buys communities.\n */\nexport function clusterRing(clusters: readonly (number | undefined)[], space: number): number[] {\n let count = 0;\n for (const slot of clusters) {\n if (slot !== undefined && slot + 1 > count) count = slot + 1;\n }\n const positions: number[] = [];\n for (let slot = 0; slot < count; slot++) {\n const angle = (slot / count) * 2 * Math.PI;\n const ring = space * RING;\n positions.push(space / 2 + Math.cos(angle) * ring, space / 2 + Math.sin(angle) * ring);\n }\n return positions;\n}\n\n/**\n * How far out the ring sits, as a **fraction of the box the renderer is working in** — which is why\n * `clusterRing` is handed that box rather than reading a constant. There is no `SPACE` any more: the\n * coordinate space belongs to whatever wrote the positions, and the one place that can still answer\n * it is the live renderer.\n *\n * Matched to where the seeded layout already lives rather than chosen for the picture, and\n * re-measured for the archive: the shipped seed puts nodes at a median radius of 351 from the centre\n * of the 4,096 box, quartiles 211 and 655. At 492 — 0.12 of that box — the ring lands between the\n * median and the upper quartile, so the cluster force redistributes points around a circle the\n * layout already occupies instead of inflating it. It was `0.16` — 655 — for the old corpus, whose\n * median sat at 587; this one packs tighter, because 938 of its 1,543 vertices are leaves hanging\n * off a contract. **The figures are against a 4,096 box**; the fraction is what survives a different\n * one, and it is a fraction for that reason.\n *\n * Slot order is the order the group values were first seen in the relation, which on a ring means\n * neighbouring slots are neighbouring arcs. Nothing reads meaning into that adjacency, and nothing\n * should — the order is the relation's, not the domain's.\n */\nconst RING = 0.12;\n"],"names":["clusterRing","clusters","space","count","slot","positions","angle","ring","RING"],"mappings":"AAuBO,SAASA,EAAYC,GAA2CC,GAAyB;AAC9F,MAAIC,IAAQ;AACZ,aAAWC,KAAQH;AACjB,IAAIG,MAAS,UAAaA,IAAO,IAAID,UAAeC,IAAO;AAE7D,QAAMC,IAAsB,CAAA;AAC5B,WAASD,IAAO,GAAGA,IAAOD,GAAOC,KAAQ;AACvC,UAAME,IAASF,IAAOD,IAAS,IAAI,KAAK,IAClCI,IAAOL,IAAQM;AACrB,IAAAH,EAAU,KAAKH,IAAQ,IAAI,KAAK,IAAII,CAAK,IAAIC,GAAML,IAAQ,IAAI,KAAK,IAAII,CAAK,IAAIC,CAAI;AAAA,EACvF;AACA,SAAOF;AACT;AAqBA,MAAMG,IAAO;"}