@kanzo-tech/graph 0.9.0 → 0.10.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 CHANGED
@@ -89,7 +89,7 @@ nothing.
89
89
 
90
90
  ## Scale
91
91
 
92
- Measured, not asserted — see `BENCHMARKS.md` at the repository root, and
92
+ Measured, not asserted — see the [benchmarks page](https://kanzo-tech.github.io/ui/docs/graph/benchmarks), and
93
93
  `/view/showcases/graph-bench` to re-run it.
94
94
 
95
95
  A live simulation is comfortable to about **50,000** points and finished by **200,000** (a step
@@ -1 +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;"}
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` when fossil stopped shipping a viewer, which filed 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;"}
@@ -1 +1 @@
1
- {"version":3,"file":"duck-source.js","sources":["../src/duck-source.ts"],"sourcesContent":["\"use client\";\n\nimport { PAYLOAD_ADDRESS, PAYLOAD_COORDINATES, PAYLOAD_IDENTITY } from \"@fossil-lang/corpus\";\nimport type {\n Corpus,\n CorpusRelation,\n EdgeAddress,\n GapReason,\n ProjectionAddress,\n} from \"@fossil-lang/corpus\";\nimport { clausePoints, column, fillColumn, numbers } from \"@kanzo-tech/mosaic\";\nimport type { Coordinator, Engine, FilterExpr, Selection } from \"@kanzo-tech/mosaic\";\nimport type { BoundedSource, Slice, SliceRequest, Viewport } from \"./bounded\";\nimport { SliceRead } from \"./slice-client\";\nimport { denseOf, typeOf, vertexId, type VertexId } from \"./resident\";\n\n/**\n * The source: a corpus fossil wrote, read through DuckDB.\n *\n * On a subpath because Mosaic and fossil's reader are optional peers and this is the half that needs\n * them. The root barrel ships no source at all, which is the whole of what that split now means: a\n * host that installs neither gets the rendering surface and draws nothing.\n *\n * **There used to be two here and the second was neutral about storage.** `duckBoundedSource` took\n * two ordinary relations and the column names that made sense of them, and that neutrality earned\n * its keep once — it let bounded be measured against unbounded before anything committed to a layout\n * on disk. What it cost afterwards is the reason it is gone: a corpus already declares those names\n * in its manifest, so the second source was this side writing down what the other side owns, and the\n * two answered the same question in two dialects of the same SQL.\n *\n * **Every query in this file goes through a `SliceRead`, and there is no other path.** `onceQuery`\n * was the other one — a throwaway client per query, on this subpath, re-exported for the two\n * showcases that also read a relation directly. It is gone: a read the page's filters cannot reach\n * is a picture that disagrees with the page, and `slice-client.ts` carries what that cost.\n */\n\n/**\n * A DuckDB-backed source, and the one thing it can do that the render contract knows nothing about.\n *\n * `BoundedSource` says what a renderer needs: answer a bounded question. Publishing a selection is\n * the other direction of the same seam and it is Mosaic's, not the renderer's — so it lives on the\n * concrete type rather than on the contract, beside `watch`, which is on the contract because the\n * query loop is what has to act on it.\n */\nexport interface DuckSource extends BoundedSource {\n /**\n * The reader's own selection, as a clause the rest of the page filters by. `null` retracts it.\n *\n * **The graph is exempt from its own clause, and that is the whole difference from the greyout it\n * replaces.** While the canvas *faded* excluded rows it could take its own clause too — the row was\n * still drawn and still selectable, and the fade was the brush. A canvas that now draws what\n * survives would answer a lasso by deleting everything the reader did not lasso, which is not a\n * selection, it is a filter nobody asked for.\n */\n publish(vertices: readonly VertexId[] | null): void;\n}\n\n/**\n * The type index every vertex this source returns wears — **zero, because there is one of them.**\n *\n * A `dense_id` numbers within one vertex type, so an identity is the pair and the second half has to\n * come from somewhere. It used to be an option: the general source took a `typeIndex` because only\n * the caller knew which relation it had handed over, and a defaulted `0` would have let a second\n * relation ship the first one's identities with nothing raised. `openCorpus` draws **one** vertex\n * type — `vertexType` picks which, and it is numbered zero either way — so the option had one legal\n * argument and it is written here instead, once, where the reason fits beside it.\n *\n * **What would reverse it:** a canvas drawing two vertex types at once, which is what a multi-type\n * corpus asks for. Then the number comes back — off the manifest's own type ordering rather than off\n * a caller, because by then the corpus is what knows.\n */\nconst VERTEX_TYPE = 0;\n\ninterface Columns {\n id: string;\n /** `undefined` when the host did not ask to be able to name a vertex. */\n subject: string | undefined;\n x: string;\n y: string;\n /**\n * The categorical column, when a channel named one — **and `undefined` is not a missing value.**\n *\n * It used to default to `community`, in both sources, which is this side writing what the corpus\n * owns: a relation that has no such column answered `Referenced column \"community\" not found`, and\n * one that has a differently named cluster column was silently coloured by the wrong thing. Neither\n * failure is the caller's, and both were invented here.\n *\n * Unbound, every point is one colour — which is Plot's own answer to a mark with no `fill` channel,\n * and an honest picture rather than a guess.\n */\n category: string | undefined;\n size: string | undefined;\n source: string;\n target: string;\n}\n\n/**\n * The three reads a source makes, and which of them the page can filter.\n *\n * `points` and `links` carry the crossfilter; `meta` deliberately does not. How big the corpus is,\n * where it sits and what its tile footers say are facts about the corpus rather than about the\n * page's current question — and a `total()` that shrank with the filters would make the view's own\n * \"20,000 of 1,000,000\" a fraction of itself, which is the one number a bounded renderer owes its\n * reader honestly.\n */\ninterface Reads {\n points: SliceRead;\n links: SliceRead;\n meta: SliceRead;\n}\n\nfunction openReads(coordinator: Coordinator, filterBy?: Selection): Reads {\n return {\n points: new SliceRead(coordinator, filterBy),\n links: new SliceRead(coordinator, filterBy),\n meta: new SliceRead(coordinator),\n };\n}\n\n/**\n * The metadata reads, queued behind each other.\n *\n * One client answers one question at a time — a second `ask` supersedes the first — and `total()`,\n * `extent()` and the tile probing are issued by different effects with no ordering between them. A\n * queue rather than a client each, because they *already* run one at a time: DuckDB-WASM answers\n * over one connection, measured, so three clients would buy three registrations and no concurrency.\n */\nfunction metaAsker(read: SliceRead): (sql: string) => Promise<unknown> {\n let queue: Promise<unknown> = Promise.resolve();\n return (sql) => {\n const next = queue.then(() => read.ask(() => sql));\n queue = next.catch(() => undefined);\n return next;\n };\n}\n\n/**\n * The page's predicate, as SQL text.\n *\n * The reads here are CTEs over window functions rather than builder queries — `row_number()` over the\n * visible set is what makes a slice's links speak in buffer positions — so the predicate has to be\n * interpolated rather than handed to `Query.where`. Mosaic's expression nodes stringify to the same\n * SQL the builder would emit, which is what makes that safe rather than a re-implementation.\n */\nfunction predicateSql(filter: FilterExpr | undefined): string {\n if (filter == null) return \"\";\n const list = Array.isArray(filter) ? filter : [filter];\n const clauses = list.filter((node) => node != null).map((node) => String(node));\n return clauses.length > 0 ? clauses.map((c) => `(${c})`).join(\" AND \") : \"\";\n}\n\n/** Two predicates, conjoined, where an absent one contributes nothing rather than `AND TRUE`. */\nfunction both(left: string, right: string): string {\n if (!left) return right || \"TRUE\";\n if (!right) return left;\n return `(${left}) AND (${right})`;\n}\n\n/**\n * The re-indexing happens in SQL, and that is the whole trick.\n *\n * `row_number() - 1` over the visible set gives every returned point a position in the arrays about\n * to be built, so the edge query can join to it twice and hand back links that already speak in\n * those positions. No id→index map is constructed in JavaScript — which is the 148 ms `load()` spent\n * at 200,000 nodes, gone by construction rather than by optimisation.\n *\n * The `LIMIT` sits inside the CTE, so the numbering is over what survives it. Numbering first and\n * limiting after would hand out indices into an array that was never built.\n */\n/**\n * A rectangle as a SQL predicate, and an **unbounded** rectangle as no predicate at all.\n *\n * `shouldSlice` answers `false` for a graph that fits, and the loop then asks for everything — a\n * viewport whose edges are `±Infinity`. Interpolated, that reads `x BETWEEN -Infinity AND Infinity`,\n * and SQL has no infinity literal: DuckDB parses `Infinity` as a **column name** and fails with\n * `Referenced column \"Infinity\" not found`. So an open edge contributes no clause, and a rectangle\n * open on every side is `TRUE` — which is also the right plan, because a query that wants every row\n * has nothing to prune.\n */\nfunction bboxSql(c: Columns, view: Viewport): string {\n const bounds: [string, number, string][] = [\n [c.x, view.xMin, \">=\"],\n [c.x, view.xMax, \"<=\"],\n [c.y, view.yMin, \">=\"],\n [c.y, view.yMax, \"<=\"],\n ];\n const clauses = bounds\n .filter(([, value]) => Number.isFinite(value))\n .map(([column, value, op]) => `${column} ${op} ${value}`);\n return clauses.length > 0 ? clauses.join(\" AND \") : \"TRUE\";\n}\n\n/**\n * How far apart the sampled ids are — one every `ceil(matched / limit)`.\n *\n * **In SQL rather than in JavaScript because the number it divides is only known inside the query.**\n * `matched` is a window aggregate over the rows the `WHERE` kept, so a caller wanting to compute\n * this outside would have to count first and slice second — two round trips down a connection that\n * answers one at a time, which is the shape `BENCHMARKS.md` records as a hung tab rather than a slow\n * one. As a column reference it costs the pass that was being made anyway.\n *\n * `greatest(1, …)` because an empty window makes the divisor zero, and a modulo by zero is an error\n * rather than an empty answer. At `matched <= limit` it is exactly 1 and `id % 1 = 0` keeps every\n * row: a window that fits is not sampled, it is returned.\n */\nconst strideSql = (limit: number) => `greatest(1, CAST(ceil(matched / ${limit}.0) AS BIGINT))`;\n\n/**\n * The visible set: what the rectangle matched, and the sample of it that gets drawn.\n *\n * **Two CTEs, and the second one is the whole of the far view.** `pool` is every row the predicate\n * kept, carrying `count(*) OVER ()` — a window function is evaluated over everything the `WHERE`\n * kept and `LIMIT` applies after it, so that column is the number that *matched* rather than the\n * number returned. It is what deleted the third query: `SELECT count(*) FROM … WHERE <the same\n * predicate>` was a second scan to learn a number the first scan already had to compute.\n *\n * `vis` then keeps one row in `stride`, **striding over the id rather than taking the front of the\n * ordering**, and that is the difference between a picture of the window and a picture of one corner\n * of it. A corpus numbers `dense_id` along the Morton curve, so every `s`-th id is a spatially\n * stratified sample; the same `LIMIT` with no stride returns a contiguous run of the curve, which is\n * a sub-region. Measured against the truth at screen resolution, L1@8px over blocks of eight pixels:\n * a stride sample of 20,000 scores 0.167 / 0.240 / 0.269 at 200k / 1M / 5M against a uniform null of\n * 1.044 / 0.829 / 0.731 — see `/docs/design/graph`.\n *\n * @param matched Whether to project the pre-sample count out to the caller.\n *\n * It rides on the points read only. The links read builds the same CTEs to join against and never\n * looks at the column — but it does compute it, because the stride is a function of it and both\n * reads have to select the *same* rows or a slice would draw edges to vertices it did not return.\n */\nfunction visibleCte(\n nodes: string,\n c: Columns,\n where: string,\n limit: number,\n matched = true,\n): string {\n const size = c.size ? `, ${c.size} AS size` : \"\";\n // Selected in the CTE rather than joined back afterwards: the numbering is over what survives the\n // LIMIT, and a second pass keyed on `local` would be a second scan to fetch a column the first one\n // was already standing on.\n const subject = c.subject ? `, ${c.subject} AS subject` : \"\";\n // No categorical binding, no ranking: a literal zero is the ordinal every point wears, and the\n // scale hands that one colour. Ranking a column nobody named is how a default column gets invented.\n // Ranked over the sample rather than over the window, so the ordinals are contiguous across what\n // is actually drawn — which is what the colour scale is handed.\n const category = c.category\n ? `(dense_rank() OVER (ORDER BY cat) - 1)::INTEGER AS category`\n : \"0::INTEGER AS category\";\n return `WITH pool AS (\n SELECT ${c.id} AS id, ${c.x} AS x, ${c.y} AS y${size}${subject}${\n c.category ? `, ${c.category} AS cat` : \"\"\n },\n count(*) OVER () AS matched\n FROM ${nodes}\n WHERE ${where}\n ), vis AS (\n SELECT id, x, y${c.size ? \", size\" : \"\"}${c.subject ? \", subject\" : \"\"}${\n matched ? \", matched\" : \"\"\n },\n ${category},\n (row_number() OVER (ORDER BY id) - 1)::INTEGER AS local\n FROM pool\n WHERE id % ${strideSql(limit)} = 0\n LIMIT ${limit}\n )`;\n}\n\n/**\n * The shortest edge worth a row, as a predicate over the two endpoints — **squared, and on purpose.**\n *\n * A distance is compared against a threshold, and squaring both sides removes a `sqrt` per row from\n * a predicate evaluated once per candidate edge. It changes no answer: both sides are non-negative.\n *\n * `undefined` when the caller said nothing about resolution, and then there is no predicate at all\n * rather than a permissive one — a request with no canvas behind it (`EVERYTHING`) has no pixels to\n * measure three of.\n */\nfunction longEnough(\n a: string,\n b: string,\n perPixel: number | undefined,\n minLinkPixels: number,\n): string {\n if (perPixel === undefined || !Number.isFinite(perPixel) || perPixel <= 0) return \"\";\n const floor = minLinkPixels * perPixel;\n return `(${a}.x - ${b}.x) * (${a}.x - ${b}.x) + (${a}.y - ${b}.y) * (${a}.y - ${b}.y) >= ${floor * floor}`;\n}\n\n/**\n * The far ends, and the edges that reach them — **out of bytes the reader already fetched.**\n *\n * An edge with one end outside the rectangle is dropped today, and that loses 19.31% / 31.94% /\n * 28.92% of the edges incident to a window at 200k / 1M / 5M; 7,930 of 20,000 vertices carry at least\n * one at five million. What was missing was never the edge row — a window reads the `by_source` tiles\n * of every vertex it draws, so the row is in hand — it was **a position to draw the far end at**.\n *\n * **And a tile answers that for free.** A tile is 4,096 rows of a Morton-ordered relation and its\n * bounding box is far wider than the rows the rectangle keeps, so the vertices just outside the\n * window are usually in a tile the window already fetched. Measured over five windows of\n * `docs/public/bench/1000000`, 2026-08-19: relaxing the join from *inside the rectangle* to *inside\n * the tiles that were read* takes the drawn edges of five windows from 616,885 to 781,562 of 906,337\n * incident — **56.9% of everything the reader was losing, at no request, no query and no byte.**\n *\n * **The tile boundary is also a distance filter, and that is what settles the drawing.** Over every\n * far end a window loses, the median sits 1.64 semi-widths out and the worst 47.1 — which is why a\n * stub clipped to the viewport is a lie: nothing distinguishes 1.1 from 47. The far ends a held tile\n * can answer are the near ones: median 1.11–1.85 semi-widths, 90th percentile 1.29–2.60, worst\n * **6.74**, against 7.09–16.91 for the full reachable set. So the honest picture and the free one are\n * the same picture, and there is no trade to make: the anchors are drawn where the vertices are, and\n * the long tail stays undrawn because its bytes are not here — not because we decided.\n *\n * @param held The relation whose rows the reader is holding — the tiles a corpus fetched for this\n * window, which is the same relation the marks were read from. **Only an addressed source can name\n * one**, and that is why there is no longer a branch here for a source that cannot: over an\n * ordinary relation `held` would be the whole node table, and the join would scan the corpus twice\n * per camera move — the unbounded pattern wearing a bounded interface.\n */\nfunction anchorCte(\n held: string,\n edges: string,\n c: Columns,\n spatial: string,\n filter: string,\n perPixel: number | undefined,\n minLinkPixels: number,\n): string {\n const outside = both(`NOT (${spatial})`, filter);\n const long = longEnough(\"a\", \"b\", perPixel, minLinkPixels);\n return `, out AS (\n SELECT ${c.id} AS id, ${c.x} AS x, ${c.y} AS y FROM ${held} WHERE ${outside}\n ), reach AS (\n SELECT id, x, y FROM vis UNION ALL SELECT id, x, y FROM out\n ), span AS (\n SELECT sv.local AS src, tv.local AS dst, a.id AS src_id, b.id AS dst_id\n FROM ${edges} e\n JOIN reach a ON e.${c.source} = a.id\n JOIN reach b ON e.${c.target} = b.id\n LEFT JOIN vis sv ON sv.id = a.id\n LEFT JOIN vis tv ON tv.id = b.id\n WHERE (sv.id IS NOT NULL OR tv.id IS NOT NULL)${long ? ` AND ${long}` : \"\"}\n ), anchor AS (\n SELECT o.id, o.x, o.y,\n ((SELECT count(*) FROM vis) + row_number() OVER (ORDER BY o.id) - 1)::INTEGER AS local\n FROM out o\n WHERE o.id IN (SELECT src_id FROM span WHERE src IS NULL\n UNION SELECT dst_id FROM span WHERE dst IS NULL)\n )`;\n}\n\n/**\n * A question, in the two halves it is asked in and the one place they are put back together.\n *\n * The reads run through the coordinator, so the *same* pair of SQL builders serves both directions:\n * the camera pulling an answer, and the page's filters pushing one. `assemble` is therefore a\n * function of the two results and of nothing else — no closure over which of the two paths asked,\n * because a slice that came back because somebody brushed a histogram is the same slice.\n */\ninterface Plan {\n points: (filter: FilterExpr) => string;\n links: (filter: FilterExpr) => string;\n assemble: (points: unknown, links: unknown) => Slice;\n}\n\n/**\n * The one plan there is: a rectangle, its sample, and the edges both of whose ends survived it.\n *\n * It was called `detail` because it was one of two, and the other one — a `GROUP BY` over a\n * categorical column, one super-node per group — is gone. There is no mode to be in.\n */\nfunction region(\n nodes: string,\n edges: string,\n c: Columns,\n view: Viewport,\n limit: number,\n pinned: VertexId[] | undefined,\n perPixel: number | undefined,\n minLinkPixels: number,\n): Plan {\n const bbox = bboxSql(c, view);\n // A dragged node is drawn where the reader dropped it and indexed where it always was, so the\n // rectangle cannot find it. Riding along in the predicate is what keeps it on screen — and it\n // stays a predicate rather than a second query so the numbering still covers everything returned.\n //\n // Only this relation's own vertices: a pinned set spans the whole canvas, and asking one node\n // table for another type's dense ids returns the wrong rows rather than none. `VERTEX_TYPE` is the\n // whole of what \"this relation\" means here — see the constant.\n const mine = (pinned ?? []).filter((v) => typeOf(v) === VERTEX_TYPE).map(denseOf);\n const pins = mine.length > 0 ? ` OR ${c.id} IN (${mine.join(\",\")})` : \"\";\n const spatial = `(${bbox})${pins}`;\n /**\n * The page's predicate outside the pin, not inside it.\n *\n * A pin says *where to look*; the filters say *what exists*. Written the other way round —\n * `bbox AND filter OR pinned` — a pinned node would survive a filter that excludes it, and the\n * canvas would draw a vertex the rest of the page has agreed is not there.\n */\n const where = (filter: FilterExpr) => both(spatial, predicateSql(filter));\n const size = c.size ? \", size\" : \"\";\n const subject = c.subject ? \", subject\" : \"\";\n // The relation the marks were read from *is* what the reader is holding, so the anchor CTE is\n // unconditional: there is no source left that fetches a window without also fetching the tiles\n // around it.\n const anchors = (filter: FilterExpr) =>\n anchorCte(nodes, edges, c, spatial, predicateSql(filter), perPixel, minLinkPixels);\n\n return {\n /**\n * The marks, then the anchors, in one answer — because they are one buffer.\n *\n * `ORDER BY local` is load-bearing rather than tidy: `local` runs `0..marks-1` over the sample\n * and continues past it over the anchors, so ordering by it puts every mark before every anchor\n * and makes `marks` a prefix length. `matched` is then still read off row zero.\n */\n points: (filter) =>\n `${visibleCte(nodes, c, where(filter), limit)}${anchors(filter)}\n SELECT local, id, x, y, category, matched, TRUE AS mark${size}${subject} FROM vis\n UNION ALL\n SELECT local, id, x, y, 0::INTEGER, NULL::BIGINT, FALSE AS mark${\n c.size ? \", CAST(NULL AS DOUBLE)\" : \"\"\n }${c.subject ? \", CAST(NULL AS VARCHAR)\" : \"\"} FROM anchor\n ORDER BY local`,\n /**\n * One end drawn and both ends positioned.\n *\n * There was a second form here — both ends drawn, edges leaving the window dropped — for a source\n * that fetched no bytes beyond the rectangle and therefore had no position to put a far end at.\n * It went with that source, and `longEnough` with it — the length predicate lives in `anchorCte`\n * now, on the same span, which is where it has to sit once the join runs over the held rows\n * rather than over the visible ones.\n */\n links: (filter) =>\n `${visibleCte(nodes, c, where(filter), limit, false)}${anchors(filter)}\n SELECT coalesce(sp.src, sa.local) AS src, coalesce(sp.dst, da.local) AS dst\n FROM span sp\n LEFT JOIN anchor sa ON sa.id = sp.src_id\n LEFT JOIN anchor da ON da.id = sp.dst_id`,\n assemble: (points, links) => ({\n /**\n * How many matched, separately from how many came back.\n *\n * Without it the view cannot tell a reader \"there is more here than I am showing you\", and a\n * truncated slice looks exactly like a complete one — which is the failure this whole branch\n * has been about. Read off the first row rather than asked for: `matched` is constant down the\n * column, and an empty answer has no row and no matches, which agree.\n */\n n: Number(numbers(points, \"matched\")[0] ?? 0),\n ...arrays(points, links, c.size ? \"size\" : undefined, c.subject !== undefined),\n }),\n };\n}\n\n/**\n * The half of a source that runs a [`Plan`], and the half that answers when nobody asked.\n *\n * Both sources need exactly this and neither should own a second copy of it — which is why it is a\n * function over the reads rather than two blocks of the same bookkeeping. What it holds is the\n * *standing* plan: the last question the camera put, kept so an answer arriving because the page\n * filtered something can be put back together the same way.\n */\nfunction watcher(reads: Reads) {\n let standing: Plan | null = null;\n let listener: ((slice: Slice) => void) | null = null;\n /** The half-answers of a push, waiting for their sibling. */\n const landed = new Map<\"points\" | \"links\", unknown>();\n\n const arrived = (half: \"points\" | \"links\") => (data: unknown) => {\n if (!standing || !listener) return;\n landed.set(half, data);\n if (landed.size < 2) return;\n const points = landed.get(\"points\");\n const links = landed.get(\"links\");\n landed.clear();\n listener(standing.assemble(points, links));\n };\n\n return {\n api: {\n /**\n * Say when the answer changes for a reason the camera cannot see.\n *\n * The reason it hands over a whole `Slice` rather than a nudge to ask again: the coordinator\n * has *already* re-run both reads with the new predicate by the time we hear about it. Asking\n * again would run the same two queries a second time to learn what is in hand.\n */\n watch(answered: (slice: Slice) => void): () => void {\n listener = answered;\n reads.points.onAnswer = arrived(\"points\");\n reads.links.onAnswer = arrived(\"links\");\n return () => {\n listener = null;\n landed.clear();\n standing = null;\n reads.points.release();\n reads.links.release();\n reads.meta.release();\n };\n },\n },\n async run(plan: Plan): Promise<Slice> {\n standing = plan;\n // Cleared because these two are halves of the *previous* question: keeping one would pair a\n // stale rectangle's points with the new rectangle's links the next time the page filters.\n landed.clear();\n const [points, links] = await Promise.all([\n reads.points.ask(plan.points),\n reads.links.ask(plan.links),\n ]);\n return plan.assemble(points, links);\n },\n };\n}\n\n/**\n * The reader's own gesture, as a clause — and the graph exempted from it.\n *\n * `clausePoints` defaults `clients` to the clause's source when that source is itself a client, which\n * is the exemption a crossfilter is built on. Here the source is a plain object and there are two\n * clients to exempt, so the set is written out: a lasso must filter the page's charts and leave the\n * canvas showing what the reader lassoed *in context*, rather than deleting everything else.\n */\nfunction publishSelection(\n reads: Reads,\n filterBy: Selection | undefined,\n idField: string,\n vertices: readonly VertexId[] | null,\n): void {\n if (!filterBy) return;\n filterBy.update(\n clausePoints([idField], vertices?.map((vertex) => [denseOf(vertex)]), {\n source: reads.points,\n clients: new Set([reads.points, reads.links]),\n }),\n );\n}\n\n/**\n * Arrow columns to the parallel typed arrays the renderer takes — sized once, filled in place.\n *\n * `fillColumn` rather than `numbers`: Arrow already hands back a typed buffer, and the obvious route\n * through `Array.from(...).map(Number)` allocates two full-length boxed arrays on the way to a third\n * that was the actual destination. Three copies to move nothing. Here the point buffers are sized\n * against the query's own `LIMIT` and each column is written straight into its stride, so `x` and\n * `y` interleave with no seam between them and no intermediate at all.\n */\nfunction arrays(\n points: unknown,\n links: unknown,\n sizeField?: string,\n withSubjects = false,\n): Omit<Slice, \"n\"> {\n // Sized from the answer rather than from the request's `limit`, which it used to be: an answer\n // carries the window's marks *and* the anchors the edges leaving it end at, so `limit` is no longer\n // an upper bound on the rows. Under-sizing here would drop the anchors silently and leave every\n // link that pointed at one indexing past the buffer.\n const rows = countOf(points, \"x\");\n const positions = new Float32Array(rows * 2);\n const n = fillColumn(points, \"x\", positions, 0, 2);\n fillColumn(points, \"y\", positions, 1, 2);\n\n // Where the marks stop. `mark` is `TRUE` down the sample and `FALSE` down the anchors, and the\n // query orders by `local`, so this is a prefix length rather than a count — which is what lets\n // `residentOf` and `buffers` treat \"is this a mark\" as an index comparison.\n const marked = new Int32Array(rows);\n fillColumn(points, \"mark\", marked);\n let marks = 0;\n while (marks < n && marked[marks] !== 0) marks++;\n\n // The dense ids land in a scratch and are widened into identities as they are copied across.\n //\n // In place, into the destination, would be better and is not available: `fillColumn` writes\n // `Number(…)`, and a `BigUint64Array` element takes a `bigint` only — assigning a `number` to one\n // throws rather than coercing. That refusal is the same guarantee this whole change is for, so the\n // extra `n`-long buffer is the price of the boundary being enforced by the runtime and not by us.\n const dense = new Float64Array(n);\n fillColumn(points, \"id\", dense);\n const vertices = new BigUint64Array(n);\n for (let i = 0; i < n; i++) vertices[i] = vertexId(VERTEX_TYPE, dense[i] as number);\n const categories = new Uint16Array(n);\n fillColumn(points, \"category\", categories);\n\n // `column` rather than `fillColumn`: an IRI is a string, so there is no typed buffer to write\n // into and no interleaving to express. It is the one thing a slice carries that never reaches the\n // GPU, which is why asking for it is a decision rather than a default.\n const subjects = withSubjects ? (column(points, \"subject\") as string[]) : undefined;\n\n let sizes: Float32Array | undefined;\n if (sizeField) {\n sizes = new Float32Array(n);\n fillColumn(points, sizeField, sizes);\n }\n\n // The edge count is not bounded by the point limit, so it is asked for rather than assumed.\n const edgeCount = countOf(links, \"src\");\n const edges = new Float32Array(edgeCount * 2);\n const wrote = fillColumn(links, \"src\", edges, 0, 2);\n fillColumn(links, \"dst\", edges, 1, 2);\n\n return {\n marks,\n vertices,\n subjects,\n positions: positions.subarray(0, n * 2),\n links: edges.subarray(0, wrote * 2),\n categories,\n sizes,\n };\n}\n\n/** A row count for a result that does not advertise one, without materialising the rows. */\nfunction countOf(rows: unknown, field: string): number {\n const advertised = (rows as { numRows?: number } | null)?.numRows;\n if (typeof advertised === \"number\") return advertised;\n const child = (rows as { getChild?: (f: string) => { length: number } | null })?.getChild?.(field);\n if (child) return child.length;\n return Array.from(rows as Iterable<unknown>).length;\n}\n\n/**\n * A corpus that fossil wrote, read by address — **and the addressing is fossil's.**\n *\n * **The five things a call site used to know, and now does not.** Drawing a corpus meant deriving\n * the chunk URLs from a `chunk_size` copied by hand, knowing how a tile is named, knowing what the\n * edge directory is called, knowing GraphAr's column names, and knowing that a glob cannot work over\n * a plain HTTP origin because there is no listing. Five conventions and about forty lines, none of\n * it the business of something that wants to draw a graph. `/docs/design/graph` carries the\n * argument; the copied `chunk_size` carries the evidence, because it went stale and read\n * a fraction of a corpus in silence for as long as it did.\n *\n * The consumer holds two things: **the corpus fossil opened, and the page's engine.**\n *\n * ```ts\n * const e = await engine();\n * const corpus = await open(\"/bench/1000000\", { query: e.query });\n * const { source } = await openCorpus({ corpus, engine: e });\n * ```\n *\n * **And this side no longer knows the conventions either, which is the change.** It used to remove\n * that defect for its callers by committing it one level down — a YAML line-scanner, a\n * `chunk{k}.parquet` spelling, a `by_source/tile{k}.parquet` spelling and a `HEAD`-probing search\n * for a tile count — and by the time those were deleted all four were **wrong**: fossil writes\n * `container: rowgroups`, one `tiles.parquet` whose row groups are the tiles, and `vertex_count`\n * had been in the manifest the whole time the search was probing for it. Fossil's `open` is\n * `fossil_graph::plan` compiled to wasm32: the arithmetic the native reader runs, not a second\n * implementation of it that agrees until it does not.\n *\n * **Addressed, not queried.** The manifests and the per-tile boxes are read once and kept; after\n * that a camera move is arithmetic over boxes and a list of URLs. There is deliberately no request\n * on the path between the camera moving and a URL being computable — the moment there is one, this\n * has become the `viewport` verb fossil deleted.\n *\n * **What it is not.** It takes no column names and no type index. Those come from the manifest or\n * they do not come. There used to be a second source here for the case this is not — an arbitrary\n * relation with `x`/`y` that nobody wrote as a corpus — and taking `idField` here would have been\n * that one with extra steps. It is gone, so the rule is simpler than the guard against it: a column\n * name reaching this door is a name the manifest should have carried.\n */\n\nexport interface OpenCorpusOptions {\n /**\n * The corpus, as fossil's `open` answered it — **opened by the host, and once.**\n *\n * This door used to open one itself from a `dest`, a `readText` and a `wasm`, which made it the\n * third open of the same corpus on a keasy discover visit: the host had already opened it to\n * query it, and opened it again only so that this could. Opening is the host's, because what it\n * takes is the host's — where the corpus is, and what signs it. Drawing takes the result.\n */\n corpus: Corpus;\n /**\n * The page's engine — `engine()` from `@kanzo-tech/mosaic`. The canvas reads its tiles and\n * publishes its clauses through the coordinator; the files are the ones the corpus already made\n * readable, by the names its addressing gives them.\n */\n engine: Pick<Engine, \"coordinator\">;\n /**\n * The crossfilter this graph draws inside — the same `Selection` the page's charts filter by.\n *\n * Given, the predicate rides in the slice query and the canvas draws what survives.\n */\n filterBy?: Selection;\n /**\n * Which vertex type to draw, when a corpus carries more than one.\n *\n * Defaults to the first the manifest names. A corpus of one type never passes it; a corpus of\n * several has to, because *which graph do you mean* is not a question a reader can answer.\n */\n vertexType?: string;\n /**\n * Read the identity column as well. **Off by default, and the same trade as everywhere else:**\n * `subject` costs about twice the drawing tile, so the drawing path carries addresses and a host\n * asks for names when something has to be *named* rather than painted.\n */\n subjects?: boolean;\n}\n\n/** A relation this source draws: its address, and the adjacency it reads it from. */\ninterface DrawnRelation {\n readonly address: EdgeAddress;\n /** The source-ordered orientation — the CSR one, which is the drawing read. */\n readonly adjacency: ProjectionAddress;\n}\n\n/** One tile's bounding box, from the footer. A tile with no `x`/`y` statistics is not in the list. */\ninterface TileBox {\n tile: number;\n x0: number;\n x1: number;\n y0: number;\n y1: number;\n}\n\n/**\n * An opened corpus: the half that **draws** and the half that **answers**.\n *\n * A host needs both over the same bytes and they are not the same access. The canvas reads tiles by\n * address — a handful of files per camera move, chosen from the footer's boxes, with no query. A\n * chart, a crossfilter clause or a verb reads the *relation*: every row, by column name, in SQL.\n * Hiding the URLs behind `source` is right for the first and leaves the second with nothing to\n * query, so this hands on the names fossil's verbs already query the corpus by.\n */\nexport interface OpenedCorpus {\n /**\n * For the canvas: `<GraphCanvas source={…}>`. Reads tiles, never the whole relation.\n *\n * A `DuckSource`, and `CorpusSource` is gone with the reason it existed: `extent()` was declared\n * there because a source over an unlaid-out relation has no answer to it, and *optional on the\n * base contract* says that better than a second interface — a relation with `x`/`y` has an extent\n * too, and it was the one host that could not frame its opening view.\n */\n source: DuckSource;\n /**\n * The vertex relation, as fossil registered it — qualified by the corpus's catalog.\n *\n * Every column the manifest declares, including the corpus' own properties — so a clause a chart\n * publishes over `kind` or `region` lands here with no translation, which is what makes one\n * crossfilter serve the canvas and the charts.\n */\n nodes: string;\n /**\n * The source-ordered edge relations this canvas draws, one fossil relation each.\n *\n * **A list, because a corpus declares a list.** This was one name, picked with `.find` over the\n * relations whose source is this type — so a corpus declaring two edge labels registered the\n * first and dropped the second with nothing raised, and a chart over `corpus_Person_edges` was a\n * chart over half the graph. `frame` on the other side takes every one of them\n * (`packages/corpus/src/corpus.ts`, `edges.filter((e) => e.srcType === address.type)`), and\n * fossil's own `verbs()` registers a view per relation under `{src}_{edge}_{dst}` rather than\n * unioning them — which is also what keeps two relations' differing property columns from having\n * to agree on one schema.\n *\n * Empty when the corpus declares no relation this source can draw; {@link OpenedCorpus.undrawn}\n * is then where the ones it declared went.\n */\n edges: readonly EdgeRelation[];\n /**\n * The relations incident to this type that the canvas does **not** read, and why.\n *\n * A single-type canvas can draw a relation only where *both* endpoints are numbered in its own\n * `dense_id` space. The rest are dropped from the read rather than mixed into it, and reported\n * here rather than dropped in silence.\n */\n undrawn: readonly UndrawnRelation[];\n}\n\n/** One edge relation of the drawn type, and the relation fossil registered its adjacency as. */\nexport interface EdgeRelation {\n readonly edgeType: string;\n readonly srcType: string;\n readonly dstType: string;\n /** Fossil's relation, qualified by the corpus's catalog — its `CorpusRelation.sql`. */\n readonly view: string;\n}\n\n/**\n * One relation incident to the drawn type that this source cannot read, and why.\n *\n * **The reason is fossil's {@link GapReason} and not a second spelling of it** — `other-space` when\n * an endpoint is another vertex type, so the `dense_id` on that side numbers a different set of\n * vertices and this source holds no coordinates for any of them; `not-declared` when both endpoints\n * are this type and the corpus publishes no source-ordered adjacency to read. The two the drawing\n * read can produce; the third, `not-requested`, is `tilesFor`'s and never reaches here.\n *\n * What this adds to a `Gap` is the endpoint pair, which a `Gap` does not carry: it names a relation\n * by label and orientation, and a corpus may declare two relations under one label.\n */\nexport interface UndrawnRelation {\n readonly edgeType: string;\n readonly srcType: string;\n readonly dstType: string;\n readonly reason: GapReason;\n}\n\nexport async function openCorpus(options: OpenCorpusOptions): Promise<OpenedCorpus> {\n const { corpus, engine, filterBy, subjects = false, vertexType } = options;\n const { addressing } = corpus;\n\n const reads = openReads(engine.coordinator, filterBy);\n const meta = metaAsker(reads.meta);\n const watching = watcher(reads);\n\n const type = addressing.vertexType(vertexType);\n /**\n * Which relations this canvas may draw, and which incident ones it may not — **asked, not\n * derived.**\n *\n * A picture is one type's `dense_id` space, so a relation that LEAVES the type has its far ends\n * numbered in another type's, both spaces are dense from zero, and `BIGINT` compares against\n * `BIGINT` without complaining: the join matches and draws a line between two vertices with\n * nothing between them. The rule that refuses it used to be written here too — a filter over\n * `incident` on `srcType`/`dstType` plus a null check on the source-ordered adjacency — which was\n * the second copy of a rule that belongs to the addressing. It is `ReadPlan::drawing`\n * (`crates/fossil-graph/src/plan.rs`), in Rust, said once, and this is the call.\n *\n * **Not `tilesFor`, which answers the other question.** Its `edgeUrls` is every relation whose\n * *source* is this type, cross-type ones included — right for incidence, unusable for a picture.\n * Confusing the two is the bug this call closes.\n *\n * `by_target` is not asked for and its absence is reported rather than hidden: a window's\n * drawable edges all have their source on screen, so the source-aligned tiles are complete for\n * drawing and incomplete for incidence. `tilesFor` says which below, in `gaps`.\n */\n const drawing = addressing.drawing(type.type);\n const drawn: DrawnRelation[] = drawing.relations.map((address) => ({\n address,\n // Never null on a drawn relation: a declared source-ordered adjacency is what `drawing` admits\n // one for, and `not-declared` is why the others are in `undrawn` instead.\n adjacency: address.adjacency(\"src\") as ProjectionAddress,\n }));\n const adjacencies = drawn.map((relation) => relation.adjacency);\n /**\n * The rejected ones, with their endpoints — **the reasons are fossil's, the endpoints are the\n * corpus's, and neither is a judgement of ours.**\n *\n * The two lists are one partition of `incident` in declaration order: fossil walks the plan's\n * edges once and puts each incident one in `relations` or in `undrawn`, so the incident relations\n * missing from the first are the gaps of the second, in order. That join is here because a `Gap`\n * names a relation by label and orientation, and a label does not identify one — two relations\n * may share it — so the endpoint types cannot be read back off the gap alone.\n */\n const undrawn: UndrawnRelation[] = addressing\n .incident(type.type)\n .filter((edge) => !drawing.relations.includes(edge))\n .map((edge, index) => ({\n edgeType: edge.edgeType,\n srcType: edge.srcType,\n dstType: edge.dstType,\n reason: drawing.undrawn[index]!.reason,\n }));\n\n /**\n * The boxes, and the one query this source makes that is not a slice.\n *\n * Read on first use rather than in the factory: a host that constructs a source and never draws\n * should not pay for it, and the cost is a footer read over the payload. Kept forever after —\n * tiles are precomputed and their boxes cannot move without the corpus being rewritten.\n *\n * **Held as the promise rather than as the answer**, which the move to one shared metadata client\n * forced and which was a latent defect before it: `total()` and `extent()` are called by different\n * effects with nothing ordering them, so two loads used to run concurrently and probe the whole\n * tile range twice.\n */\n let loading: Promise<TileBox[]> | null = null;\n\n /**\n * Where the payload is, as the list of files that hold it.\n *\n * One file per tile under `container: files`, one file in total under `rowgroups` — and this side\n * does not know or care which, because the address is asked for rather than composed. Throws when\n * the manifest declares no `vertex_count`, which is the one absence that makes a corpus\n * un-enumerable.\n */\n const payloadFiles = type.files();\n /** A URL list as a SQL list literal. Every read below composes one and none of them composes a URL. */\n const quoted = (urls: readonly string[]) =>\n urls.map((url) => `'${url.replace(/'/g, \"''\")}'`).join(\", \");\n\n /**\n * The boxes, from the footer — **which tile a row group is, asked of the addressing.**\n *\n * `parquet_metadata` reports a `file_name` and a `row_group_id`, and which of the two names the\n * tile is the container's business: under `rowgroups` row group `k` IS tile `k`; under `files`\n * the file is, and its row groups are the writer's business, so their boxes are merged. This used\n * to read the tile out of the URL with `regexp_extract(file_name, 'chunk(\\d+)')` — one\n * container's spelling hard-coded into a query, and `NULL` for every row of a corpus fossil\n * writes today.\n *\n * `min_value`/`max_value`, never `min`/`max`. Parquet's original statistics fields are defined by\n * *signed* byte comparison, which is meaningless for an unsigned column — a writer that gets this\n * right leaves them empty. A reader that only knows the deprecated pair concludes the footer\n * carries no box for the column the whole address is built on. `coalesce` keeps the float columns\n * working either way.\n */\n function load(): Promise<TileBox[]> {\n loading ??= (async () => {\n const files = quoted(payloadFiles);\n const [xCol, yCol] = PAYLOAD_COORDINATES;\n // Which of `file_name` and `row_group_id` names the tile, as an expression rather than as a\n // branch in JavaScript: the grouping has to happen where the rows are either way.\n const tile =\n addressing.container === \"rowgroups\"\n ? \"row_group_id\"\n : `list_position([${files}], file_name) - 1`;\n const stats = await meta(\n `SELECT ${tile} AS tile,\n min(CASE WHEN path_in_schema = '${xCol}' THEN coalesce(stats_min_value, stats_min)::DOUBLE END) AS x0,\n max(CASE WHEN path_in_schema = '${xCol}' THEN coalesce(stats_max_value, stats_max)::DOUBLE END) AS x1,\n min(CASE WHEN path_in_schema = '${yCol}' THEN coalesce(stats_min_value, stats_min)::DOUBLE END) AS y0,\n max(CASE WHEN path_in_schema = '${yCol}' THEN coalesce(stats_max_value, stats_max)::DOUBLE END) AS y1\n FROM parquet_metadata([${files}])\n WHERE path_in_schema IN ('${xCol}', '${yCol}') GROUP BY 1 ORDER BY 1`,\n );\n const tiles = numbers(stats, \"tile\");\n const [x0, x1, y0, y1] = [\"x0\", \"x1\", \"y0\", \"y1\"].map((f) => numbers(stats, f));\n return tiles.map((t, i) => ({\n tile: t as number,\n x0: x0?.[i] as number,\n x1: x1?.[i] as number,\n y0: y0?.[i] as number,\n y1: y1?.[i] as number,\n }));\n })();\n return loading;\n }\n\n /** The tiles a rectangle touches. Pure — this is the whole of the selection, and it makes no call. */\n function intersecting(all: TileBox[], view: Viewport): number[] {\n return all\n .filter((b) => b.x1 >= view.xMin && b.x0 <= view.xMax && b.y1 >= view.yMin && b.y0 <= view.yMax)\n .map((b) => b.tile);\n }\n\n /**\n * Everything the corpus fixes, and nothing it does not — **by ROLE, not by name.**\n *\n * The address, the identity and the coordinates are facts of the format, and the names they are\n * written under come off `@fossil-lang/corpus`'s generated column table rather than four string\n * literals here. The endpoint columns come off the adjacency's own address. What colours and what\n * sizes are channels, so they arrive with the request and are filled in per slice.\n */\n const fixed = {\n id: PAYLOAD_ADDRESS[0] as string,\n subject: subjects ? (PAYLOAD_IDENTITY[0] as string) : undefined,\n x: PAYLOAD_COORDINATES[0] as string,\n y: PAYLOAD_COORDINATES[1] as string,\n source: adjacencies[0]?.column ?? \"src_dense\",\n target: drawn[0]?.address.adjacency(\"dst\")?.column ?? \"dst_dense\",\n } as const;\n\n /**\n * The relation half — **fossil's views, not a second set of ours.**\n *\n * A chart, a count or a crossfilter clause reads every row by name, so the canvas has to hand a\n * host relation names as well as tiles. This used to register its own: persistent `corpus_*`\n * views over the same files fossil's verbs already viewed as `TEMP`, with a different rule for\n * which edge files a relation is — two view sets over one corpus, and the persistent one outlived\n * it. `relations()` answers with the names the verbs query, qualified by the corpus's own catalog,\n * so closing the corpus takes them with it.\n */\n const relations = await corpus.relations();\n const relationOf = (match: (r: CorpusRelation) => boolean, what: string): string => {\n const found = relations.find(match);\n if (!found) throw new Error(`openCorpus: the corpus registers no relation for ${what}`);\n return found.sql;\n };\n const nodesView = relationOf((r) => r.kind === \"vertex\" && r.name === type.type, type.type);\n\n const source: DuckSource = {\n ...watching.api,\n\n publish(vertices) {\n publishSelection(reads, filterBy, fixed.id, vertices);\n },\n\n /**\n * How many vertices there are — **read, not probed.**\n *\n * The manifest declares `vertex_count`. What stood here was a note saying no manifest carries\n * it, and a doubling-then-bisecting `HEAD` search for the last chunk plus a\n * `parquet_file_metadata` read of its row count — about a dozen requests and a query, per\n * corpus, for a number already in hand.\n */\n async total() {\n if (type.count !== null) return Number(type.count);\n const rows = await meta(`SELECT count(*) AS n FROM ${nodesView}`);\n return Number(numbers(rows, \"n\")[0] ?? 0);\n },\n\n async extent() {\n const all = await load();\n return {\n xMin: Math.min(...all.map((b) => b.x0)),\n yMin: Math.min(...all.map((b) => b.y0)),\n xMax: Math.max(...all.map((b) => b.x1)),\n yMax: Math.max(...all.map((b) => b.y1)),\n };\n },\n\n // Regions only, and it says so by being the only method there is. A neighbourhood needs\n // adjacency this source does not index; `ExploringSource` declared the second question here and\n // was deleted with it — `index.test.ts` carries why, and fossil's `expand` is what answers it.\n async slice(request: SliceRequest): Promise<Slice> {\n const { fill, limit, minLinkPixels, perPixel, pinned, r, signal, view } = request;\n const columns: Columns = { ...fixed, category: fill, size: r };\n const all = await load();\n /**\n * The tiles the rectangle touches — **and the far view is not a special case of this.**\n *\n * It used to be: past a zoom threshold the selection was replaced by every tile, because the\n * aggregate branch was going to read the whole relation anyway. A window that covers the\n * extent already intersects every box, so the branch was arithmetic restating itself, and it\n * is the reason `Viewport` carried a `zoom` at all.\n */\n const selected = intersecting(all, view);\n // Nothing selected is a legitimate answer — the camera is over empty space — and asking\n // `read_parquet([])` is a syntax error rather than an empty result. Checked before the files\n // are fetched, so an empty window costs no bytes at all.\n if (selected.length === 0) {\n return {\n n: 0,\n marks: 0,\n vertices: new BigUint64Array(0),\n positions: new Float32Array(0),\n links: new Float32Array(0),\n categories: new Uint16Array(0),\n };\n }\n\n /**\n * The URLs those tiles are in — **asked, not composed**, and distinct.\n *\n * The vertex half off `tilesFor`; the edge half off each drawn relation's own adjacency,\n * which is `frame`'s shape for the same reason (`corpus.ts`, `edgeLevels.flatMap((set) =>\n * held.map((tile) => set.tileUrl(tile)))`). `tilesFor`'s `edgeUrls` is every relation whose\n * SOURCE is this type, so a cross-type one is in it — and `drawing` is where that set is\n * narrowed to the relations whose far end this source can position. The source-ordered\n * orientation is the drawing read either way: every edge a window can draw has its source on\n * screen, therefore in one of these files.\n */\n const addressed = addressing.tilesFor({\n type: type.type,\n tiles: selected,\n directions: [\"src\"],\n });\n const edgeUrls = [\n ...new Set(adjacencies.flatMap((a) => selected.map((tile) => a.tileUrl(tile)))),\n ];\n // Both halves at once: the vertex files and the edge files a window touches are independent\n // reads, and the window is not drawable until both have landed.\n /**\n * The camera moved while the tiles were arriving, so this question is already the wrong one.\n *\n * Checked here rather than left to the caller because of what comes next: the reads hold one\n * standing question each, so a request that resumes after the loop moved on would *supersede*\n * the newer one and reject it — the stale question winning the race against the live one.\n *\n * `signal.reason` and not a sentinel of ours: an aborted signal already carries what it was\n * aborted with, which for `AbortController.abort()` is a `DOMException` named `AbortError`.\n * Rethrowing it is the whole of the cancellation contract a source owes — see `SliceRequest`.\n */\n if (signal?.aborted) throw signal.reason;\n const nodes = `read_parquet([${quoted(addressed.vertexUrls)}])`;\n // A corpus that declares no adjacency for this type still has to answer: the links query is\n // built either way, so what it reads is an empty relation of the right shape rather than a\n // `read_parquet([])`, which is a syntax error, or the vertex view, which has neither column.\n const relation =\n edgeUrls.length > 0\n ? `read_parquet([${quoted(edgeUrls)}])`\n : `(SELECT NULL::BIGINT AS ${columns.source}, NULL::BIGINT AS ${columns.target} WHERE FALSE)`;\n\n // `nodes` is both the window's rows and the bytes the reader holds: the tiles that answer\n // \"what is in the rectangle\" are the tiles that answer \"where is the far end of an edge that\n // leaves it\".\n return watching.run(region(nodes, relation, columns, view, limit, pinned, perPixel, minLinkPixels));\n },\n };\n\n return {\n source,\n nodes: nodesView,\n edges: drawn.map(({ address }) => ({\n edgeType: address.edgeType,\n srcType: address.srcType,\n dstType: address.dstType,\n view: relationOf(\n (r) =>\n r.kind === \"edge\" &&\n r.edgeType === address.edgeType &&\n r.srcType === address.srcType &&\n r.dstType === address.dstType,\n `${address.srcType}_${address.edgeType}_${address.dstType}`,\n ),\n })),\n undrawn,\n };\n}\n"],"names":[],"mappings":";;;;;AAuEA;AAwCA;AACE;AAAO;AACsC;AACD;AACX;AAEnC;AAUA;AACE;AACA;AACE;AACA;AAAmB;AACZ;AAEX;AAUA;AACE;AAEA;AACA;AACF;AAGA;AACE;AAGF;AAuBA;AAOE;AAN2C;AACpB;AACA;AACA;AACA;AAKvB;AACF;AAeA;AAyBA;AAOE;AAYA;AAAO;AAGL;AAAA;AAEY;AACC;AAAA;AAIb;AACiB;AAAA;AAAA;AAGY;AAChB;AAEjB;AAYA;AAME;AACA;AACA;AACF;AA+BA;AASE;AAEA;AAAO;AACsE;AAAA;AAAA;AAAA;AAAA;AAK/D;AACgB;AACA;AAAA;AAAA;AAG8C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQ9E;AAsBA;AAUE;AA2BA;AAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAS4D;AACS;AAAA;AAI1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAYwB;AAAA;AAAA;AAAA;AAAA;AAK1C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASgB;AACiC;AAAA;AAGnF;AAUA;AACE;AAGA;AAKE;AACA;AAEA;AACyC;AAG3C;AAAO;AACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASD;AAIE;AAKW;AACb;AACF;AAAA;AAGA;AAIA;AAA0C;AACZ;AACF;AAE5B;AAAkC;AACpC;AAEJ;AAUA;AAME;AACS;AAC+D;AACtD;AAC8B;AAC7C;AAEL;AAWA;AAUE;AAGA;AAKA;AACA;AACA;AACA;AAQA;AACA;AACA;AACA;AACA;AACA;AAKA;AAEA;AACA;AAMA;AAGA;AAEO;AACL;AACA;AACA;AACsC;AACJ;AAClC;AACA;AAEJ;AAGA;;AACE;AACA;AACA;AACA;AAEF;AAgLA;;AACE;AA6BmE;AACjE;AAAA;AAAA;AAGkC;AAgBX;AACN;AACD;AACA;AACkB;AAepC;AAUA;AAqBA;AACE;AACE;AAQoB;AACJ;AAC2B;AACA;AACA;AACA;AACV;AACa;AAI9C;AAA4B;AACpB;AACG;AACA;AACA;AACA;AACT;AAEG;AAIT;AACE;AAEoB;AAWtB;AAAc;AACS;AACiC;AAC9B;AACA;AACU;AACoB;AAetD;AACA;AACA;AAAa;AAkHf;AAAO;AA9GoB;AACb;AAGV;AAAoD;AACtD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAWE;AACA;AACA;AAAwC;AAC1C;AAGE;AACA;AAAO;AACiC;AACA;AACA;AACA;AAAA;AAE1C;AAAA;AAAA;AAAA;AAME;AAeA;AACE;AAAO;AACF;AACI;AACuB;AACD;AACJ;AACI;AAejC;AAAsC;AACzB;AACJ;AACW;AAEH;AAC+D;AAehF;AACA;AAYA;AAAkG;AACpG;AAAA;AAKO;AAC4B;AACf;AACD;AACA;AACX;AAKoB;AACiC;AAAA;AAE3D;AACF;AAEJ;;;;"}
1
+ {"version":3,"file":"duck-source.js","sources":["../src/duck-source.ts"],"sourcesContent":["\"use client\";\n\nimport { PAYLOAD_ADDRESS, PAYLOAD_COORDINATES, PAYLOAD_IDENTITY } from \"@fossil-lang/corpus\";\nimport type {\n Corpus,\n CorpusRelation,\n EdgeAddress,\n GapReason,\n ProjectionAddress,\n} from \"@fossil-lang/corpus\";\nimport { clausePoints, column, fillColumn, numbers } from \"@kanzo-tech/mosaic\";\nimport type { Coordinator, Engine, FilterExpr, Selection } from \"@kanzo-tech/mosaic\";\nimport type { BoundedSource, Slice, SliceRequest, Viewport } from \"./bounded\";\nimport { SliceRead } from \"./slice-client\";\nimport { denseOf, typeOf, vertexId, type VertexId } from \"./resident\";\n\n/**\n * The source: a corpus fossil wrote, read through DuckDB.\n *\n * On a subpath because Mosaic and fossil's reader are optional peers and this is the half that needs\n * them. The root barrel ships no source at all, which is the whole of what that split now means: a\n * host that installs neither gets the rendering surface and draws nothing.\n *\n * **There used to be two here and the second was neutral about storage.** `duckBoundedSource` took\n * two ordinary relations and the column names that made sense of them, and that neutrality earned\n * its keep once — it let bounded be measured against unbounded before anything committed to a layout\n * on disk. What it cost afterwards is the reason it is gone: a corpus already declares those names\n * in its manifest, so the second source was this side writing down what the other side owns, and the\n * two answered the same question in two dialects of the same SQL.\n *\n * **Every query in this file goes through a `SliceRead`, and there is no other path.** `onceQuery`\n * was the other one — a throwaway client per query, on this subpath, re-exported for the two\n * showcases that also read a relation directly. It is gone: a read the page's filters cannot reach\n * is a picture that disagrees with the page, and `slice-client.ts` carries what that cost.\n */\n\n/**\n * A DuckDB-backed source, and the one thing it can do that the render contract knows nothing about.\n *\n * `BoundedSource` says what a renderer needs: answer a bounded question. Publishing a selection is\n * the other direction of the same seam and it is Mosaic's, not the renderer's — so it lives on the\n * concrete type rather than on the contract, beside `watch`, which is on the contract because the\n * query loop is what has to act on it.\n */\nexport interface DuckSource extends BoundedSource {\n /**\n * The reader's own selection, as a clause the rest of the page filters by. `null` retracts it.\n *\n * **The graph is exempt from its own clause, and that is the whole difference from the greyout it\n * replaces.** While the canvas *faded* excluded rows it could take its own clause too — the row was\n * still drawn and still selectable, and the fade was the brush. A canvas that now draws what\n * survives would answer a lasso by deleting everything the reader did not lasso, which is not a\n * selection, it is a filter nobody asked for.\n */\n publish(vertices: readonly VertexId[] | null): void;\n}\n\n/**\n * The type index every vertex this source returns wears — **zero, because there is one of them.**\n *\n * A `dense_id` numbers within one vertex type, so an identity is the pair and the second half has to\n * come from somewhere. It used to be an option: the general source took a `typeIndex` because only\n * the caller knew which relation it had handed over, and a defaulted `0` would have let a second\n * relation ship the first one's identities with nothing raised. `openCorpus` draws **one** vertex\n * type — `vertexType` picks which, and it is numbered zero either way — so the option had one legal\n * argument and it is written here instead, once, where the reason fits beside it.\n *\n * **What would reverse it:** a canvas drawing two vertex types at once, which is what a multi-type\n * corpus asks for. Then the number comes back — off the manifest's own type ordering rather than off\n * a caller, because by then the corpus is what knows.\n */\nconst VERTEX_TYPE = 0;\n\ninterface Columns {\n id: string;\n /** `undefined` when the host did not ask to be able to name a vertex. */\n subject: string | undefined;\n x: string;\n y: string;\n /**\n * The categorical column, when a channel named one — **and `undefined` is not a missing value.**\n *\n * It used to default to `community`, in both sources, which is this side writing what the corpus\n * owns: a relation that has no such column answered `Referenced column \"community\" not found`, and\n * one that has a differently named cluster column was silently coloured by the wrong thing. Neither\n * failure is the caller's, and both were invented here.\n *\n * Unbound, every point is one colour — which is Plot's own answer to a mark with no `fill` channel,\n * and an honest picture rather than a guess.\n */\n category: string | undefined;\n size: string | undefined;\n source: string;\n target: string;\n}\n\n/**\n * The three reads a source makes, and which of them the page can filter.\n *\n * `points` and `links` carry the crossfilter; `meta` deliberately does not. How big the corpus is,\n * where it sits and what its tile footers say are facts about the corpus rather than about the\n * page's current question — and a `total()` that shrank with the filters would make the view's own\n * \"20,000 of 1,000,000\" a fraction of itself, which is the one number a bounded renderer owes its\n * reader honestly.\n */\ninterface Reads {\n points: SliceRead;\n links: SliceRead;\n meta: SliceRead;\n}\n\nfunction openReads(coordinator: Coordinator, filterBy?: Selection): Reads {\n return {\n points: new SliceRead(coordinator, filterBy),\n links: new SliceRead(coordinator, filterBy),\n meta: new SliceRead(coordinator),\n };\n}\n\n/**\n * The metadata reads, queued behind each other.\n *\n * One client answers one question at a time — a second `ask` supersedes the first — and `total()`,\n * `extent()` and the tile probing are issued by different effects with no ordering between them. A\n * queue rather than a client each, because they *already* run one at a time: DuckDB-WASM answers\n * over one connection, measured, so three clients would buy three registrations and no concurrency.\n */\nfunction metaAsker(read: SliceRead): (sql: string) => Promise<unknown> {\n let queue: Promise<unknown> = Promise.resolve();\n return (sql) => {\n const next = queue.then(() => read.ask(() => sql));\n queue = next.catch(() => undefined);\n return next;\n };\n}\n\n/**\n * The page's predicate, as SQL text.\n *\n * The reads here are CTEs over window functions rather than builder queries — `row_number()` over the\n * visible set is what makes a slice's links speak in buffer positions — so the predicate has to be\n * interpolated rather than handed to `Query.where`. Mosaic's expression nodes stringify to the same\n * SQL the builder would emit, which is what makes that safe rather than a re-implementation.\n */\nfunction predicateSql(filter: FilterExpr | undefined): string {\n if (filter == null) return \"\";\n const list = Array.isArray(filter) ? filter : [filter];\n const clauses = list.filter((node) => node != null).map((node) => String(node));\n return clauses.length > 0 ? clauses.map((c) => `(${c})`).join(\" AND \") : \"\";\n}\n\n/** Two predicates, conjoined, where an absent one contributes nothing rather than `AND TRUE`. */\nfunction both(left: string, right: string): string {\n if (!left) return right || \"TRUE\";\n if (!right) return left;\n return `(${left}) AND (${right})`;\n}\n\n/**\n * The re-indexing happens in SQL, and that is the whole trick.\n *\n * `row_number() - 1` over the visible set gives every returned point a position in the arrays about\n * to be built, so the edge query can join to it twice and hand back links that already speak in\n * those positions. No id→index map is constructed in JavaScript — which is the 148 ms `load()` spent\n * at 200,000 nodes, gone by construction rather than by optimisation.\n *\n * The `LIMIT` sits inside the CTE, so the numbering is over what survives it. Numbering first and\n * limiting after would hand out indices into an array that was never built.\n */\n/**\n * A rectangle as a SQL predicate, and an **unbounded** rectangle as no predicate at all.\n *\n * `shouldSlice` answers `false` for a graph that fits, and the loop then asks for everything — a\n * viewport whose edges are `±Infinity`. Interpolated, that reads `x BETWEEN -Infinity AND Infinity`,\n * and SQL has no infinity literal: DuckDB parses `Infinity` as a **column name** and fails with\n * `Referenced column \"Infinity\" not found`. So an open edge contributes no clause, and a rectangle\n * open on every side is `TRUE` — which is also the right plan, because a query that wants every row\n * has nothing to prune.\n */\nfunction bboxSql(c: Columns, view: Viewport): string {\n const bounds: [string, number, string][] = [\n [c.x, view.xMin, \">=\"],\n [c.x, view.xMax, \"<=\"],\n [c.y, view.yMin, \">=\"],\n [c.y, view.yMax, \"<=\"],\n ];\n const clauses = bounds\n .filter(([, value]) => Number.isFinite(value))\n .map(([column, value, op]) => `${column} ${op} ${value}`);\n return clauses.length > 0 ? clauses.join(\" AND \") : \"TRUE\";\n}\n\n/**\n * How far apart the sampled ids are — one every `ceil(matched / limit)`.\n *\n * **In SQL rather than in JavaScript because the number it divides is only known inside the query.**\n * `matched` is a window aggregate over the rows the `WHERE` kept, so a caller wanting to compute\n * this outside would have to count first and slice second — two round trips down a connection that\n * answers one at a time, which is the shape the benchmark record describes as a hung tab rather than a slow\n * one. As a column reference it costs the pass that was being made anyway.\n *\n * `greatest(1, …)` because an empty window makes the divisor zero, and a modulo by zero is an error\n * rather than an empty answer. At `matched <= limit` it is exactly 1 and `id % 1 = 0` keeps every\n * row: a window that fits is not sampled, it is returned.\n */\nconst strideSql = (limit: number) => `greatest(1, CAST(ceil(matched / ${limit}.0) AS BIGINT))`;\n\n/**\n * The visible set: what the rectangle matched, and the sample of it that gets drawn.\n *\n * **Two CTEs, and the second one is the whole of the far view.** `pool` is every row the predicate\n * kept, carrying `count(*) OVER ()` — a window function is evaluated over everything the `WHERE`\n * kept and `LIMIT` applies after it, so that column is the number that *matched* rather than the\n * number returned. It is what deleted the third query: `SELECT count(*) FROM … WHERE <the same\n * predicate>` was a second scan to learn a number the first scan already had to compute.\n *\n * `vis` then keeps one row in `stride`, **striding over the id rather than taking the front of the\n * ordering**, and that is the difference between a picture of the window and a picture of one corner\n * of it. A corpus numbers `dense_id` along the Morton curve, so every `s`-th id is a spatially\n * stratified sample; the same `LIMIT` with no stride returns a contiguous run of the curve, which is\n * a sub-region. Measured against the truth at screen resolution, L1@8px over blocks of eight pixels:\n * a stride sample of 20,000 scores 0.167 / 0.240 / 0.269 at 200k / 1M / 5M against a uniform null of\n * 1.044 / 0.829 / 0.731 — see `/docs/design/graph`.\n *\n * @param matched Whether to project the pre-sample count out to the caller.\n *\n * It rides on the points read only. The links read builds the same CTEs to join against and never\n * looks at the column — but it does compute it, because the stride is a function of it and both\n * reads have to select the *same* rows or a slice would draw edges to vertices it did not return.\n */\nfunction visibleCte(\n nodes: string,\n c: Columns,\n where: string,\n limit: number,\n matched = true,\n): string {\n const size = c.size ? `, ${c.size} AS size` : \"\";\n // Selected in the CTE rather than joined back afterwards: the numbering is over what survives the\n // LIMIT, and a second pass keyed on `local` would be a second scan to fetch a column the first one\n // was already standing on.\n const subject = c.subject ? `, ${c.subject} AS subject` : \"\";\n // No categorical binding, no ranking: a literal zero is the ordinal every point wears, and the\n // scale hands that one colour. Ranking a column nobody named is how a default column gets invented.\n // Ranked over the sample rather than over the window, so the ordinals are contiguous across what\n // is actually drawn — which is what the colour scale is handed.\n const category = c.category\n ? `(dense_rank() OVER (ORDER BY cat) - 1)::INTEGER AS category`\n : \"0::INTEGER AS category\";\n return `WITH pool AS (\n SELECT ${c.id} AS id, ${c.x} AS x, ${c.y} AS y${size}${subject}${\n c.category ? `, ${c.category} AS cat` : \"\"\n },\n count(*) OVER () AS matched\n FROM ${nodes}\n WHERE ${where}\n ), vis AS (\n SELECT id, x, y${c.size ? \", size\" : \"\"}${c.subject ? \", subject\" : \"\"}${\n matched ? \", matched\" : \"\"\n },\n ${category},\n (row_number() OVER (ORDER BY id) - 1)::INTEGER AS local\n FROM pool\n WHERE id % ${strideSql(limit)} = 0\n LIMIT ${limit}\n )`;\n}\n\n/**\n * The shortest edge worth a row, as a predicate over the two endpoints — **squared, and on purpose.**\n *\n * A distance is compared against a threshold, and squaring both sides removes a `sqrt` per row from\n * a predicate evaluated once per candidate edge. It changes no answer: both sides are non-negative.\n *\n * `undefined` when the caller said nothing about resolution, and then there is no predicate at all\n * rather than a permissive one — a request with no canvas behind it (`EVERYTHING`) has no pixels to\n * measure three of.\n */\nfunction longEnough(\n a: string,\n b: string,\n perPixel: number | undefined,\n minLinkPixels: number,\n): string {\n if (perPixel === undefined || !Number.isFinite(perPixel) || perPixel <= 0) return \"\";\n const floor = minLinkPixels * perPixel;\n return `(${a}.x - ${b}.x) * (${a}.x - ${b}.x) + (${a}.y - ${b}.y) * (${a}.y - ${b}.y) >= ${floor * floor}`;\n}\n\n/**\n * The far ends, and the edges that reach them — **out of bytes the reader already fetched.**\n *\n * An edge with one end outside the rectangle is dropped today, and that loses 19.31% / 31.94% /\n * 28.92% of the edges incident to a window at 200k / 1M / 5M; 7,930 of 20,000 vertices carry at least\n * one at five million. What was missing was never the edge row — a window reads the `by_source` tiles\n * of every vertex it draws, so the row is in hand — it was **a position to draw the far end at**.\n *\n * **And a tile answers that for free.** A tile is 4,096 rows of a Morton-ordered relation and its\n * bounding box is far wider than the rows the rectangle keeps, so the vertices just outside the\n * window are usually in a tile the window already fetched. Measured over five windows of\n * `docs/public/bench/1000000`, 2026-08-19: relaxing the join from *inside the rectangle* to *inside\n * the tiles that were read* takes the drawn edges of five windows from 616,885 to 781,562 of 906,337\n * incident — **56.9% of everything the reader was losing, at no request, no query and no byte.**\n *\n * **The tile boundary is also a distance filter, and that is what settles the drawing.** Over every\n * far end a window loses, the median sits 1.64 semi-widths out and the worst 47.1 — which is why a\n * stub clipped to the viewport is a lie: nothing distinguishes 1.1 from 47. The far ends a held tile\n * can answer are the near ones: median 1.11–1.85 semi-widths, 90th percentile 1.29–2.60, worst\n * **6.74**, against 7.09–16.91 for the full reachable set. So the honest picture and the free one are\n * the same picture, and there is no trade to make: the anchors are drawn where the vertices are, and\n * the long tail stays undrawn because its bytes are not here — not because we decided.\n *\n * @param held The relation whose rows the reader is holding — the tiles a corpus fetched for this\n * window, which is the same relation the marks were read from. **Only an addressed source can name\n * one**, and that is why there is no longer a branch here for a source that cannot: over an\n * ordinary relation `held` would be the whole node table, and the join would scan the corpus twice\n * per camera move — the unbounded pattern wearing a bounded interface.\n */\nfunction anchorCte(\n held: string,\n edges: string,\n c: Columns,\n spatial: string,\n filter: string,\n perPixel: number | undefined,\n minLinkPixels: number,\n): string {\n const outside = both(`NOT (${spatial})`, filter);\n const long = longEnough(\"a\", \"b\", perPixel, minLinkPixels);\n return `, out AS (\n SELECT ${c.id} AS id, ${c.x} AS x, ${c.y} AS y FROM ${held} WHERE ${outside}\n ), reach AS (\n SELECT id, x, y FROM vis UNION ALL SELECT id, x, y FROM out\n ), span AS (\n SELECT sv.local AS src, tv.local AS dst, a.id AS src_id, b.id AS dst_id\n FROM ${edges} e\n JOIN reach a ON e.${c.source} = a.id\n JOIN reach b ON e.${c.target} = b.id\n LEFT JOIN vis sv ON sv.id = a.id\n LEFT JOIN vis tv ON tv.id = b.id\n WHERE (sv.id IS NOT NULL OR tv.id IS NOT NULL)${long ? ` AND ${long}` : \"\"}\n ), anchor AS (\n SELECT o.id, o.x, o.y,\n ((SELECT count(*) FROM vis) + row_number() OVER (ORDER BY o.id) - 1)::INTEGER AS local\n FROM out o\n WHERE o.id IN (SELECT src_id FROM span WHERE src IS NULL\n UNION SELECT dst_id FROM span WHERE dst IS NULL)\n )`;\n}\n\n/**\n * A question, in the two halves it is asked in and the one place they are put back together.\n *\n * The reads run through the coordinator, so the *same* pair of SQL builders serves both directions:\n * the camera pulling an answer, and the page's filters pushing one. `assemble` is therefore a\n * function of the two results and of nothing else — no closure over which of the two paths asked,\n * because a slice that came back because somebody brushed a histogram is the same slice.\n */\ninterface Plan {\n points: (filter: FilterExpr) => string;\n links: (filter: FilterExpr) => string;\n assemble: (points: unknown, links: unknown) => Slice;\n}\n\n/**\n * The one plan there is: a rectangle, its sample, and the edges both of whose ends survived it.\n *\n * It was called `detail` because it was one of two, and the other one — a `GROUP BY` over a\n * categorical column, one super-node per group — is gone. There is no mode to be in.\n */\nfunction region(\n nodes: string,\n edges: string,\n c: Columns,\n view: Viewport,\n limit: number,\n pinned: VertexId[] | undefined,\n perPixel: number | undefined,\n minLinkPixels: number,\n): Plan {\n const bbox = bboxSql(c, view);\n // A dragged node is drawn where the reader dropped it and indexed where it always was, so the\n // rectangle cannot find it. Riding along in the predicate is what keeps it on screen — and it\n // stays a predicate rather than a second query so the numbering still covers everything returned.\n //\n // Only this relation's own vertices: a pinned set spans the whole canvas, and asking one node\n // table for another type's dense ids returns the wrong rows rather than none. `VERTEX_TYPE` is the\n // whole of what \"this relation\" means here — see the constant.\n const mine = (pinned ?? []).filter((v) => typeOf(v) === VERTEX_TYPE).map(denseOf);\n const pins = mine.length > 0 ? ` OR ${c.id} IN (${mine.join(\",\")})` : \"\";\n const spatial = `(${bbox})${pins}`;\n /**\n * The page's predicate outside the pin, not inside it.\n *\n * A pin says *where to look*; the filters say *what exists*. Written the other way round —\n * `bbox AND filter OR pinned` — a pinned node would survive a filter that excludes it, and the\n * canvas would draw a vertex the rest of the page has agreed is not there.\n */\n const where = (filter: FilterExpr) => both(spatial, predicateSql(filter));\n const size = c.size ? \", size\" : \"\";\n const subject = c.subject ? \", subject\" : \"\";\n // The relation the marks were read from *is* what the reader is holding, so the anchor CTE is\n // unconditional: there is no source left that fetches a window without also fetching the tiles\n // around it.\n const anchors = (filter: FilterExpr) =>\n anchorCte(nodes, edges, c, spatial, predicateSql(filter), perPixel, minLinkPixels);\n\n return {\n /**\n * The marks, then the anchors, in one answer — because they are one buffer.\n *\n * `ORDER BY local` is load-bearing rather than tidy: `local` runs `0..marks-1` over the sample\n * and continues past it over the anchors, so ordering by it puts every mark before every anchor\n * and makes `marks` a prefix length. `matched` is then still read off row zero.\n */\n points: (filter) =>\n `${visibleCte(nodes, c, where(filter), limit)}${anchors(filter)}\n SELECT local, id, x, y, category, matched, TRUE AS mark${size}${subject} FROM vis\n UNION ALL\n SELECT local, id, x, y, 0::INTEGER, NULL::BIGINT, FALSE AS mark${\n c.size ? \", CAST(NULL AS DOUBLE)\" : \"\"\n }${c.subject ? \", CAST(NULL AS VARCHAR)\" : \"\"} FROM anchor\n ORDER BY local`,\n /**\n * One end drawn and both ends positioned.\n *\n * There was a second form here — both ends drawn, edges leaving the window dropped — for a source\n * that fetched no bytes beyond the rectangle and therefore had no position to put a far end at.\n * It went with that source, and `longEnough` with it — the length predicate lives in `anchorCte`\n * now, on the same span, which is where it has to sit once the join runs over the held rows\n * rather than over the visible ones.\n */\n links: (filter) =>\n `${visibleCte(nodes, c, where(filter), limit, false)}${anchors(filter)}\n SELECT coalesce(sp.src, sa.local) AS src, coalesce(sp.dst, da.local) AS dst\n FROM span sp\n LEFT JOIN anchor sa ON sa.id = sp.src_id\n LEFT JOIN anchor da ON da.id = sp.dst_id`,\n assemble: (points, links) => ({\n /**\n * How many matched, separately from how many came back.\n *\n * Without it the view cannot tell a reader \"there is more here than I am showing you\", and a\n * truncated slice looks exactly like a complete one — which is the failure this whole branch\n * has been about. Read off the first row rather than asked for: `matched` is constant down the\n * column, and an empty answer has no row and no matches, which agree.\n */\n n: Number(numbers(points, \"matched\")[0] ?? 0),\n ...arrays(points, links, c.size ? \"size\" : undefined, c.subject !== undefined),\n }),\n };\n}\n\n/**\n * The half of a source that runs a [`Plan`], and the half that answers when nobody asked.\n *\n * Both sources need exactly this and neither should own a second copy of it — which is why it is a\n * function over the reads rather than two blocks of the same bookkeeping. What it holds is the\n * *standing* plan: the last question the camera put, kept so an answer arriving because the page\n * filtered something can be put back together the same way.\n */\nfunction watcher(reads: Reads) {\n let standing: Plan | null = null;\n let listener: ((slice: Slice) => void) | null = null;\n /** The half-answers of a push, waiting for their sibling. */\n const landed = new Map<\"points\" | \"links\", unknown>();\n\n const arrived = (half: \"points\" | \"links\") => (data: unknown) => {\n if (!standing || !listener) return;\n landed.set(half, data);\n if (landed.size < 2) return;\n const points = landed.get(\"points\");\n const links = landed.get(\"links\");\n landed.clear();\n listener(standing.assemble(points, links));\n };\n\n return {\n api: {\n /**\n * Say when the answer changes for a reason the camera cannot see.\n *\n * The reason it hands over a whole `Slice` rather than a nudge to ask again: the coordinator\n * has *already* re-run both reads with the new predicate by the time we hear about it. Asking\n * again would run the same two queries a second time to learn what is in hand.\n */\n watch(answered: (slice: Slice) => void): () => void {\n listener = answered;\n reads.points.onAnswer = arrived(\"points\");\n reads.links.onAnswer = arrived(\"links\");\n return () => {\n listener = null;\n landed.clear();\n standing = null;\n reads.points.release();\n reads.links.release();\n reads.meta.release();\n };\n },\n },\n async run(plan: Plan): Promise<Slice> {\n standing = plan;\n // Cleared because these two are halves of the *previous* question: keeping one would pair a\n // stale rectangle's points with the new rectangle's links the next time the page filters.\n landed.clear();\n const [points, links] = await Promise.all([\n reads.points.ask(plan.points),\n reads.links.ask(plan.links),\n ]);\n return plan.assemble(points, links);\n },\n };\n}\n\n/**\n * The reader's own gesture, as a clause — and the graph exempted from it.\n *\n * `clausePoints` defaults `clients` to the clause's source when that source is itself a client, which\n * is the exemption a crossfilter is built on. Here the source is a plain object and there are two\n * clients to exempt, so the set is written out: a lasso must filter the page's charts and leave the\n * canvas showing what the reader lassoed *in context*, rather than deleting everything else.\n */\nfunction publishSelection(\n reads: Reads,\n filterBy: Selection | undefined,\n idField: string,\n vertices: readonly VertexId[] | null,\n): void {\n if (!filterBy) return;\n filterBy.update(\n clausePoints([idField], vertices?.map((vertex) => [denseOf(vertex)]), {\n source: reads.points,\n clients: new Set([reads.points, reads.links]),\n }),\n );\n}\n\n/**\n * Arrow columns to the parallel typed arrays the renderer takes — sized once, filled in place.\n *\n * `fillColumn` rather than `numbers`: Arrow already hands back a typed buffer, and the obvious route\n * through `Array.from(...).map(Number)` allocates two full-length boxed arrays on the way to a third\n * that was the actual destination. Three copies to move nothing. Here the point buffers are sized\n * against the query's own `LIMIT` and each column is written straight into its stride, so `x` and\n * `y` interleave with no seam between them and no intermediate at all.\n */\nfunction arrays(\n points: unknown,\n links: unknown,\n sizeField?: string,\n withSubjects = false,\n): Omit<Slice, \"n\"> {\n // Sized from the answer rather than from the request's `limit`, which it used to be: an answer\n // carries the window's marks *and* the anchors the edges leaving it end at, so `limit` is no longer\n // an upper bound on the rows. Under-sizing here would drop the anchors silently and leave every\n // link that pointed at one indexing past the buffer.\n const rows = countOf(points, \"x\");\n const positions = new Float32Array(rows * 2);\n const n = fillColumn(points, \"x\", positions, 0, 2);\n fillColumn(points, \"y\", positions, 1, 2);\n\n // Where the marks stop. `mark` is `TRUE` down the sample and `FALSE` down the anchors, and the\n // query orders by `local`, so this is a prefix length rather than a count — which is what lets\n // `residentOf` and `buffers` treat \"is this a mark\" as an index comparison.\n const marked = new Int32Array(rows);\n fillColumn(points, \"mark\", marked);\n let marks = 0;\n while (marks < n && marked[marks] !== 0) marks++;\n\n // The dense ids land in a scratch and are widened into identities as they are copied across.\n //\n // In place, into the destination, would be better and is not available: `fillColumn` writes\n // `Number(…)`, and a `BigUint64Array` element takes a `bigint` only — assigning a `number` to one\n // throws rather than coercing. That refusal is the same guarantee this whole change is for, so the\n // extra `n`-long buffer is the price of the boundary being enforced by the runtime and not by us.\n const dense = new Float64Array(n);\n fillColumn(points, \"id\", dense);\n const vertices = new BigUint64Array(n);\n for (let i = 0; i < n; i++) vertices[i] = vertexId(VERTEX_TYPE, dense[i] as number);\n const categories = new Uint16Array(n);\n fillColumn(points, \"category\", categories);\n\n // `column` rather than `fillColumn`: an IRI is a string, so there is no typed buffer to write\n // into and no interleaving to express. It is the one thing a slice carries that never reaches the\n // GPU, which is why asking for it is a decision rather than a default.\n const subjects = withSubjects ? (column(points, \"subject\") as string[]) : undefined;\n\n let sizes: Float32Array | undefined;\n if (sizeField) {\n sizes = new Float32Array(n);\n fillColumn(points, sizeField, sizes);\n }\n\n // The edge count is not bounded by the point limit, so it is asked for rather than assumed.\n const edgeCount = countOf(links, \"src\");\n const edges = new Float32Array(edgeCount * 2);\n const wrote = fillColumn(links, \"src\", edges, 0, 2);\n fillColumn(links, \"dst\", edges, 1, 2);\n\n return {\n marks,\n vertices,\n subjects,\n positions: positions.subarray(0, n * 2),\n links: edges.subarray(0, wrote * 2),\n categories,\n sizes,\n };\n}\n\n/** A row count for a result that does not advertise one, without materialising the rows. */\nfunction countOf(rows: unknown, field: string): number {\n const advertised = (rows as { numRows?: number } | null)?.numRows;\n if (typeof advertised === \"number\") return advertised;\n const child = (rows as { getChild?: (f: string) => { length: number } | null })?.getChild?.(field);\n if (child) return child.length;\n return Array.from(rows as Iterable<unknown>).length;\n}\n\n/**\n * A corpus that fossil wrote, read by address — **and the addressing is fossil's.**\n *\n * **The five things a call site used to know, and now does not.** Drawing a corpus meant deriving\n * the chunk URLs from a `chunk_size` copied by hand, knowing how a tile is named, knowing what the\n * edge directory is called, knowing GraphAr's column names, and knowing that a glob cannot work over\n * a plain HTTP origin because there is no listing. Five conventions and about forty lines, none of\n * it the business of something that wants to draw a graph. `/docs/design/graph` carries the\n * argument; the copied `chunk_size` carries the evidence, because it went stale and read\n * a fraction of a corpus in silence for as long as it did.\n *\n * The consumer holds two things: **the corpus fossil opened, and the page's engine.**\n *\n * ```ts\n * const e = await engine();\n * const corpus = await open(\"/bench/1000000\", { query: e.query });\n * const { source } = await openCorpus({ corpus, engine: e });\n * ```\n *\n * **And this side no longer knows the conventions either, which is the change.** It used to remove\n * that defect for its callers by committing it one level down — a YAML line-scanner, a\n * `chunk{k}.parquet` spelling, a `by_source/tile{k}.parquet` spelling and a `HEAD`-probing search\n * for a tile count — and by the time those were deleted all four were **wrong**: fossil writes\n * `container: rowgroups`, one `tiles.parquet` whose row groups are the tiles, and `vertex_count`\n * had been in the manifest the whole time the search was probing for it. Fossil's `open` is\n * `fossil_graph::plan` compiled to wasm32: the arithmetic the native reader runs, not a second\n * implementation of it that agrees until it does not.\n *\n * **Addressed, not queried.** The manifests and the per-tile boxes are read once and kept; after\n * that a camera move is arithmetic over boxes and a list of URLs. There is deliberately no request\n * on the path between the camera moving and a URL being computable — the moment there is one, this\n * has become the `viewport` verb fossil deleted.\n *\n * **What it is not.** It takes no column names and no type index. Those come from the manifest or\n * they do not come. There used to be a second source here for the case this is not — an arbitrary\n * relation with `x`/`y` that nobody wrote as a corpus — and taking `idField` here would have been\n * that one with extra steps. It is gone, so the rule is simpler than the guard against it: a column\n * name reaching this door is a name the manifest should have carried.\n */\n\nexport interface OpenCorpusOptions {\n /**\n * The corpus, as fossil's `open` answered it — **opened by the host, and once.**\n *\n * This door used to open one itself from a `dest`, a `readText` and a `wasm`, which made it the\n * third open of the same corpus on a keasy discover visit: the host had already opened it to\n * query it, and opened it again only so that this could. Opening is the host's, because what it\n * takes is the host's — where the corpus is, and what signs it. Drawing takes the result.\n */\n corpus: Corpus;\n /**\n * The page's engine — `engine()` from `@kanzo-tech/mosaic`. The canvas reads its tiles and\n * publishes its clauses through the coordinator; the files are the ones the corpus already made\n * readable, by the names its addressing gives them.\n */\n engine: Pick<Engine, \"coordinator\">;\n /**\n * The crossfilter this graph draws inside — the same `Selection` the page's charts filter by.\n *\n * Given, the predicate rides in the slice query and the canvas draws what survives.\n */\n filterBy?: Selection;\n /**\n * Which vertex type to draw, when a corpus carries more than one.\n *\n * Defaults to the first the manifest names. A corpus of one type never passes it; a corpus of\n * several has to, because *which graph do you mean* is not a question a reader can answer.\n */\n vertexType?: string;\n /**\n * Read the identity column as well. **Off by default, and the same trade as everywhere else:**\n * `subject` costs about twice the drawing tile, so the drawing path carries addresses and a host\n * asks for names when something has to be *named* rather than painted.\n */\n subjects?: boolean;\n}\n\n/** A relation this source draws: its address, and the adjacency it reads it from. */\ninterface DrawnRelation {\n readonly address: EdgeAddress;\n /** The source-ordered orientation — the CSR one, which is the drawing read. */\n readonly adjacency: ProjectionAddress;\n}\n\n/** One tile's bounding box, from the footer. A tile with no `x`/`y` statistics is not in the list. */\ninterface TileBox {\n tile: number;\n x0: number;\n x1: number;\n y0: number;\n y1: number;\n}\n\n/**\n * An opened corpus: the half that **draws** and the half that **answers**.\n *\n * A host needs both over the same bytes and they are not the same access. The canvas reads tiles by\n * address — a handful of files per camera move, chosen from the footer's boxes, with no query. A\n * chart, a crossfilter clause or a verb reads the *relation*: every row, by column name, in SQL.\n * Hiding the URLs behind `source` is right for the first and leaves the second with nothing to\n * query, so this hands on the names fossil's verbs already query the corpus by.\n */\nexport interface OpenedCorpus {\n /**\n * For the canvas: `<GraphCanvas source={…}>`. Reads tiles, never the whole relation.\n *\n * A `DuckSource`, and `CorpusSource` is gone with the reason it existed: `extent()` was declared\n * there because a source over an unlaid-out relation has no answer to it, and *optional on the\n * base contract* says that better than a second interface — a relation with `x`/`y` has an extent\n * too, and it was the one host that could not frame its opening view.\n */\n source: DuckSource;\n /**\n * The vertex relation, as fossil registered it — qualified by the corpus's catalog.\n *\n * Every column the manifest declares, including the corpus' own properties — so a clause a chart\n * publishes over `kind` or `region` lands here with no translation, which is what makes one\n * crossfilter serve the canvas and the charts.\n */\n nodes: string;\n /**\n * The source-ordered edge relations this canvas draws, one fossil relation each.\n *\n * **A list, because a corpus declares a list.** This was one name, picked with `.find` over the\n * relations whose source is this type — so a corpus declaring two edge labels registered the\n * first and dropped the second with nothing raised, and a chart over `corpus_Person_edges` was a\n * chart over half the graph. `frame` on the other side takes every one of them\n * (`packages/corpus/src/corpus.ts`, `edges.filter((e) => e.srcType === address.type)`), and\n * fossil's own `verbs()` registers a view per relation under `{src}_{edge}_{dst}` rather than\n * unioning them — which is also what keeps two relations' differing property columns from having\n * to agree on one schema.\n *\n * Empty when the corpus declares no relation this source can draw; {@link OpenedCorpus.undrawn}\n * is then where the ones it declared went.\n */\n edges: readonly EdgeRelation[];\n /**\n * The relations incident to this type that the canvas does **not** read, and why.\n *\n * A single-type canvas can draw a relation only where *both* endpoints are numbered in its own\n * `dense_id` space. The rest are dropped from the read rather than mixed into it, and reported\n * here rather than dropped in silence.\n */\n undrawn: readonly UndrawnRelation[];\n}\n\n/** One edge relation of the drawn type, and the relation fossil registered its adjacency as. */\nexport interface EdgeRelation {\n readonly edgeType: string;\n readonly srcType: string;\n readonly dstType: string;\n /** Fossil's relation, qualified by the corpus's catalog — its `CorpusRelation.sql`. */\n readonly view: string;\n}\n\n/**\n * One relation incident to the drawn type that this source cannot read, and why.\n *\n * **The reason is fossil's {@link GapReason} and not a second spelling of it** — `other-space` when\n * an endpoint is another vertex type, so the `dense_id` on that side numbers a different set of\n * vertices and this source holds no coordinates for any of them; `not-declared` when both endpoints\n * are this type and the corpus publishes no source-ordered adjacency to read. The two the drawing\n * read can produce; the third, `not-requested`, is `tilesFor`'s and never reaches here.\n *\n * What this adds to a `Gap` is the endpoint pair, which a `Gap` does not carry: it names a relation\n * by label and orientation, and a corpus may declare two relations under one label.\n */\nexport interface UndrawnRelation {\n readonly edgeType: string;\n readonly srcType: string;\n readonly dstType: string;\n readonly reason: GapReason;\n}\n\nexport async function openCorpus(options: OpenCorpusOptions): Promise<OpenedCorpus> {\n const { corpus, engine, filterBy, subjects = false, vertexType } = options;\n const { addressing } = corpus;\n\n const reads = openReads(engine.coordinator, filterBy);\n const meta = metaAsker(reads.meta);\n const watching = watcher(reads);\n\n const type = addressing.vertexType(vertexType);\n /**\n * Which relations this canvas may draw, and which incident ones it may not — **asked, not\n * derived.**\n *\n * A picture is one type's `dense_id` space, so a relation that LEAVES the type has its far ends\n * numbered in another type's, both spaces are dense from zero, and `BIGINT` compares against\n * `BIGINT` without complaining: the join matches and draws a line between two vertices with\n * nothing between them. The rule that refuses it used to be written here too — a filter over\n * `incident` on `srcType`/`dstType` plus a null check on the source-ordered adjacency — which was\n * the second copy of a rule that belongs to the addressing. It is `ReadPlan::drawing`\n * (`crates/fossil-graph/src/plan.rs`), in Rust, said once, and this is the call.\n *\n * **Not `tilesFor`, which answers the other question.** Its `edgeUrls` is every relation whose\n * *source* is this type, cross-type ones included — right for incidence, unusable for a picture.\n * Confusing the two is the bug this call closes.\n *\n * `by_target` is not asked for and its absence is reported rather than hidden: a window's\n * drawable edges all have their source on screen, so the source-aligned tiles are complete for\n * drawing and incomplete for incidence. `tilesFor` says which below, in `gaps`.\n */\n const drawing = addressing.drawing(type.type);\n const drawn: DrawnRelation[] = drawing.relations.map((address) => ({\n address,\n // Never null on a drawn relation: a declared source-ordered adjacency is what `drawing` admits\n // one for, and `not-declared` is why the others are in `undrawn` instead.\n adjacency: address.adjacency(\"src\") as ProjectionAddress,\n }));\n const adjacencies = drawn.map((relation) => relation.adjacency);\n /**\n * The rejected ones, with their endpoints — **the reasons are fossil's, the endpoints are the\n * corpus's, and neither is a judgement of ours.**\n *\n * The two lists are one partition of `incident` in declaration order: fossil walks the plan's\n * edges once and puts each incident one in `relations` or in `undrawn`, so the incident relations\n * missing from the first are the gaps of the second, in order. That join is here because a `Gap`\n * names a relation by label and orientation, and a label does not identify one — two relations\n * may share it — so the endpoint types cannot be read back off the gap alone.\n */\n const undrawn: UndrawnRelation[] = addressing\n .incident(type.type)\n .filter((edge) => !drawing.relations.includes(edge))\n .map((edge, index) => ({\n edgeType: edge.edgeType,\n srcType: edge.srcType,\n dstType: edge.dstType,\n reason: drawing.undrawn[index]!.reason,\n }));\n\n /**\n * The boxes, and the one query this source makes that is not a slice.\n *\n * Read on first use rather than in the factory: a host that constructs a source and never draws\n * should not pay for it, and the cost is a footer read over the payload. Kept forever after —\n * tiles are precomputed and their boxes cannot move without the corpus being rewritten.\n *\n * **Held as the promise rather than as the answer**, which the move to one shared metadata client\n * forced and which was a latent defect before it: `total()` and `extent()` are called by different\n * effects with nothing ordering them, so two loads used to run concurrently and probe the whole\n * tile range twice.\n */\n let loading: Promise<TileBox[]> | null = null;\n\n /**\n * Where the payload is, as the list of files that hold it.\n *\n * One file per tile under `container: files`, one file in total under `rowgroups` — and this side\n * does not know or care which, because the address is asked for rather than composed. Throws when\n * the manifest declares no `vertex_count`, which is the one absence that makes a corpus\n * un-enumerable.\n */\n const payloadFiles = type.files();\n /** A URL list as a SQL list literal. Every read below composes one and none of them composes a URL. */\n const quoted = (urls: readonly string[]) =>\n urls.map((url) => `'${url.replace(/'/g, \"''\")}'`).join(\", \");\n\n /**\n * The boxes, from the footer — **which tile a row group is, asked of the addressing.**\n *\n * `parquet_metadata` reports a `file_name` and a `row_group_id`, and which of the two names the\n * tile is the container's business: under `rowgroups` row group `k` IS tile `k`; under `files`\n * the file is, and its row groups are the writer's business, so their boxes are merged. This used\n * to read the tile out of the URL with `regexp_extract(file_name, 'chunk(\\d+)')` — one\n * container's spelling hard-coded into a query, and `NULL` for every row of a corpus fossil\n * writes today.\n *\n * `min_value`/`max_value`, never `min`/`max`. Parquet's original statistics fields are defined by\n * *signed* byte comparison, which is meaningless for an unsigned column — a writer that gets this\n * right leaves them empty. A reader that only knows the deprecated pair concludes the footer\n * carries no box for the column the whole address is built on. `coalesce` keeps the float columns\n * working either way.\n */\n function load(): Promise<TileBox[]> {\n loading ??= (async () => {\n const files = quoted(payloadFiles);\n const [xCol, yCol] = PAYLOAD_COORDINATES;\n // Which of `file_name` and `row_group_id` names the tile, as an expression rather than as a\n // branch in JavaScript: the grouping has to happen where the rows are either way.\n const tile =\n addressing.container === \"rowgroups\"\n ? \"row_group_id\"\n : `list_position([${files}], file_name) - 1`;\n const stats = await meta(\n `SELECT ${tile} AS tile,\n min(CASE WHEN path_in_schema = '${xCol}' THEN coalesce(stats_min_value, stats_min)::DOUBLE END) AS x0,\n max(CASE WHEN path_in_schema = '${xCol}' THEN coalesce(stats_max_value, stats_max)::DOUBLE END) AS x1,\n min(CASE WHEN path_in_schema = '${yCol}' THEN coalesce(stats_min_value, stats_min)::DOUBLE END) AS y0,\n max(CASE WHEN path_in_schema = '${yCol}' THEN coalesce(stats_max_value, stats_max)::DOUBLE END) AS y1\n FROM parquet_metadata([${files}])\n WHERE path_in_schema IN ('${xCol}', '${yCol}') GROUP BY 1 ORDER BY 1`,\n );\n const tiles = numbers(stats, \"tile\");\n const [x0, x1, y0, y1] = [\"x0\", \"x1\", \"y0\", \"y1\"].map((f) => numbers(stats, f));\n return tiles.map((t, i) => ({\n tile: t as number,\n x0: x0?.[i] as number,\n x1: x1?.[i] as number,\n y0: y0?.[i] as number,\n y1: y1?.[i] as number,\n }));\n })();\n return loading;\n }\n\n /** The tiles a rectangle touches. Pure — this is the whole of the selection, and it makes no call. */\n function intersecting(all: TileBox[], view: Viewport): number[] {\n return all\n .filter((b) => b.x1 >= view.xMin && b.x0 <= view.xMax && b.y1 >= view.yMin && b.y0 <= view.yMax)\n .map((b) => b.tile);\n }\n\n /**\n * Everything the corpus fixes, and nothing it does not — **by ROLE, not by name.**\n *\n * The address, the identity and the coordinates are facts of the format, and the names they are\n * written under come off `@fossil-lang/corpus`'s generated column table rather than four string\n * literals here. The endpoint columns come off the adjacency's own address. What colours and what\n * sizes are channels, so they arrive with the request and are filled in per slice.\n */\n const fixed = {\n id: PAYLOAD_ADDRESS[0] as string,\n subject: subjects ? (PAYLOAD_IDENTITY[0] as string) : undefined,\n x: PAYLOAD_COORDINATES[0] as string,\n y: PAYLOAD_COORDINATES[1] as string,\n source: adjacencies[0]?.column ?? \"src_dense\",\n target: drawn[0]?.address.adjacency(\"dst\")?.column ?? \"dst_dense\",\n } as const;\n\n /**\n * The relation half — **fossil's views, not a second set of ours.**\n *\n * A chart, a count or a crossfilter clause reads every row by name, so the canvas has to hand a\n * host relation names as well as tiles. This used to register its own: persistent `corpus_*`\n * views over the same files fossil's verbs already viewed as `TEMP`, with a different rule for\n * which edge files a relation is — two view sets over one corpus, and the persistent one outlived\n * it. `relations()` answers with the names the verbs query, qualified by the corpus's own catalog,\n * so closing the corpus takes them with it.\n */\n const relations = await corpus.relations();\n const relationOf = (match: (r: CorpusRelation) => boolean, what: string): string => {\n const found = relations.find(match);\n if (!found) throw new Error(`openCorpus: the corpus registers no relation for ${what}`);\n return found.sql;\n };\n const nodesView = relationOf((r) => r.kind === \"vertex\" && r.name === type.type, type.type);\n\n const source: DuckSource = {\n ...watching.api,\n\n publish(vertices) {\n publishSelection(reads, filterBy, fixed.id, vertices);\n },\n\n /**\n * How many vertices there are — **read, not probed.**\n *\n * The manifest declares `vertex_count`. What stood here was a note saying no manifest carries\n * it, and a doubling-then-bisecting `HEAD` search for the last chunk plus a\n * `parquet_file_metadata` read of its row count — about a dozen requests and a query, per\n * corpus, for a number already in hand.\n */\n async total() {\n if (type.count !== null) return Number(type.count);\n const rows = await meta(`SELECT count(*) AS n FROM ${nodesView}`);\n return Number(numbers(rows, \"n\")[0] ?? 0);\n },\n\n async extent() {\n const all = await load();\n return {\n xMin: Math.min(...all.map((b) => b.x0)),\n yMin: Math.min(...all.map((b) => b.y0)),\n xMax: Math.max(...all.map((b) => b.x1)),\n yMax: Math.max(...all.map((b) => b.y1)),\n };\n },\n\n // Regions only, and it says so by being the only method there is. A neighbourhood needs\n // adjacency this source does not index; `ExploringSource` declared the second question here and\n // was deleted with it — `index.test.ts` carries why, and fossil's `expand` is what answers it.\n async slice(request: SliceRequest): Promise<Slice> {\n const { fill, limit, minLinkPixels, perPixel, pinned, r, signal, view } = request;\n const columns: Columns = { ...fixed, category: fill, size: r };\n const all = await load();\n /**\n * The tiles the rectangle touches — **and the far view is not a special case of this.**\n *\n * It used to be: past a zoom threshold the selection was replaced by every tile, because the\n * aggregate branch was going to read the whole relation anyway. A window that covers the\n * extent already intersects every box, so the branch was arithmetic restating itself, and it\n * is the reason `Viewport` carried a `zoom` at all.\n */\n const selected = intersecting(all, view);\n // Nothing selected is a legitimate answer — the camera is over empty space — and asking\n // `read_parquet([])` is a syntax error rather than an empty result. Checked before the files\n // are fetched, so an empty window costs no bytes at all.\n if (selected.length === 0) {\n return {\n n: 0,\n marks: 0,\n vertices: new BigUint64Array(0),\n positions: new Float32Array(0),\n links: new Float32Array(0),\n categories: new Uint16Array(0),\n };\n }\n\n /**\n * The URLs those tiles are in — **asked, not composed**, and distinct.\n *\n * The vertex half off `tilesFor`; the edge half off each drawn relation's own adjacency,\n * which is `frame`'s shape for the same reason (`corpus.ts`, `edgeLevels.flatMap((set) =>\n * held.map((tile) => set.tileUrl(tile)))`). `tilesFor`'s `edgeUrls` is every relation whose\n * SOURCE is this type, so a cross-type one is in it — and `drawing` is where that set is\n * narrowed to the relations whose far end this source can position. The source-ordered\n * orientation is the drawing read either way: every edge a window can draw has its source on\n * screen, therefore in one of these files.\n */\n const addressed = addressing.tilesFor({\n type: type.type,\n tiles: selected,\n directions: [\"src\"],\n });\n const edgeUrls = [\n ...new Set(adjacencies.flatMap((a) => selected.map((tile) => a.tileUrl(tile)))),\n ];\n // Both halves at once: the vertex files and the edge files a window touches are independent\n // reads, and the window is not drawable until both have landed.\n /**\n * The camera moved while the tiles were arriving, so this question is already the wrong one.\n *\n * Checked here rather than left to the caller because of what comes next: the reads hold one\n * standing question each, so a request that resumes after the loop moved on would *supersede*\n * the newer one and reject it — the stale question winning the race against the live one.\n *\n * `signal.reason` and not a sentinel of ours: an aborted signal already carries what it was\n * aborted with, which for `AbortController.abort()` is a `DOMException` named `AbortError`.\n * Rethrowing it is the whole of the cancellation contract a source owes — see `SliceRequest`.\n */\n if (signal?.aborted) throw signal.reason;\n const nodes = `read_parquet([${quoted(addressed.vertexUrls)}])`;\n // A corpus that declares no adjacency for this type still has to answer: the links query is\n // built either way, so what it reads is an empty relation of the right shape rather than a\n // `read_parquet([])`, which is a syntax error, or the vertex view, which has neither column.\n const relation =\n edgeUrls.length > 0\n ? `read_parquet([${quoted(edgeUrls)}])`\n : `(SELECT NULL::BIGINT AS ${columns.source}, NULL::BIGINT AS ${columns.target} WHERE FALSE)`;\n\n // `nodes` is both the window's rows and the bytes the reader holds: the tiles that answer\n // \"what is in the rectangle\" are the tiles that answer \"where is the far end of an edge that\n // leaves it\".\n return watching.run(region(nodes, relation, columns, view, limit, pinned, perPixel, minLinkPixels));\n },\n };\n\n return {\n source,\n nodes: nodesView,\n edges: drawn.map(({ address }) => ({\n edgeType: address.edgeType,\n srcType: address.srcType,\n dstType: address.dstType,\n view: relationOf(\n (r) =>\n r.kind === \"edge\" &&\n r.edgeType === address.edgeType &&\n r.srcType === address.srcType &&\n r.dstType === address.dstType,\n `${address.srcType}_${address.edgeType}_${address.dstType}`,\n ),\n })),\n undrawn,\n };\n}\n"],"names":[],"mappings":";;;;;AAuEA;AAwCA;AACE;AAAO;AACsC;AACD;AACX;AAEnC;AAUA;AACE;AACA;AACE;AACA;AAAmB;AACZ;AAEX;AAUA;AACE;AAEA;AACA;AACF;AAGA;AACE;AAGF;AAuBA;AAOE;AAN2C;AACpB;AACA;AACA;AACA;AAKvB;AACF;AAeA;AAyBA;AAOE;AAYA;AAAO;AAGL;AAAA;AAEY;AACC;AAAA;AAIb;AACiB;AAAA;AAAA;AAGY;AAChB;AAEjB;AAYA;AAME;AACA;AACA;AACF;AA+BA;AASE;AAEA;AAAO;AACsE;AAAA;AAAA;AAAA;AAAA;AAK/D;AACgB;AACA;AAAA;AAAA;AAG8C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQ9E;AAsBA;AAUE;AA2BA;AAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAS4D;AACS;AAAA;AAI1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAYwB;AAAA;AAAA;AAAA;AAAA;AAK1C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASgB;AACiC;AAAA;AAGnF;AAUA;AACE;AAGA;AAKE;AACA;AAEA;AACyC;AAG3C;AAAO;AACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASD;AAIE;AAKW;AACb;AACF;AAAA;AAGA;AAIA;AAA0C;AACZ;AACF;AAE5B;AAAkC;AACpC;AAEJ;AAUA;AAME;AACS;AAC+D;AACtD;AAC8B;AAC7C;AAEL;AAWA;AAUE;AAGA;AAKA;AACA;AACA;AACA;AAQA;AACA;AACA;AACA;AACA;AACA;AAKA;AAEA;AACA;AAMA;AAGA;AAEO;AACL;AACA;AACA;AACsC;AACJ;AAClC;AACA;AAEJ;AAGA;;AACE;AACA;AACA;AACA;AAEF;AAgLA;;AACE;AA6BmE;AACjE;AAAA;AAAA;AAGkC;AAgBX;AACN;AACD;AACA;AACkB;AAepC;AAUA;AAqBA;AACE;AACE;AAQoB;AACJ;AAC2B;AACA;AACA;AACA;AACV;AACa;AAI9C;AAA4B;AACpB;AACG;AACA;AACA;AACA;AACT;AAEG;AAIT;AACE;AAEoB;AAWtB;AAAc;AACS;AACiC;AAC9B;AACA;AACU;AACoB;AAetD;AACA;AACA;AAAa;AAkHf;AAAO;AA9GoB;AACb;AAGV;AAAoD;AACtD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAWE;AACA;AACA;AAAwC;AAC1C;AAGE;AACA;AAAO;AACiC;AACA;AACA;AACA;AAAA;AAE1C;AAAA;AAAA;AAAA;AAME;AAeA;AACE;AAAO;AACF;AACI;AACuB;AACD;AACJ;AACI;AAejC;AAAsC;AACzB;AACJ;AACW;AAEH;AAC+D;AAehF;AACA;AAYA;AAAkG;AACpG;AAAA;AAKO;AAC4B;AACf;AACD;AACA;AACX;AAKoB;AACiC;AAAA;AAE3D;AACF;AAEJ;;;;"}
@@ -25,7 +25,7 @@ export interface Sim {
25
25
  *
26
26
  * Chosen against a corpus of that size, and they are a starting point rather than a law: a graph
27
27
  * two orders of magnitude larger wants less repulsion and more friction, and the measurements in
28
- * `BENCHMARKS.md` say a live simulation is finished by around 200,000 points regardless. What a
28
+ * `/docs/graph/benchmarks` say a live simulation is finished by around 200,000 points regardless. What a
29
29
  * corpus of a given size wants is computed rather than chosen — see `adaptive` — and a host that
30
30
  * knows its corpus should start its users at that answer through the tenant policy.
31
31
  *
@@ -1 +1 @@
1
- {"version":3,"file":"graph-sim.js","sources":["../src/graph-sim.ts"],"sourcesContent":["/**\n * The force coefficients, and the one function that builds them from the axes a person chose.\n *\n * `graph-looks.ts`'s sibling, and deliberately its twin: a type the renderer consumes, one builder\n * that reads declared string values, and a default that is the builder called with nothing. What was\n * here before is what was there before — an interface, a `DEFAULT_SIM` table beside it, and a dock\n * that held six of them in React state and drew six sliders by hand.\n *\n * **A coefficient is not appearance, and it is still a preference.** `section.ts` says why it shares\n * the manifest: `tokens` are the colours a document may move, `prefs` are what the person on the\n * screen decides, and somebody tuning a layout until it settles is deciding.\n */\n\n/** Force coefficients, handed straight to the GPU simulation. */\nexport interface Sim {\n gravity: number;\n repulsion: number;\n linkSpring: number;\n linkDistance: number;\n friction: number;\n /** Pull toward the node's group position on the cluster ring. Zero lets the links decide alone. */\n cluster: number;\n}\n\n/**\n * Coefficients that settle a few-hundred-node graph into something readable.\n *\n * Chosen against a corpus of that size, and they are a starting point rather than a law: a graph\n * two orders of magnitude larger wants less repulsion and more friction, and the measurements in\n * `BENCHMARKS.md` say a live simulation is finished by around 200,000 points regardless. What a\n * corpus of a given size wants is computed rather than chosen — see `adaptive` — and a host that\n * knows its corpus should start its users at that answer through the tenant policy.\n *\n * The values are strings for the reason `lookFrom`'s are: a contributed preference is a string in\n * all three kinds, so an unrecognised namespace rides through a write untouched. A key that is\n * missing, or that carries a value the section never offered, takes the default below — which is\n * what a graph simulated before any of this existed.\n */\nexport function simFrom(values: Readonly<Record<string, string | undefined>> = {}): Sim {\n const num = (key: string, fallback: number) => {\n const value = Number.parseFloat(values[key] ?? \"\");\n return Number.isFinite(value) ? value : fallback;\n };\n return {\n gravity: num(\"gravity\", 0.14),\n repulsion: num(\"repulsion\", 1.1),\n linkSpring: num(\"link-spring\", 0.6),\n linkDistance: num(\"link-distance\", 18),\n friction: num(\"friction\", 0.86),\n cluster: num(\"cluster\", 0.1),\n };\n}\n\n/**\n * What a canvas simulates with when nobody has chosen anything.\n *\n * **Not on the barrel, and it used to be** — for the reason its twin was: it is `simFrom()`, a\n * second public name for a value already available on request, exported only because `sim` took a\n * whole `Sim` and a host changing `gravity` had to supply the other five.\n */\nexport const DEFAULT_SIM: Sim = simFrom();\n\n/**\n * The coefficients a caller wants different, over the ones this package chose.\n *\n * Flat, so a spread is the whole merge. `undefined` returns the shared constant rather than a copy:\n * `useRenderer` re-heats when the forces change and tells them apart by reference, so a fresh object\n * per render would put energy back into a settled layout on every render.\n */\nexport function resolveSim(patch?: Partial<Sim>): Sim {\n if (!patch) return DEFAULT_SIM;\n return { ...DEFAULT_SIM, ...patch };\n}\n"],"names":["simFrom","values","num","key","fallback","value","DEFAULT_SIM","resolveSim","patch"],"mappings":"AAsCO,SAASA,EAAQC,IAAuD,IAAS;AACtF,QAAMC,IAAM,CAACC,GAAaC,MAAqB;AAC7C,UAAMC,IAAQ,OAAO,WAAWJ,EAAOE,CAAG,KAAK,EAAE;AACjD,WAAO,OAAO,SAASE,CAAK,IAAIA,IAAQD;AAAA,EAC1C;AACA,SAAO;AAAA,IACL,SAASF,EAAI,WAAW,IAAI;AAAA,IAC5B,WAAWA,EAAI,aAAa,GAAG;AAAA,IAC/B,YAAYA,EAAI,eAAe,GAAG;AAAA,IAClC,cAAcA,EAAI,iBAAiB,EAAE;AAAA,IACrC,UAAUA,EAAI,YAAY,IAAI;AAAA,IAC9B,SAASA,EAAI,WAAW,GAAG;AAAA,EAAA;AAE/B;AASO,MAAMI,IAAmBN,EAAA;AASzB,SAASO,EAAWC,GAA2B;AACpD,SAAKA,IACE,EAAE,GAAGF,GAAa,GAAGE,EAAA,IADTF;AAErB;"}
1
+ {"version":3,"file":"graph-sim.js","sources":["../src/graph-sim.ts"],"sourcesContent":["/**\n * The force coefficients, and the one function that builds them from the axes a person chose.\n *\n * `graph-looks.ts`'s sibling, and deliberately its twin: a type the renderer consumes, one builder\n * that reads declared string values, and a default that is the builder called with nothing. What was\n * here before is what was there before — an interface, a `DEFAULT_SIM` table beside it, and a dock\n * that held six of them in React state and drew six sliders by hand.\n *\n * **A coefficient is not appearance, and it is still a preference.** `section.ts` says why it shares\n * the manifest: `tokens` are the colours a document may move, `prefs` are what the person on the\n * screen decides, and somebody tuning a layout until it settles is deciding.\n */\n\n/** Force coefficients, handed straight to the GPU simulation. */\nexport interface Sim {\n gravity: number;\n repulsion: number;\n linkSpring: number;\n linkDistance: number;\n friction: number;\n /** Pull toward the node's group position on the cluster ring. Zero lets the links decide alone. */\n cluster: number;\n}\n\n/**\n * Coefficients that settle a few-hundred-node graph into something readable.\n *\n * Chosen against a corpus of that size, and they are a starting point rather than a law: a graph\n * two orders of magnitude larger wants less repulsion and more friction, and the measurements in\n * `/docs/graph/benchmarks` say a live simulation is finished by around 200,000 points regardless. What a\n * corpus of a given size wants is computed rather than chosen — see `adaptive` — and a host that\n * knows its corpus should start its users at that answer through the tenant policy.\n *\n * The values are strings for the reason `lookFrom`'s are: a contributed preference is a string in\n * all three kinds, so an unrecognised namespace rides through a write untouched. A key that is\n * missing, or that carries a value the section never offered, takes the default below — which is\n * what a graph simulated before any of this existed.\n */\nexport function simFrom(values: Readonly<Record<string, string | undefined>> = {}): Sim {\n const num = (key: string, fallback: number) => {\n const value = Number.parseFloat(values[key] ?? \"\");\n return Number.isFinite(value) ? value : fallback;\n };\n return {\n gravity: num(\"gravity\", 0.14),\n repulsion: num(\"repulsion\", 1.1),\n linkSpring: num(\"link-spring\", 0.6),\n linkDistance: num(\"link-distance\", 18),\n friction: num(\"friction\", 0.86),\n cluster: num(\"cluster\", 0.1),\n };\n}\n\n/**\n * What a canvas simulates with when nobody has chosen anything.\n *\n * **Not on the barrel, and it used to be** — for the reason its twin was: it is `simFrom()`, a\n * second public name for a value already available on request, exported only because `sim` took a\n * whole `Sim` and a host changing `gravity` had to supply the other five.\n */\nexport const DEFAULT_SIM: Sim = simFrom();\n\n/**\n * The coefficients a caller wants different, over the ones this package chose.\n *\n * Flat, so a spread is the whole merge. `undefined` returns the shared constant rather than a copy:\n * `useRenderer` re-heats when the forces change and tells them apart by reference, so a fresh object\n * per render would put energy back into a settled layout on every render.\n */\nexport function resolveSim(patch?: Partial<Sim>): Sim {\n if (!patch) return DEFAULT_SIM;\n return { ...DEFAULT_SIM, ...patch };\n}\n"],"names":["simFrom","values","num","key","fallback","value","DEFAULT_SIM","resolveSim","patch"],"mappings":"AAsCO,SAASA,EAAQC,IAAuD,IAAS;AACtF,QAAMC,IAAM,CAACC,GAAaC,MAAqB;AAC7C,UAAMC,IAAQ,OAAO,WAAWJ,EAAOE,CAAG,KAAK,EAAE;AACjD,WAAO,OAAO,SAASE,CAAK,IAAIA,IAAQD;AAAA,EAC1C;AACA,SAAO;AAAA,IACL,SAASF,EAAI,WAAW,IAAI;AAAA,IAC5B,WAAWA,EAAI,aAAa,GAAG;AAAA,IAC/B,YAAYA,EAAI,eAAe,GAAG;AAAA,IAClC,cAAcA,EAAI,iBAAiB,EAAE;AAAA,IACrC,UAAUA,EAAI,YAAY,IAAI;AAAA,IAC9B,SAASA,EAAI,WAAW,GAAG;AAAA,EAAA;AAE/B;AASO,MAAMI,IAAmBN,EAAA;AASzB,SAASO,EAAWC,GAA2B;AACpD,SAAKA,IACE,EAAE,GAAGF,GAAa,GAAGE,EAAA,IADTF;AAErB;"}
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * `ui` is the generic vocabulary shared by every product. A sibling package is also the only place
15
15
  * a required WebGL peer belongs.
16
16
  *
17
- * **The measurements that shape it** are in `BENCHMARKS.md`: a live simulation is comfortable to
17
+ * **The measurements that shape it** are on `/docs/graph/benchmarks`: a live simulation is comfortable to
18
18
  * about 50,000 points and finished by 200,000, and past that the honest design is positions
19
19
  * precomputed once and stored as a column — which is why a simulation is opt-in here and off by
20
20
  * default, and why the render path is bounded rather than fast.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAeH,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAC;AAqBrE,OAAO,EAIL,QAAQ,EACR,KAAK,IAAI,EACT,KAAK,SAAS,EACd,KAAK,KAAK,GACX,MAAM,eAAe,CAAC;AAQvB,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAEjE;;;;;;;;;;;;GAYG;AACH,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,eAAe,EACf,KAAK,gBAAgB,EACrB,KAAK,sBAAsB,GAC5B,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,QAAQ,EAAE,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAgC5F,OAAO,EAAE,gBAAgB,EAAE,KAAK,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAC5E,OAAO,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAEtE;;;;;;;;GAQG;AACH,OAAO,EACL,UAAU,EACV,QAAQ,EACR,MAAM,EACN,OAAO,EACP,KAAK,QAAQ,EACb,KAAK,QAAQ,GACd,MAAM,YAAY,CAAC;AAwBpB;;;;;;;;;;;;;;GAcG;AAoBH,OAAO,EACL,WAAW,EACX,KAAK,aAAa,EAClB,KAAK,KAAK,EACV,KAAK,YAAY,EACjB,KAAK,QAAQ,GACd,MAAM,WAAW,CAAC;AAsBnB,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAKtC,OAAO,EAAE,YAAY,EAAE,KAAK,EAAE,KAAK,IAAI,EAAE,MAAM,aAAa,CAAC;AAE7D,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,MAAM,EACX,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,IAAI,GACV,MAAM,SAAS,CAAC;AASjB,OAAO,EAAE,OAAO,EAAE,KAAK,GAAG,EAAE,MAAM,aAAa,CAAC;AAIhD,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAeH,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAC;AAqBrE,OAAO,EAIL,QAAQ,EACR,KAAK,IAAI,EACT,KAAK,SAAS,EACd,KAAK,KAAK,GACX,MAAM,eAAe,CAAC;AAQvB,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAEjE;;;;;;;;;;;;GAYG;AACH,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,eAAe,EACf,KAAK,gBAAgB,EACrB,KAAK,sBAAsB,GAC5B,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,QAAQ,EAAE,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAgC5F,OAAO,EAAE,gBAAgB,EAAE,KAAK,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAC5E,OAAO,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAEtE;;;;;;;;GAQG;AACH,OAAO,EACL,UAAU,EACV,QAAQ,EACR,MAAM,EACN,OAAO,EACP,KAAK,QAAQ,EACb,KAAK,QAAQ,GACd,MAAM,YAAY,CAAC;AAuBpB;;;;;;;;;;;;;;GAcG;AAoBH,OAAO,EACL,WAAW,EACX,KAAK,aAAa,EAClB,KAAK,KAAK,EACV,KAAK,YAAY,EACjB,KAAK,QAAQ,GACd,MAAM,WAAW,CAAC;AAsBnB,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAKtC,OAAO,EAAE,YAAY,EAAE,KAAK,EAAE,KAAK,IAAI,EAAE,MAAM,aAAa,CAAC;AAE7D,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,MAAM,EACX,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,IAAI,GACV,MAAM,SAAS,CAAC;AASjB,OAAO,EAAE,OAAO,EAAE,KAAK,GAAG,EAAE,MAAM,aAAa,CAAC;AAIhD,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC"}
@@ -30,7 +30,7 @@ import { Slice } from './bounded';
30
30
  * where Java and Go both give `1152921504606846977`. JavaScript's `number` is 53 bits and a cell id
31
31
  * is 64, and a binding cannot protect a language boundary that cannot hold the value. H3 settled it
32
32
  * by decree before anyone could get it wrong: `h3-js` types `H3Index` as a string. This is the same
33
- * decree with the type JavaScript grew for it. See rmlext ADR-0045.
33
+ * decree with the type JavaScript grew for it.
34
34
  *
35
35
  * **And `>>` means three different things across our three layers.** In JavaScript it converts its
36
36
  * operand to *32 bits* and takes the shift count modulo 32, so the obvious `dense | (type << 32)`
@@ -1 +1 @@
1
- {"version":3,"file":"resident.js","sources":["../src/resident.ts"],"sourcesContent":["import type { Slice } from \"./bounded\";\n\n/**\n * Who is drawn, and where the GPU is drawing them.\n *\n * **A buffer index numbers the answer, not the corpus.** cosmos.gl addresses every point by its\n * position in the arrays it was last handed, so index 7 is whatever the current answer put seventh —\n * and a resident set that comes and goes reuses every index while the vertices behind them change.\n * Anything that outlives one answer — a selection, a label, a hover, a pin — therefore has to be\n * held as an identity and re-resolved against whatever is drawn now.\n *\n * **A vertex is the pair `(type_idx, dense_id)`.** Not `dense_id` alone: it numbers\n * within one vertex type, so a union of two types repeats every value and the same number names two\n * different vertices. A `LIMIT` breaks the correspondence between an id and a position regardless of\n * how many types there are.\n */\n\n/**\n * A vertex identity — the pair, packed into one 64-bit integer so it can key a `Map` and a `Set`.\n *\n * **A `bigint`, which is strictly stronger than the brand it also wears.** A buffer index is a\n * `number`; an identity is a `bigint`. Confusing the two therefore stops being a branding\n * convention that holds while everyone remembers it and becomes a *primitive* type error — and no\n * cast quietly launders one: `7 as VertexId` was legal against `number & brand` and is a compile\n * error against `bigint & brand`. Getting one wrong now costs `as unknown as`, which is loud.\n *\n * **The evidence this is worth the friction, because it is not a precaution we invented.** Across\n * every multi-language format surveyed — Arrow, Parquet, Iceberg, Delta, MVT, PMTiles, Zarr,\n * GraphAr, H3, S2 — the failure that recurs is a 64-bit value crossing into JavaScript.\n * `mapbox/node-s2` is a **binding**, not a port: it calls the same C++ the reference implementation\n * does, and it still returned wrong cell ids — issue #92, open since 2017, `1152921504606847000`\n * where Java and Go both give `1152921504606846977`. JavaScript's `number` is 53 bits and a cell id\n * is 64, and a binding cannot protect a language boundary that cannot hold the value. H3 settled it\n * by decree before anyone could get it wrong: `h3-js` types `H3Index` as a string. This is the same\n * decree with the type JavaScript grew for it. See rmlext ADR-0045.\n *\n * **And `>>` means three different things across our three layers.** In JavaScript it converts its\n * operand to *32 bits* and takes the shift count modulo 32, so the obvious `dense | (type << 32)`\n * is not a lost high word — it is `type | dense`, silently. The packing below cannot be written on\n * `number` at all and be right.\n *\n * What goes to the GPU does not change: positions and buffer indices stay `number` and\n * `Float32Array`. The conversion happens at the edge, which is where it belongs.\n */\nexport type VertexId = bigint & { readonly vertex: unique symbol };\n\n/**\n * One type's worth of dense ids.\n *\n * `dense_id` is a `UInt32`, so the type index occupies everything above bit 32 — exactly, for all\n * 2³² of them, which is the whole point of the width. The old packing was `type * 2**32 + dense` in\n * `float64` and ran out of exactness at type index 2²¹.\n */\nconst TYPE_SHIFT = 32n;\nconst DENSE_MASK = 0xffff_ffffn;\n\nexport function vertexId(type: number, dense: number): VertexId {\n return ((BigInt(type) << TYPE_SHIFT) | BigInt(dense)) as VertexId;\n}\n\n/** Both halves come back as `number`: each is 32 bits, and a `number` holds those exactly. */\nexport function typeOf(vertex: VertexId): number {\n return Number(vertex >> TYPE_SHIFT);\n}\n\nexport function denseOf(vertex: VertexId): number {\n return Number(vertex & DENSE_MASK);\n}\n\n/**\n * The identity↔index map for whatever is drawn right now.\n *\n * Built once per residency change and never mutated, because that is what a residency change *is*: a\n * different set of vertices, in a different order. It belongs to whoever owns residency — that is\n * `useQueryLoop`, which holds the answer — and is read from there by everything else, because a\n * second copy is a second copy that can disagree with the buffers on screen.\n */\nexport interface Resident {\n /** How many points are drawn. */\n readonly size: number;\n /** Where a vertex is drawn, or `undefined` when it is not resident. */\n indexOf(vertex: VertexId): number | undefined;\n /** Which vertex is drawn at a buffer index, or `undefined` past the end of the answer. */\n at(index: number): VertexId | undefined;\n /** Where these vertices are drawn, skipping every one that is not resident. */\n indicesOf(vertices: Iterable<VertexId>): number[];\n /** Which vertices are drawn at these buffer indices, skipping any the answer does not hold. */\n verticesAt(indices: Iterable<number>): VertexId[];\n}\n\nconst NOBODY = new BigUint64Array(0);\n\n/**\n * The map an answer implies. `null` — before the first answer — is nobody resident, not an error.\n *\n * Keyed by the identity itself. `Map` and `Set` compare keys by SameValueZero, which for a `bigint`\n * is *value* equality and not reference equality — two separately-constructed `vertexId(0, 5)`\n * reach the same entry. That is asserted rather than assumed in `resident.test.ts`, because the\n * whole file rests on it and BigInt being an object-shaped primitive makes it a fair thing to doubt.\n */\nexport function residentOf(slice: Slice | null): Resident {\n const all = slice?.vertices ?? NOBODY;\n /**\n * The marks, and not the anchors past them.\n *\n * An anchor is a real vertex in the buffers at its real coordinates, there so an edge leaving the\n * window has an end — and it is never drawn. Residency is *what is drawn*, so it stops here: a\n * hover, a selection or a frame that could land on an anchor would be pointing at a vertex with no\n * ink, off screen, that the window deliberately did not return.\n */\n const size = Math.min(slice?.marks ?? all.length, all.length);\n const vertices = all;\n const index = new Map<bigint, number>();\n for (let i = 0; i < size; i++) index.set(vertices[i] as bigint, i);\n\n const at = (i: number): VertexId | undefined =>\n i >= 0 && i < size ? (vertices[i] as VertexId) : undefined;\n const indexOf = (vertex: VertexId): number | undefined => index.get(vertex);\n\n return {\n size,\n indexOf,\n at,\n indicesOf(wanted) {\n const found: number[] = [];\n for (const vertex of wanted) {\n const i = indexOf(vertex);\n if (i !== undefined) found.push(i);\n }\n return found;\n },\n verticesAt(indices) {\n const found: VertexId[] = [];\n for (const i of indices) {\n const vertex = at(i);\n if (vertex !== undefined) found.push(vertex);\n }\n return found;\n },\n };\n}\n"],"names":["TYPE_SHIFT","DENSE_MASK","vertexId","type","dense","typeOf","vertex","denseOf","NOBODY","residentOf","slice","all","size","vertices","index","i","at","indexOf","wanted","found","indices"],"mappings":"AAqDA,MAAMA,IAAa,KACbC,IAAa;AAEZ,SAASC,EAASC,GAAcC,GAAyB;AAC9D,SAAS,OAAOD,CAAI,KAAKH,IAAc,OAAOI,CAAK;AACrD;AAGO,SAASC,EAAOC,GAA0B;AAC/C,SAAO,OAAOA,KAAUN,CAAU;AACpC;AAEO,SAASO,EAAQD,GAA0B;AAChD,SAAO,OAAOA,IAASL,CAAU;AACnC;AAuBA,MAAMO,IAAS,IAAI,eAAe,CAAC;AAU5B,SAASC,EAAWC,GAA+B;AACxD,QAAMC,KAAMD,KAAA,gBAAAA,EAAO,aAAYF,GASzBI,IAAO,KAAK,KAAIF,KAAA,gBAAAA,EAAO,UAASC,EAAI,QAAQA,EAAI,MAAM,GACtDE,IAAWF,GACXG,wBAAY,IAAA;AAClB,WAASC,IAAI,GAAGA,IAAIH,GAAMG,OAAW,IAAIF,EAASE,CAAC,GAAaA,CAAC;AAEjE,QAAMC,IAAK,CAACD,MACVA,KAAK,KAAKA,IAAIH,IAAQC,EAASE,CAAC,IAAiB,QAC7CE,IAAU,CAACX,MAAyCQ,EAAM,IAAIR,CAAM;AAE1E,SAAO;AAAA,IACL,MAAAM;AAAA,IACA,SAAAK;AAAA,IACA,IAAAD;AAAA,IACA,UAAUE,GAAQ;AAChB,YAAMC,IAAkB,CAAA;AACxB,iBAAWb,KAAUY,GAAQ;AAC3B,cAAMH,IAAIE,EAAQX,CAAM;AACxB,QAAIS,MAAM,UAAWI,EAAM,KAAKJ,CAAC;AAAA,MACnC;AACA,aAAOI;AAAA,IACT;AAAA,IACA,WAAWC,GAAS;AAClB,YAAMD,IAAoB,CAAA;AAC1B,iBAAWJ,KAAKK,GAAS;AACvB,cAAMd,IAASU,EAAGD,CAAC;AACnB,QAAIT,MAAW,UAAWa,EAAM,KAAKb,CAAM;AAAA,MAC7C;AACA,aAAOa;AAAA,IACT;AAAA,EAAA;AAEJ;"}
1
+ {"version":3,"file":"resident.js","sources":["../src/resident.ts"],"sourcesContent":["import type { Slice } from \"./bounded\";\n\n/**\n * Who is drawn, and where the GPU is drawing them.\n *\n * **A buffer index numbers the answer, not the corpus.** cosmos.gl addresses every point by its\n * position in the arrays it was last handed, so index 7 is whatever the current answer put seventh —\n * and a resident set that comes and goes reuses every index while the vertices behind them change.\n * Anything that outlives one answer — a selection, a label, a hover, a pin — therefore has to be\n * held as an identity and re-resolved against whatever is drawn now.\n *\n * **A vertex is the pair `(type_idx, dense_id)`.** Not `dense_id` alone: it numbers\n * within one vertex type, so a union of two types repeats every value and the same number names two\n * different vertices. A `LIMIT` breaks the correspondence between an id and a position regardless of\n * how many types there are.\n */\n\n/**\n * A vertex identity — the pair, packed into one 64-bit integer so it can key a `Map` and a `Set`.\n *\n * **A `bigint`, which is strictly stronger than the brand it also wears.** A buffer index is a\n * `number`; an identity is a `bigint`. Confusing the two therefore stops being a branding\n * convention that holds while everyone remembers it and becomes a *primitive* type error — and no\n * cast quietly launders one: `7 as VertexId` was legal against `number & brand` and is a compile\n * error against `bigint & brand`. Getting one wrong now costs `as unknown as`, which is loud.\n *\n * **The evidence this is worth the friction, because it is not a precaution we invented.** Across\n * every multi-language format surveyed — Arrow, Parquet, Iceberg, Delta, MVT, PMTiles, Zarr,\n * GraphAr, H3, S2 — the failure that recurs is a 64-bit value crossing into JavaScript.\n * `mapbox/node-s2` is a **binding**, not a port: it calls the same C++ the reference implementation\n * does, and it still returned wrong cell ids — issue #92, open since 2017, `1152921504606847000`\n * where Java and Go both give `1152921504606846977`. JavaScript's `number` is 53 bits and a cell id\n * is 64, and a binding cannot protect a language boundary that cannot hold the value. H3 settled it\n * by decree before anyone could get it wrong: `h3-js` types `H3Index` as a string. This is the same\n * decree with the type JavaScript grew for it.\n *\n * **And `>>` means three different things across our three layers.** In JavaScript it converts its\n * operand to *32 bits* and takes the shift count modulo 32, so the obvious `dense | (type << 32)`\n * is not a lost high word — it is `type | dense`, silently. The packing below cannot be written on\n * `number` at all and be right.\n *\n * What goes to the GPU does not change: positions and buffer indices stay `number` and\n * `Float32Array`. The conversion happens at the edge, which is where it belongs.\n */\nexport type VertexId = bigint & { readonly vertex: unique symbol };\n\n/**\n * One type's worth of dense ids.\n *\n * `dense_id` is a `UInt32`, so the type index occupies everything above bit 32 — exactly, for all\n * 2³² of them, which is the whole point of the width. The old packing was `type * 2**32 + dense` in\n * `float64` and ran out of exactness at type index 2²¹.\n */\nconst TYPE_SHIFT = 32n;\nconst DENSE_MASK = 0xffff_ffffn;\n\nexport function vertexId(type: number, dense: number): VertexId {\n return ((BigInt(type) << TYPE_SHIFT) | BigInt(dense)) as VertexId;\n}\n\n/** Both halves come back as `number`: each is 32 bits, and a `number` holds those exactly. */\nexport function typeOf(vertex: VertexId): number {\n return Number(vertex >> TYPE_SHIFT);\n}\n\nexport function denseOf(vertex: VertexId): number {\n return Number(vertex & DENSE_MASK);\n}\n\n/**\n * The identity↔index map for whatever is drawn right now.\n *\n * Built once per residency change and never mutated, because that is what a residency change *is*: a\n * different set of vertices, in a different order. It belongs to whoever owns residency — that is\n * `useQueryLoop`, which holds the answer — and is read from there by everything else, because a\n * second copy is a second copy that can disagree with the buffers on screen.\n */\nexport interface Resident {\n /** How many points are drawn. */\n readonly size: number;\n /** Where a vertex is drawn, or `undefined` when it is not resident. */\n indexOf(vertex: VertexId): number | undefined;\n /** Which vertex is drawn at a buffer index, or `undefined` past the end of the answer. */\n at(index: number): VertexId | undefined;\n /** Where these vertices are drawn, skipping every one that is not resident. */\n indicesOf(vertices: Iterable<VertexId>): number[];\n /** Which vertices are drawn at these buffer indices, skipping any the answer does not hold. */\n verticesAt(indices: Iterable<number>): VertexId[];\n}\n\nconst NOBODY = new BigUint64Array(0);\n\n/**\n * The map an answer implies. `null` — before the first answer — is nobody resident, not an error.\n *\n * Keyed by the identity itself. `Map` and `Set` compare keys by SameValueZero, which for a `bigint`\n * is *value* equality and not reference equality — two separately-constructed `vertexId(0, 5)`\n * reach the same entry. That is asserted rather than assumed in `resident.test.ts`, because the\n * whole file rests on it and BigInt being an object-shaped primitive makes it a fair thing to doubt.\n */\nexport function residentOf(slice: Slice | null): Resident {\n const all = slice?.vertices ?? NOBODY;\n /**\n * The marks, and not the anchors past them.\n *\n * An anchor is a real vertex in the buffers at its real coordinates, there so an edge leaving the\n * window has an end — and it is never drawn. Residency is *what is drawn*, so it stops here: a\n * hover, a selection or a frame that could land on an anchor would be pointing at a vertex with no\n * ink, off screen, that the window deliberately did not return.\n */\n const size = Math.min(slice?.marks ?? all.length, all.length);\n const vertices = all;\n const index = new Map<bigint, number>();\n for (let i = 0; i < size; i++) index.set(vertices[i] as bigint, i);\n\n const at = (i: number): VertexId | undefined =>\n i >= 0 && i < size ? (vertices[i] as VertexId) : undefined;\n const indexOf = (vertex: VertexId): number | undefined => index.get(vertex);\n\n return {\n size,\n indexOf,\n at,\n indicesOf(wanted) {\n const found: number[] = [];\n for (const vertex of wanted) {\n const i = indexOf(vertex);\n if (i !== undefined) found.push(i);\n }\n return found;\n },\n verticesAt(indices) {\n const found: VertexId[] = [];\n for (const i of indices) {\n const vertex = at(i);\n if (vertex !== undefined) found.push(vertex);\n }\n return found;\n },\n };\n}\n"],"names":["TYPE_SHIFT","DENSE_MASK","vertexId","type","dense","typeOf","vertex","denseOf","NOBODY","residentOf","slice","all","size","vertices","index","i","at","indexOf","wanted","found","indices"],"mappings":"AAqDA,MAAMA,IAAa,KACbC,IAAa;AAEZ,SAASC,EAASC,GAAcC,GAAyB;AAC9D,SAAS,OAAOD,CAAI,KAAKH,IAAc,OAAOI,CAAK;AACrD;AAGO,SAASC,EAAOC,GAA0B;AAC/C,SAAO,OAAOA,KAAUN,CAAU;AACpC;AAEO,SAASO,EAAQD,GAA0B;AAChD,SAAO,OAAOA,IAASL,CAAU;AACnC;AAuBA,MAAMO,IAAS,IAAI,eAAe,CAAC;AAU5B,SAASC,EAAWC,GAA+B;AACxD,QAAMC,KAAMD,KAAA,gBAAAA,EAAO,aAAYF,GASzBI,IAAO,KAAK,KAAIF,KAAA,gBAAAA,EAAO,UAASC,EAAI,QAAQA,EAAI,MAAM,GACtDE,IAAWF,GACXG,wBAAY,IAAA;AAClB,WAASC,IAAI,GAAGA,IAAIH,GAAMG,OAAW,IAAIF,EAASE,CAAC,GAAaA,CAAC;AAEjE,QAAMC,IAAK,CAACD,MACVA,KAAK,KAAKA,IAAIH,IAAQC,EAASE,CAAC,IAAiB,QAC7CE,IAAU,CAACX,MAAyCQ,EAAM,IAAIR,CAAM;AAE1E,SAAO;AAAA,IACL,MAAAM;AAAA,IACA,SAAAK;AAAA,IACA,IAAAD;AAAA,IACA,UAAUE,GAAQ;AAChB,YAAMC,IAAkB,CAAA;AACxB,iBAAWb,KAAUY,GAAQ;AAC3B,cAAMH,IAAIE,EAAQX,CAAM;AACxB,QAAIS,MAAM,UAAWI,EAAM,KAAKJ,CAAC;AAAA,MACnC;AACA,aAAOI;AAAA,IACT;AAAA,IACA,WAAWC,GAAS;AAClB,YAAMD,IAAoB,CAAA;AAC1B,iBAAWJ,KAAKK,GAAS;AACvB,cAAMd,IAASU,EAAGD,CAAC;AACnB,QAAIT,MAAW,UAAWa,EAAM,KAAKb,CAAM;AAAA,MAC7C;AACA,aAAOa;AAAA,IACT;AAAA,EAAA;AAEJ;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kanzo-tech/graph",
3
- "version": "0.9.0",
3
+ "version": "0.10.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,14 +26,14 @@
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`, 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.",
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 is absent too: it is `@kanzo-tech/mosaic`'s dependency, pinned to the release `engine()` boots.",
30
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. The floor is `0.3.0-alpha.10` because `openCorpus` takes the corpus the host opened and hands on the names fossil's verbs query it by — `CorpusRelation.sql`, qualified by the corpus's own catalog — and that release is the one that answers with them.",
31
31
  "peerDependencies": {
32
32
  "@cosmos.gl/graph": "^3.4.0",
33
33
  "@fossil-lang/corpus": "^0.3.0-alpha.10",
34
34
  "react": "^19.0.0",
35
- "@kanzo-tech/mosaic": "0.9.0",
36
- "@kanzo-tech/ui": "0.9.0"
35
+ "@kanzo-tech/mosaic": "0.10.0",
36
+ "@kanzo-tech/ui": "0.10.0"
37
37
  },
38
38
  "peerDependenciesMeta": {
39
39
  "@fossil-lang/corpus": {
@@ -56,7 +56,7 @@
56
56
  "react": "^19.0.0",
57
57
  "react-dom": "^19.0.0",
58
58
  "rollup-plugin-preserve-directives": "^0.4.0",
59
- "@kanzo-tech/mosaic": "0.9.0"
59
+ "@kanzo-tech/mosaic": "0.10.0"
60
60
  },
61
61
  "repository": {
62
62
  "type": "git",