@kanzo-tech/graph 0.2.0 → 0.4.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.
@@ -7,8 +7,8 @@ import { Resident, VertexId } from './resident';
7
7
  *
8
8
  * This is the half the engine rule requires of anything with an engine — the canvas stays presentational
9
9
  * and this owns the asking. It debounces, cancels what the camera has already superseded, and pushes
10
- * each answer's geometry into the renderer. A host that already holds its arrays wraps them in a
11
- * source and gets the same path; there is no second one.
10
+ * each answer's geometry into the renderer. There is one source and it is `openCorpus`; this loop is
11
+ * written against the contract rather than against it, which is what keeps a second one possible.
12
12
  *
13
13
  * **A graph that fits pays for nothing.** `total()` is asked first, and under the limit the source is
14
14
  * asked once for everything and never again — panning is then free exactly when it can be. Above it,
@@ -83,8 +83,6 @@ export interface QueryLoopState {
83
83
  sliced: boolean;
84
84
  /** Ask again. Wire it to the camera, and call it when the pinned set changes. */
85
85
  refresh: () => void;
86
- /** Ask a topological question instead of a spatial one, when the source supports one. */
87
- explore: (seeds: VertexId[], depth: number) => void;
88
86
  }
89
87
  export declare function useQueryLoop(options: QueryLoopOptions): QueryLoopState;
90
88
  //# sourceMappingURL=use-query-loop.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-query-loop.d.ts","sourceRoot":"","sources":["../src/use-query-loop.ts"],"names":[],"mappings":"AAEA,OAAO,EAAqD,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAC1F,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAKL,KAAK,aAAa,EAElB,KAAK,KAAK,EAEX,MAAM,WAAW,CAAC;AACnB,OAAO,EAAc,KAAK,QAAQ,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAGtE;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,aAAa,GAAG,IAAI,CAAC;IAC7B,2EAA2E;IAC3E,QAAQ,EAAE,SAAS,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC;IAClC,8EAA8E;IAC9E,OAAO,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IACvC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAC;IACpB;;;;;;;;;;OAUG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,CAAC,CAAC,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACrC;AAED,MAAM,WAAW,cAAc;IAC7B,kEAAkE;IAClE,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB;;;;;;;;OAQG;IACH,QAAQ,EAAE,QAAQ,CAAC;IACnB,yCAAyC;IACzC,OAAO,EAAE,OAAO,CAAC;IACjB,0DAA0D;IAC1D,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B;;;;;OAKG;IACH,MAAM,EAAE,OAAO,CAAC;IAChB,iFAAiF;IACjF,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,yFAAyF;IACzF,OAAO,EAAE,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACrD;AAwCD,wBAAgB,YAAY,CAAC,OAAO,EAAE,gBAAgB,GAAG,cAAc,CA0UtE"}
1
+ {"version":3,"file":"use-query-loop.d.ts","sourceRoot":"","sources":["../src/use-query-loop.ts"],"names":[],"mappings":"AAEA,OAAO,EAAqD,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAC1F,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAKL,KAAK,aAAa,EAClB,KAAK,KAAK,EAEX,MAAM,WAAW,CAAC;AACnB,OAAO,EAAc,KAAK,QAAQ,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAGtE;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,aAAa,GAAG,IAAI,CAAC;IAC7B,2EAA2E;IAC3E,QAAQ,EAAE,SAAS,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC;IAClC,8EAA8E;IAC9E,OAAO,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IACvC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAC;IACpB;;;;;;;;;;OAUG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,CAAC,CAAC,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACrC;AAED,MAAM,WAAW,cAAc;IAC7B,kEAAkE;IAClE,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB;;;;;;;;OAQG;IACH,QAAQ,EAAE,QAAQ,CAAC;IACnB,yCAAyC;IACzC,OAAO,EAAE,OAAO,CAAC;IACjB,0DAA0D;IAC1D,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B;;;;;OAKG;IACH,MAAM,EAAE,OAAO,CAAC;IAChB,iFAAiF;IACjF,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAwCD,wBAAgB,YAAY,CAAC,OAAO,EAAE,gBAAgB,GAAG,cAAc,CA4StE"}
@@ -1,169 +1,148 @@
1
1
  "use client";
2
- import { useState as p, useRef as m, useCallback as b, useEffect as T, useMemo as U } from "react";
3
- import { isAbort as z, DEFAULT_MIN_LINK_PIXELS as L, DEFAULT_LIMIT as H, shouldSlice as K } from "./bounded.js";
4
- import { residentOf as Q } from "./resident.js";
5
- import { whenReady as X } from "./when-ready.js";
6
- const j = {
2
+ import { useState as y, useRef as d, useCallback as v, useEffect as N, useMemo as G } from "react";
3
+ import { isAbort as U, DEFAULT_MIN_LINK_PIXELS as C, DEFAULT_LIMIT as z, shouldSlice as H } from "./bounded.js";
4
+ import { residentOf as K } from "./resident.js";
5
+ import { whenReady as Q } from "./when-ready.js";
6
+ const X = {
7
7
  xMin: Number.NEGATIVE_INFINITY,
8
8
  yMin: Number.NEGATIVE_INFINITY,
9
9
  xMax: Number.POSITIVE_INFINITY,
10
10
  yMax: Number.POSITIVE_INFINITY
11
11
  };
12
- function q(N, g) {
13
- const { height: l, width: s } = g.getBoundingClientRect(), [M, c] = N.screenToSpacePosition([0, 0]), [h, I] = N.screenToSpacePosition([s, l]), u = {
14
- xMin: Math.min(M, h),
15
- yMin: Math.min(c, I),
16
- xMax: Math.max(M, h),
17
- yMax: Math.max(c, I)
18
- }, e = s > 0 ? (u.xMax - u.xMin) / s : 0;
19
- return { view: u, perPixel: e };
12
+ function j(T, p) {
13
+ const { height: f, width: i } = p.getBoundingClientRect(), [M, o] = T.screenToSpacePosition([0, 0]), [m, I] = T.screenToSpacePosition([i, f]), a = {
14
+ xMin: Math.min(M, m),
15
+ yMin: Math.min(o, I),
16
+ xMax: Math.max(M, m),
17
+ yMax: Math.max(o, I)
18
+ }, e = i > 0 ? (a.xMax - a.xMin) / i : 0;
19
+ return { view: a, perPixel: e };
20
20
  }
21
- function tn(N) {
21
+ function tt(T) {
22
22
  const {
23
- debounce: g = J,
24
- fill: l,
25
- graphRef: s,
23
+ debounce: p = q,
24
+ fill: f,
25
+ graphRef: i,
26
26
  hostRef: M,
27
- limit: c = H,
28
- onError: h,
27
+ limit: o = z,
28
+ onError: m,
29
29
  pinned: I,
30
- r: u,
30
+ r: a,
31
31
  source: e
32
- } = N, [x, P] = p(null), [R, _] = p(!1), [Y, k] = p(void 0), [O, B] = p(!1), [F, V] = p(null), y = m(null), f = m(null), E = m(!1), w = m(I);
33
- w.current = I;
34
- const a = m(h);
35
- a.current = h;
36
- const d = b(
37
- async (t) => {
38
- var r, i;
32
+ } = T, [u, g] = y(null), [R, L] = y(!1), [Y, _] = y(void 0), [O, B] = y(!1), [F, V] = y(null), h = d(null), x = d(null), w = d(!1), E = d(I);
33
+ E.current = I;
34
+ const l = d(m);
35
+ l.current = m;
36
+ const b = v(
37
+ async (n) => {
38
+ var r, c;
39
39
  if (!e) return;
40
- (r = y.current) == null || r.abort();
41
- const n = new AbortController();
42
- y.current = n, _(!0);
40
+ (r = h.current) == null || r.abort();
41
+ const t = new AbortController();
42
+ h.current = t, L(!0);
43
43
  try {
44
- const o = await t(e, n.signal);
45
- if (n.signal.aborted) return;
46
- P(o);
47
- } catch (o) {
48
- if (n.signal.aborted || z(o)) return;
49
- (i = a.current) == null || i.call(a, String(o));
44
+ const s = await n(e, t.signal);
45
+ if (t.signal.aborted) return;
46
+ g(s);
47
+ } catch (s) {
48
+ if (t.signal.aborted || U(s)) return;
49
+ (c = l.current) == null || c.call(l, String(s));
50
50
  } finally {
51
- y.current === n && (y.current = null, _(!1));
51
+ h.current === t && (h.current = null, L(!1));
52
52
  }
53
53
  },
54
54
  [e]
55
- ), A = m(null), C = b(
56
- async (t) => {
57
- var o;
58
- if (!t.extent || A.current === t) return;
59
- A.current = t;
60
- let n;
55
+ ), k = d(null), A = v(
56
+ async (n) => {
57
+ var s;
58
+ if (!n.extent || k.current === n) return;
59
+ k.current = n;
60
+ let t;
61
61
  try {
62
- n = await t.extent();
63
- } catch (v) {
64
- (o = a.current) == null || o.call(a, String(v));
62
+ t = await n.extent();
63
+ } catch (S) {
64
+ (s = l.current) == null || s.call(l, String(S));
65
65
  return;
66
66
  }
67
- const r = s.current;
67
+ const r = i.current;
68
68
  if (!r) return;
69
- const i = await r.ready.then(() => r);
70
- i.setConfigPartial({ spaceSize: Math.max(n.xMax - n.xMin, n.yMax - n.yMin) }), i.fitViewByPointPositions([n.xMin, n.yMin, n.xMax, n.yMax], 0);
69
+ const c = await r.ready.then(() => r);
70
+ c.setConfigPartial({ spaceSize: Math.max(t.xMax - t.xMin, t.yMax - t.yMin) }), c.fitViewByPointPositions([t.xMin, t.yMin, t.xMax, t.yMax], 0);
71
71
  },
72
- [s]
73
- ), S = b(() => {
74
- E.current && (f.current && clearTimeout(f.current), f.current = setTimeout(() => {
75
- f.current = null;
76
- const t = s.current, n = M.current;
77
- if (!t || !n) return;
78
- const { perPixel: r, view: i } = q(t, n);
79
- d(
80
- (o, v) => o.slice({
81
- view: i,
72
+ [i]
73
+ ), P = v(() => {
74
+ w.current && (x.current && clearTimeout(x.current), x.current = setTimeout(() => {
75
+ x.current = null;
76
+ const n = i.current, t = M.current;
77
+ if (!n || !t) return;
78
+ const { perPixel: r, view: c } = j(n, t);
79
+ b(
80
+ (s, S) => s.slice({
81
+ view: c,
82
82
  perPixel: r,
83
- pinned: w.current,
84
- fill: l,
85
- r: u,
86
- limit: c,
87
- minLinkPixels: L,
88
- signal: v
83
+ pinned: E.current,
84
+ fill: f,
85
+ r: a,
86
+ limit: o,
87
+ minLinkPixels: C,
88
+ signal: S
89
89
  })
90
90
  );
91
- }, g));
92
- }, [d, g, l, s, M, c, u]), D = b(
93
- (t, n) => {
94
- var r;
95
- if (!e || !("explore" in e)) {
96
- (r = a.current) == null || r.call(a, "this source answers regions only");
97
- return;
98
- }
99
- f.current && clearTimeout(f.current), d(
100
- (i, o) => i.explore({
101
- seeds: t,
102
- depth: n,
103
- pinned: w.current,
104
- fill: l,
105
- r: u,
106
- limit: c,
107
- minLinkPixels: L,
108
- signal: o
109
- })
110
- );
111
- },
112
- [d, l, c, u, e]
113
- );
114
- T(() => {
91
+ }, p));
92
+ }, [b, p, f, i, M, o, a]);
93
+ N(() => {
115
94
  if (!e) {
116
- P(null), k(void 0), V(null);
95
+ g(null), _(void 0), V(null);
117
96
  return;
118
97
  }
119
- let t = !0;
98
+ let n = !0;
120
99
  return (async () => {
121
- var i;
122
- let n;
100
+ var c;
101
+ let t;
123
102
  try {
124
- n = await ((i = e.total) == null ? void 0 : i.call(e));
103
+ t = await ((c = e.total) == null ? void 0 : c.call(e));
125
104
  } catch {
126
105
  }
127
- if (!t) return;
128
- k(n);
129
- const r = K(n, c);
130
- E.current = r, B(r), V(e);
106
+ if (!n) return;
107
+ _(t);
108
+ const r = H(t, o);
109
+ w.current = r, B(r), V(e);
131
110
  })(), () => {
132
- t = !1;
111
+ n = !1;
133
112
  };
134
- }, [c, e]), T(() => {
135
- !e || F !== e || (async () => (await C(e), E.current ? S() : await d(
136
- (t, n) => t.slice({
137
- view: j,
138
- pinned: w.current,
139
- fill: l,
140
- r: u,
141
- limit: c,
142
- minLinkPixels: L,
143
- signal: n
113
+ }, [o, e]), N(() => {
114
+ !e || F !== e || (async () => (await A(e), w.current ? P() : await b(
115
+ (n, t) => n.slice({
116
+ view: X,
117
+ pinned: E.current,
118
+ fill: f,
119
+ r: a,
120
+ limit: o,
121
+ minLinkPixels: C,
122
+ signal: t
144
123
  })
145
124
  )))();
146
- }, [d, F, l, C, c, u, S, e]), T(() => {
147
- var t;
148
- return (t = e == null ? void 0 : e.watch) == null ? void 0 : t.call(e, P);
149
- }, [e]), T(
125
+ }, [b, F, f, A, o, a, P, e]), N(() => {
126
+ var n;
127
+ return (n = e == null ? void 0 : e.watch) == null ? void 0 : n.call(e, g);
128
+ }, [e]), N(
150
129
  () => () => {
151
- var t;
152
- f.current && clearTimeout(f.current), (t = y.current) == null || t.abort();
130
+ var n;
131
+ x.current && clearTimeout(x.current), (n = h.current) == null || n.abort();
153
132
  },
154
133
  []
155
- ), T(() => {
156
- const t = s.current;
157
- if (!(!t || !x))
158
- return X(t, (n) => {
159
- n.setPointPositions(x.positions), n.setLinks(x.links), n.render();
134
+ ), N(() => {
135
+ const n = i.current;
136
+ if (!(!n || !u))
137
+ return Q(n, (t) => {
138
+ t.setPointPositions(u.positions), t.setLinks(u.links), t.render();
160
139
  });
161
- }, [s, x]);
162
- const G = U(() => Q(x), [x]);
163
- return { slice: x, resident: G, pending: R, total: Y, sliced: O, refresh: S, explore: D };
140
+ }, [i, u]);
141
+ const D = G(() => K(u), [u]);
142
+ return { slice: u, resident: D, pending: R, total: Y, sliced: O, refresh: P };
164
143
  }
165
- const J = 120;
144
+ const q = 120;
166
145
  export {
167
- tn as useQueryLoop
146
+ tt as useQueryLoop
168
147
  };
169
148
  //# sourceMappingURL=use-query-loop.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-query-loop.js","sources":["../src/use-query-loop.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from \"react\";\nimport type { Graph } from \"@cosmos.gl/graph\";\nimport {\n DEFAULT_LIMIT,\n DEFAULT_MIN_LINK_PIXELS,\n isAbort,\n shouldSlice,\n type BoundedSource,\n type ExploringSource,\n type Slice,\n type Viewport,\n} from \"./bounded\";\nimport { residentOf, type Resident, type VertexId } from \"./resident\";\nimport { whenReady } from \"./when-ready\";\n\n/**\n * The query loop: the camera moves, a bounded question is asked, the answer becomes the picture.\n *\n * This is the half the engine rule requires of anything with an engine — the canvas stays presentational\n * and this owns the asking. It debounces, cancels what the camera has already superseded, and pushes\n * each answer's geometry into the renderer. A host that already holds its arrays wraps them in a\n * source and gets the same path; there is no second one.\n *\n * **A graph that fits pays for nothing.** `total()` is asked first, and under the limit the source is\n * asked once for everything and never again — panning is then free exactly when it can be. Above it,\n * every camera move costs a query, which at 200,000 nodes is the trade that buys the ceiling away.\n *\n * **It is also where the question is finished.** A host's `limit` is optional and `minLinkPixels` is\n * not a host's business at all, and both are **required** on the `SliceRequest` a source receives —\n * resolved here, once, from `DEFAULT_LIMIT` and `DEFAULT_MIN_LINK_PIXELS`. They used to reach a\n * source as an exported `BOUNDED_DEFAULTS` table it looked them up in, which made a request\n * something a source had to *complete* rather than answer, in its own words, in two repositories.\n */\n\nexport interface QueryLoopOptions {\n source: BoundedSource | null;\n /** The live renderer, for reading the camera and receiving each answer. */\n graphRef: RefObject<Graph | null>;\n /** The element the canvas is drawn into — its box is the screen rectangle. */\n hostRef: RefObject<HTMLElement | null>;\n /**\n * Vertices that must come back whatever the camera is looking at.\n *\n * Dragged, pinned, selected. Their drawn positions are a view-local overlay on coordinates that\n * never move, so the index cannot find them where they now appear.\n */\n pinned?: VertexId[];\n /**\n * Which column colours a point and which the size ramp is spent on — Plot's channel names.\n *\n * They arrive here rather than being baked into the source because a channel is part of the\n * **question**: colouring by another column is a new answer over the same bytes, and a source that\n * held them meant building a second source to change a colour. So this loop asks again when one\n * changes, which is the whole behaviour the move buys.\n *\n * Both optional, and the source decides what an omitted one means — it is the only thing that\n * knows what its corpus carries.\n */\n fill?: string;\n r?: string;\n limit?: number;\n /**\n * How long the camera has to be still before asking, in milliseconds.\n *\n * A pan is a stream of `onZoom` callbacks and every one of them would otherwise be a query. Long\n * enough that a gesture costs one question rather than sixty; short enough that letting go feels\n * like it answered immediately.\n */\n debounce?: number;\n onError?: (message: string) => void;\n}\n\nexport interface QueryLoopState {\n /** The answer currently drawn, or `null` before the first one. */\n slice: Slice | null;\n /**\n * Who is drawn, and where — rebuilt with every answer, which is what makes it correct.\n *\n * It lives here rather than in each hook because **this is where residency changes**. The map is\n * a function of the current answer and nothing else, so a consumer that built its own would be\n * building the same thing from the same input, one render later, with no way to notice it had\n * fallen behind the buffers on screen. Selection, overlays, pins and the greyout all read this\n * one.\n */\n resident: Resident;\n /** Whether a question is outstanding. */\n pending: boolean;\n /** How many vertices there are, when the source knows. */\n total: number | undefined;\n /**\n * Whether this graph is being asked in pieces at all.\n *\n * `false` means it fit under the limit and was taken whole — the camera is not observed, and\n * nothing is asked again.\n */\n sliced: boolean;\n /** Ask again. Wire it to the camera, and call it when the pinned set changes. */\n refresh: () => void;\n /** Ask a topological question instead of a spatial one, when the source supports one. */\n explore: (seeds: VertexId[], depth: number) => void;\n}\n\nconst EVERYTHING: Viewport = {\n xMin: Number.NEGATIVE_INFINITY,\n yMin: Number.NEGATIVE_INFINITY,\n xMax: Number.POSITIVE_INFINITY,\n yMax: Number.POSITIVE_INFINITY,\n};\n\n/**\n * What the camera is over, asked of the renderer rather than recomputed.\n *\n * cosmos.gl owns the screen↔space transform, so deriving the rectangle from `camera.x/y/k` and the\n * space size — which an earlier `viewportOf` did — is a second implementation of it, free to drift.\n * Screen y grows downward and space y does not, so the corners are sorted rather than assumed.\n */\nfunction cameraViewport(graph: Graph, host: HTMLElement): { view: Viewport; perPixel: number } {\n const { height, width } = host.getBoundingClientRect();\n const [ax, ay] = graph.screenToSpacePosition([0, 0]);\n const [bx, by] = graph.screenToSpacePosition([width, height]);\n const view = {\n xMin: Math.min(ax, bx),\n yMin: Math.min(ay, by),\n xMax: Math.max(ax, bx),\n yMax: Math.max(ay, by),\n };\n /**\n * How much space one pixel covers — asked of the same transform, not derived from the zoom.\n *\n * The rectangle came from mapping the host's own corners, so its width **is** `width` pixels of\n * space and the ratio is exact. Reading `camera.k` instead would be a second implementation of the\n * renderer's screen↔space maths, which is the mistake `viewportOf` already made once.\n *\n * Zero width — an unmounted or hidden host — gives no resolution rather than a division by zero,\n * and a source asked with none discards nothing.\n */\n const perPixel = width > 0 ? (view.xMax - view.xMin) / width : 0;\n return { view, perPixel };\n}\n\nexport function useQueryLoop(options: QueryLoopOptions): QueryLoopState {\n const {\n debounce = DEBOUNCE_MS,\n fill,\n graphRef,\n hostRef,\n limit = DEFAULT_LIMIT,\n onError,\n pinned,\n r,\n source,\n } = options;\n\n const [slice, setSlice] = useState<Slice | null>(null);\n const [pending, setPending] = useState(false);\n const [total, setTotal] = useState<number | undefined>(undefined);\n const [sliced, setSliced] = useState(false);\n /** The source `total()` has already answered for — the gate the asking effect waits on. */\n const [counted, setCounted] = useState<BoundedSource | null>(null);\n\n /**\n * The request in flight, and the timer waiting to become one.\n *\n * Refs rather than state: aborting a superseded request is bookkeeping the render output never\n * shows, and putting it in state would re-render the canvas once per camera frame to no effect.\n */\n const inFlight = useRef<AbortController | null>(null);\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n /** Whether the camera is being observed at all, where `refresh` can read it without a rebuild. */\n const slicing = useRef(false);\n /** The live pinned set, so the query loop is not rebuilt every time a node is dragged. */\n const held = useRef(pinned);\n held.current = pinned;\n const report = useRef(onError);\n report.current = onError;\n\n const ask = useCallback(\n async (run: (from: BoundedSource, signal: AbortSignal) => Promise<Slice>) => {\n if (!source) return;\n inFlight.current?.abort();\n const controller = new AbortController();\n inFlight.current = controller;\n setPending(true);\n try {\n const answer = await run(source, controller.signal);\n if (controller.signal.aborted) return;\n setSlice(answer);\n } catch (error) {\n /**\n * An abort is the loop working, not a failure: the camera moved on before the answer landed.\n *\n * Two halves, and they are two because only one of them is this loop's own doing. The first\n * is the controller above — this loop aborted the request itself, so the signal says so\n * without anything having been thrown. The second is the **source's** half: a source that\n * holds one standing question settles the one a newer caller replaced, and that rejection\n * arrives here for a request whose signal was never aborted at all.\n *\n * `isAbort` reads `error.name`, which is what makes a source written over `fetch` — or over\n * `AbortSignal.timeout`, or a stream — correct here without importing anything from us. It\n * was `isSuperseded(error)` against an exported `Symbol`, and that symbol was the reason a\n * source doing the standard thing was reported to the host as a failure.\n */\n if (controller.signal.aborted || isAbort(error)) return;\n report.current?.(String(error));\n } finally {\n if (inFlight.current === controller) {\n inFlight.current = null;\n setPending(false);\n }\n }\n },\n [source],\n );\n\n /**\n * Put the camera over the corpus, once, before the first question is asked.\n *\n * **The order is the point, and it is why this is not `fitView()` after the first slice.** A\n * sliced graph's first question is *what is the camera over*, so framing afterwards means the\n * opening query was asked about wherever the renderer happened to start — which for a corpus\n * occupying a corner of the space is a first paint of nothing, followed by a second query once the\n * fit moves the camera. Framing first costs one query instead of two and shows the corpus instead\n * of the default box.\n *\n * A source that cannot say its extent is left alone rather than guessed at: the camera stays where\n * the renderer put it, which is today's behaviour and is correct for arrays with no layout.\n *\n * Once, tracked on a ref, because this is the *opening* view: a reader who has panned somewhere\n * and then changes a channel is not asking to be sent home.\n */\n const framed = useRef<BoundedSource | null>(null);\n const frame = useCallback(\n async (from: BoundedSource) => {\n if (!from.extent || framed.current === from) return;\n framed.current = from;\n let box;\n try {\n box = await from.extent();\n } catch (error) {\n // **Framing may not stop the drawing.** This is a camera placement, and a source that cannot\n // answer where it is still has a slice to give — but the first version let the rejection\n // escape the chain the ask was waiting on, so a failed extent left the canvas saying \"asking\n // for what is in view\" forever, with nothing in the console. A defect that silences the whole\n // canvas has to be louder than the thing it was helping.\n report.current?.(String(error));\n return;\n }\n const graph = graphRef.current;\n if (!graph) return;\n // The quiet half of the race `whenReady` describes: a fit that arrives before the device is\n // not applied and does not complain, so the camera stays on the default box while the corpus\n // sits outside it. Awaited rather than wrapped because this function is already async and\n // already the one place that waits.\n const ready = await graph.ready.then(() => graph);\n /**\n * **The renderer's coordinate box, from the corpus rather than from a constant of ours.**\n *\n * This is the one place that already waits for an `extent()`, so it is the only place that can\n * set the box without asking twice. `spaceSize` is not one of the three fields cosmos.gl\n * treats as init-only — `initialZoomLevel`, `randomSeed`, `attribution` — so it takes effect\n * here; the config change calls `adjustSpaceSize` and re-syncs the screen scales.\n *\n * **Before the fit, not after.** Changing the box shifts the space→screen origin by half the\n * difference — measured 2.4 px on the million at its fitted zoom — so a set that follows the\n * fit slides the picture the reader was just given. The fit absorbs it in this order.\n *\n * A box past the device's `maxTextureDimension2D` is halved by cosmos.gl with a console line\n * of its own; that is left visible rather than clamped here, and `/docs/design/graph` says\n * why.\n *\n * The larger side, because the box is a square. There used to be an exported `SPACE = 4096`\n * declaring it, hand-copied into both bench generators, and a corpus fossil wrote ignored it:\n * a million vertices span about x ∈ [−345, 645396]. That was survivable only because\n * `spaceSize` enters every render path as a pure translation — it changed nothing anyone could\n * see, which is exactly why nothing caught it.\n */\n ready.setConfigPartial({ spaceSize: Math.max(box.xMax - box.xMin, box.yMax - box.yMin) });\n // Two corners are enough: cosmos.gl fits the bounding box of whatever positions it is handed,\n // and a rectangle is its own bounding box. Duration zero — an opening view that flies in from\n // the default box is animation for its own sake, and the reader has not asked for anything yet.\n ready.fitViewByPointPositions([box.xMin, box.yMin, box.xMax, box.yMax], 0);\n },\n [graphRef],\n );\n\n /**\n * Ask about wherever the camera is now, after `debounce` of stillness.\n *\n * Wired to the renderer's `onZoom`, which fires per frame of a gesture. The timer collapses a pan\n * into one question; `ask` aborts anything the pan has already made stale.\n */\n const refresh = useCallback(() => {\n // A graph that fits was answered whole, and re-asking would replace that answer with whatever\n // rectangle the camera happens to be over — which is how a corpus of 582 nodes ends up empty\n // because the reader zoomed. The promise that panning is free exactly when it can be is kept\n // here rather than by asking every call site to remember not to wire this up.\n if (!slicing.current) return;\n if (timer.current) clearTimeout(timer.current);\n timer.current = setTimeout(() => {\n timer.current = null;\n const graph = graphRef.current;\n const host = hostRef.current;\n if (!graph || !host) return;\n const { perPixel, view } = cameraViewport(graph, host);\n void ask((s, signal) =>\n s.slice({\n view,\n perPixel,\n pinned: held.current,\n fill,\n r,\n limit,\n minLinkPixels: DEFAULT_MIN_LINK_PIXELS,\n signal,\n }),\n );\n }, debounce);\n // The channels are dependencies rather than a ref, unlike `pinned`: a new one is a new question\n // and the effect below re-runs this the moment its identity changes. A pin is a gesture the host\n // reports, and it says when to ask again itself.\n }, [ask, debounce, fill, graphRef, hostRef, limit, r]);\n\n /**\n * Ask a topological question, when the source is one that can answer.\n *\n * `\"explore\" in source` is the narrowing, and it is the whole check — a source that cannot walk\n * edges does not carry the method, so this is the one place a caller pays for the distinction\n * instead of every source restating it in a predicate and a throw.\n */\n const explore = useCallback(\n (seeds: VertexId[], depth: number) => {\n if (!source || !(\"explore\" in source)) {\n report.current?.(\"this source answers regions only\");\n return;\n }\n if (timer.current) clearTimeout(timer.current);\n void ask((s, signal) =>\n (s as ExploringSource).explore({\n seeds,\n depth,\n pinned: held.current,\n fill,\n r,\n limit,\n minLinkPixels: DEFAULT_MIN_LINK_PIXELS,\n signal,\n }),\n );\n },\n [ask, fill, limit, r, source],\n );\n\n /**\n * How big it is — asked once per source, and the answer decides whether there is a query loop at\n * all: under the limit one slice covers everything and the camera is never consulted again. A\n * source that cannot say cheaply is treated as large, because an unknown corpus is more likely to\n * be the kind that needs bounding than not.\n *\n * The *asking* is the effect below rather than the tail of this one, and the split is what lets a\n * channel change re-ask: how big a corpus is belongs to the source and does not change with what\n * you want drawn, so counting again on every colour would be paying a `count(*)` for a question\n * nobody asked.\n */\n useEffect(() => {\n if (!source) {\n setSlice(null);\n setTotal(undefined);\n setCounted(null);\n return;\n }\n let live = true;\n void (async () => {\n let count: number | undefined;\n try {\n count = await source.total?.();\n } catch {\n // A source that will not count is a source that gets sliced.\n }\n if (!live) return;\n setTotal(count);\n const bounded = shouldSlice(count, limit);\n slicing.current = bounded;\n setSliced(bounded);\n setCounted(source);\n })();\n return () => {\n live = false;\n };\n }, [limit, source]);\n\n /**\n * What to draw — the opening question, and every later one that is not the camera's.\n *\n * It runs when the count lands, and again whenever the question itself changes: a channel is part\n * of the question, so `refresh` and this both carry them and both re-run. The counted source is\n * compared rather than a boolean, so an answer that arrives for a source already replaced cannot\n * open a slice against the new one.\n */\n useEffect(() => {\n if (!source || counted !== source) return;\n void (async () => {\n await frame(source);\n if (slicing.current) refresh();\n else\n await ask((s, signal) =>\n s.slice({\n view: EVERYTHING,\n pinned: held.current,\n fill,\n r,\n limit,\n minLinkPixels: DEFAULT_MIN_LINK_PIXELS,\n signal,\n }),\n );\n })();\n }, [ask, counted, fill, frame, limit, r, refresh, source]);\n\n /**\n * The other way an answer arrives: the page filtered something.\n *\n * A camera move is a *pull* — the loop asks, because only the loop knows where the camera is. A\n * filter change is a **push**: the coordinator re-runs the source's reads with the new predicate\n * the way it re-runs a histogram's, and what lands here is the finished slice. Re-asking instead\n * would issue those queries a second time to learn what is already in hand.\n *\n * The disposer is also the release, which is why this is wired even when the source is between\n * renders: `watch` is where a source lets go of a client registration, and a loop that calls it is\n * a loop that cannot leak one.\n */\n useEffect(() => source?.watch?.(setSlice), [source]);\n\n // A dropped canvas must not leave a timer holding a stale camera, or a request nobody will read.\n useEffect(\n () => () => {\n if (timer.current) clearTimeout(timer.current);\n inFlight.current?.abort();\n },\n [],\n );\n\n /**\n * The answer becomes the picture.\n *\n * Separate from the effect that builds the renderer, which is the whole point: a slice arrives on\n * every camera move, and rebuilding the instance for each one would destroy and recreate a WebGL\n * context sixty times a pan. Geometry is pushed; the instance outlives every slice it draws.\n */\n useEffect(() => {\n const graph = graphRef.current;\n if (!graph || !slice) return;\n /**\n * **Behind `ready`, because a non-null instance is not a usable one.**\n *\n * `whenReady` carries the whole argument; the cancel is what matters here. Slices arrive\n * faster than a frame during a pan, and every one of them but the last is superseded before it\n * could have been drawn.\n */\n return whenReady(graph, (ready) => {\n ready.setPointPositions(slice.positions);\n ready.setLinks(slice.links);\n ready.render();\n });\n }, [graphRef, slice]);\n\n // Memoised on the answer, because a fresh map per render would make every consumer that depends on\n // it re-run for a value that had not changed.\n const resident = useMemo(() => residentOf(slice), [slice]);\n\n return { slice, resident, pending, total, sliced, refresh, explore };\n}\n\n/**\n * The stillness a camera has to hold before it is asked about — 120 ms.\n *\n * Under a gesture's own frame budget it would be one query per frame; far above it and letting go of\n * a pan feels like the canvas is thinking. The measured slice at 200,000 nodes is 30 ms, so this is\n * the larger half of the latency a reader actually feels, and it is the half we chose.\n */\nconst DEBOUNCE_MS = 120;\n"],"names":[],"mappings":";;;;;AAwGA;AAA6B;AACd;AACA;AACA;AAEf;AASA;AACE;AAGa;AACU;AACA;AACA;AACA;AAavB;AACF;AAEO;AACL;AAAM;AACO;AACX;AACA;AACA;AACQ;AACR;AACA;AACA;AACA;AAsBF;AACA;AACA;AAEA;AAAY;;AAER;AACA;AACA;AACA;AAEA;AACE;AACA;AACA;AAAe;AAgBf;AACA;AAA6B;AAE7B;AAEkB;AAEpB;AACF;AACO;AAoBK;;AAEV;AACA;AACA;AACA;AACE;AAAiB;AAOjB;AACA;AAAA;AAEF;AACA;AAKA;AAuBA;AAIyE;AAC3E;AACS;AAcT;AAGE;AACA;AAEA;AACA;AACA;AAAK;AACK;AACN;AACA;AACa;AACb;AACA;AACA;AACe;AACf;AACD;AAAA;AAEM;AAaG;;AAEZ;AACE;AACA;AAAA;AAEF;AACK;AAC4B;AAC7B;AACA;AACa;AACb;AACA;AACA;AACe;AACf;AACD;AAAA;AAEL;AAC4B;AAc9B;AACE;AACE;AAGA;AAAA;AAEF;AACA;;AACE;AACA;AACE;AAAc;AACR;AAGR;AACA;AACA;AACA;AAEiB;AAGjB;AAAO;AACT;AAYA;AAKU;AACI;AACA;AACO;AACb;AACA;AACA;AACe;AACf;AACD;AAAA;;AAiBO;AAAgB;AAGhC;;AAEI;AACkB;AACpB;AACA;AAWA;AACA;AAQA;AACE;AAEM;AACP;AAKH;AAEA;AACF;AASA;;;;"}
1
+ {"version":3,"file":"use-query-loop.js","sources":["../src/use-query-loop.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from \"react\";\nimport type { Graph } from \"@cosmos.gl/graph\";\nimport {\n DEFAULT_LIMIT,\n DEFAULT_MIN_LINK_PIXELS,\n isAbort,\n shouldSlice,\n type BoundedSource,\n type Slice,\n type Viewport,\n} from \"./bounded\";\nimport { residentOf, type Resident, type VertexId } from \"./resident\";\nimport { whenReady } from \"./when-ready\";\n\n/**\n * The query loop: the camera moves, a bounded question is asked, the answer becomes the picture.\n *\n * This is the half the engine rule requires of anything with an engine — the canvas stays presentational\n * and this owns the asking. It debounces, cancels what the camera has already superseded, and pushes\n * each answer's geometry into the renderer. There is one source and it is `openCorpus`; this loop is\n * written against the contract rather than against it, which is what keeps a second one possible.\n *\n * **A graph that fits pays for nothing.** `total()` is asked first, and under the limit the source is\n * asked once for everything and never again — panning is then free exactly when it can be. Above it,\n * every camera move costs a query, which at 200,000 nodes is the trade that buys the ceiling away.\n *\n * **It is also where the question is finished.** A host's `limit` is optional and `minLinkPixels` is\n * not a host's business at all, and both are **required** on the `SliceRequest` a source receives —\n * resolved here, once, from `DEFAULT_LIMIT` and `DEFAULT_MIN_LINK_PIXELS`. They used to reach a\n * source as an exported `BOUNDED_DEFAULTS` table it looked them up in, which made a request\n * something a source had to *complete* rather than answer, in its own words, in two repositories.\n */\n\nexport interface QueryLoopOptions {\n source: BoundedSource | null;\n /** The live renderer, for reading the camera and receiving each answer. */\n graphRef: RefObject<Graph | null>;\n /** The element the canvas is drawn into — its box is the screen rectangle. */\n hostRef: RefObject<HTMLElement | null>;\n /**\n * Vertices that must come back whatever the camera is looking at.\n *\n * Dragged, pinned, selected. Their drawn positions are a view-local overlay on coordinates that\n * never move, so the index cannot find them where they now appear.\n */\n pinned?: VertexId[];\n /**\n * Which column colours a point and which the size ramp is spent on — Plot's channel names.\n *\n * They arrive here rather than being baked into the source because a channel is part of the\n * **question**: colouring by another column is a new answer over the same bytes, and a source that\n * held them meant building a second source to change a colour. So this loop asks again when one\n * changes, which is the whole behaviour the move buys.\n *\n * Both optional, and the source decides what an omitted one means — it is the only thing that\n * knows what its corpus carries.\n */\n fill?: string;\n r?: string;\n limit?: number;\n /**\n * How long the camera has to be still before asking, in milliseconds.\n *\n * A pan is a stream of `onZoom` callbacks and every one of them would otherwise be a query. Long\n * enough that a gesture costs one question rather than sixty; short enough that letting go feels\n * like it answered immediately.\n */\n debounce?: number;\n onError?: (message: string) => void;\n}\n\nexport interface QueryLoopState {\n /** The answer currently drawn, or `null` before the first one. */\n slice: Slice | null;\n /**\n * Who is drawn, and where — rebuilt with every answer, which is what makes it correct.\n *\n * It lives here rather than in each hook because **this is where residency changes**. The map is\n * a function of the current answer and nothing else, so a consumer that built its own would be\n * building the same thing from the same input, one render later, with no way to notice it had\n * fallen behind the buffers on screen. Selection, overlays, pins and the greyout all read this\n * one.\n */\n resident: Resident;\n /** Whether a question is outstanding. */\n pending: boolean;\n /** How many vertices there are, when the source knows. */\n total: number | undefined;\n /**\n * Whether this graph is being asked in pieces at all.\n *\n * `false` means it fit under the limit and was taken whole — the camera is not observed, and\n * nothing is asked again.\n */\n sliced: boolean;\n /** Ask again. Wire it to the camera, and call it when the pinned set changes. */\n refresh: () => void;\n}\n\nconst EVERYTHING: Viewport = {\n xMin: Number.NEGATIVE_INFINITY,\n yMin: Number.NEGATIVE_INFINITY,\n xMax: Number.POSITIVE_INFINITY,\n yMax: Number.POSITIVE_INFINITY,\n};\n\n/**\n * What the camera is over, asked of the renderer rather than recomputed.\n *\n * cosmos.gl owns the screen↔space transform, so deriving the rectangle from `camera.x/y/k` and the\n * space size — which an earlier `viewportOf` did — is a second implementation of it, free to drift.\n * Screen y grows downward and space y does not, so the corners are sorted rather than assumed.\n */\nfunction cameraViewport(graph: Graph, host: HTMLElement): { view: Viewport; perPixel: number } {\n const { height, width } = host.getBoundingClientRect();\n const [ax, ay] = graph.screenToSpacePosition([0, 0]);\n const [bx, by] = graph.screenToSpacePosition([width, height]);\n const view = {\n xMin: Math.min(ax, bx),\n yMin: Math.min(ay, by),\n xMax: Math.max(ax, bx),\n yMax: Math.max(ay, by),\n };\n /**\n * How much space one pixel covers — asked of the same transform, not derived from the zoom.\n *\n * The rectangle came from mapping the host's own corners, so its width **is** `width` pixels of\n * space and the ratio is exact. Reading `camera.k` instead would be a second implementation of the\n * renderer's screen↔space maths, which is the mistake `viewportOf` already made once.\n *\n * Zero width — an unmounted or hidden host — gives no resolution rather than a division by zero,\n * and a source asked with none discards nothing.\n */\n const perPixel = width > 0 ? (view.xMax - view.xMin) / width : 0;\n return { view, perPixel };\n}\n\nexport function useQueryLoop(options: QueryLoopOptions): QueryLoopState {\n const {\n debounce = DEBOUNCE_MS,\n fill,\n graphRef,\n hostRef,\n limit = DEFAULT_LIMIT,\n onError,\n pinned,\n r,\n source,\n } = options;\n\n const [slice, setSlice] = useState<Slice | null>(null);\n const [pending, setPending] = useState(false);\n const [total, setTotal] = useState<number | undefined>(undefined);\n const [sliced, setSliced] = useState(false);\n /** The source `total()` has already answered for — the gate the asking effect waits on. */\n const [counted, setCounted] = useState<BoundedSource | null>(null);\n\n /**\n * The request in flight, and the timer waiting to become one.\n *\n * Refs rather than state: aborting a superseded request is bookkeeping the render output never\n * shows, and putting it in state would re-render the canvas once per camera frame to no effect.\n */\n const inFlight = useRef<AbortController | null>(null);\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n /** Whether the camera is being observed at all, where `refresh` can read it without a rebuild. */\n const slicing = useRef(false);\n /** The live pinned set, so the query loop is not rebuilt every time a node is dragged. */\n const held = useRef(pinned);\n held.current = pinned;\n const report = useRef(onError);\n report.current = onError;\n\n const ask = useCallback(\n async (run: (from: BoundedSource, signal: AbortSignal) => Promise<Slice>) => {\n if (!source) return;\n inFlight.current?.abort();\n const controller = new AbortController();\n inFlight.current = controller;\n setPending(true);\n try {\n const answer = await run(source, controller.signal);\n if (controller.signal.aborted) return;\n setSlice(answer);\n } catch (error) {\n /**\n * An abort is the loop working, not a failure: the camera moved on before the answer landed.\n *\n * Two halves, and they are two because only one of them is this loop's own doing. The first\n * is the controller above — this loop aborted the request itself, so the signal says so\n * without anything having been thrown. The second is the **source's** half: a source that\n * holds one standing question settles the one a newer caller replaced, and that rejection\n * arrives here for a request whose signal was never aborted at all.\n *\n * `isAbort` reads `error.name`, which is what makes a source written over `fetch` — or over\n * `AbortSignal.timeout`, or a stream — correct here without importing anything from us. It\n * was `isSuperseded(error)` against an exported `Symbol`, and that symbol was the reason a\n * source doing the standard thing was reported to the host as a failure.\n */\n if (controller.signal.aborted || isAbort(error)) return;\n report.current?.(String(error));\n } finally {\n if (inFlight.current === controller) {\n inFlight.current = null;\n setPending(false);\n }\n }\n },\n [source],\n );\n\n /**\n * Put the camera over the corpus, once, before the first question is asked.\n *\n * **The order is the point, and it is why this is not `fitView()` after the first slice.** A\n * sliced graph's first question is *what is the camera over*, so framing afterwards means the\n * opening query was asked about wherever the renderer happened to start — which for a corpus\n * occupying a corner of the space is a first paint of nothing, followed by a second query once the\n * fit moves the camera. Framing first costs one query instead of two and shows the corpus instead\n * of the default box.\n *\n * A source that cannot say its extent is left alone rather than guessed at: the camera stays where\n * the renderer put it, which is today's behaviour and is correct for arrays with no layout.\n *\n * Once, tracked on a ref, because this is the *opening* view: a reader who has panned somewhere\n * and then changes a channel is not asking to be sent home.\n */\n const framed = useRef<BoundedSource | null>(null);\n const frame = useCallback(\n async (from: BoundedSource) => {\n if (!from.extent || framed.current === from) return;\n framed.current = from;\n let box;\n try {\n box = await from.extent();\n } catch (error) {\n // **Framing may not stop the drawing.** This is a camera placement, and a source that cannot\n // answer where it is still has a slice to give — but the first version let the rejection\n // escape the chain the ask was waiting on, so a failed extent left the canvas saying \"asking\n // for what is in view\" forever, with nothing in the console. A defect that silences the whole\n // canvas has to be louder than the thing it was helping.\n report.current?.(String(error));\n return;\n }\n const graph = graphRef.current;\n if (!graph) return;\n // The quiet half of the race `whenReady` describes: a fit that arrives before the device is\n // not applied and does not complain, so the camera stays on the default box while the corpus\n // sits outside it. Awaited rather than wrapped because this function is already async and\n // already the one place that waits.\n const ready = await graph.ready.then(() => graph);\n /**\n * **The renderer's coordinate box, from the corpus rather than from a constant of ours.**\n *\n * This is the one place that already waits for an `extent()`, so it is the only place that can\n * set the box without asking twice. `spaceSize` is not one of the three fields cosmos.gl\n * treats as init-only — `initialZoomLevel`, `randomSeed`, `attribution` — so it takes effect\n * here; the config change calls `adjustSpaceSize` and re-syncs the screen scales.\n *\n * **Before the fit, not after.** Changing the box shifts the space→screen origin by half the\n * difference — measured 2.4 px on the million at its fitted zoom — so a set that follows the\n * fit slides the picture the reader was just given. The fit absorbs it in this order.\n *\n * A box past the device's `maxTextureDimension2D` is halved by cosmos.gl with a console line\n * of its own; that is left visible rather than clamped here, and `/docs/design/graph` says\n * why.\n *\n * The larger side, because the box is a square. There used to be an exported `SPACE = 4096`\n * declaring it, hand-copied into both bench generators, and a corpus fossil wrote ignored it:\n * a million vertices span about x ∈ [−345, 645396]. That was survivable only because\n * `spaceSize` enters every render path as a pure translation — it changed nothing anyone could\n * see, which is exactly why nothing caught it.\n */\n ready.setConfigPartial({ spaceSize: Math.max(box.xMax - box.xMin, box.yMax - box.yMin) });\n // Two corners are enough: cosmos.gl fits the bounding box of whatever positions it is handed,\n // and a rectangle is its own bounding box. Duration zero — an opening view that flies in from\n // the default box is animation for its own sake, and the reader has not asked for anything yet.\n ready.fitViewByPointPositions([box.xMin, box.yMin, box.xMax, box.yMax], 0);\n },\n [graphRef],\n );\n\n /**\n * Ask about wherever the camera is now, after `debounce` of stillness.\n *\n * Wired to the renderer's `onZoom`, which fires per frame of a gesture. The timer collapses a pan\n * into one question; `ask` aborts anything the pan has already made stale.\n */\n const refresh = useCallback(() => {\n // A graph that fits was answered whole, and re-asking would replace that answer with whatever\n // rectangle the camera happens to be over — which is how a corpus of 582 nodes ends up empty\n // because the reader zoomed. The promise that panning is free exactly when it can be is kept\n // here rather than by asking every call site to remember not to wire this up.\n if (!slicing.current) return;\n if (timer.current) clearTimeout(timer.current);\n timer.current = setTimeout(() => {\n timer.current = null;\n const graph = graphRef.current;\n const host = hostRef.current;\n if (!graph || !host) return;\n const { perPixel, view } = cameraViewport(graph, host);\n void ask((s, signal) =>\n s.slice({\n view,\n perPixel,\n pinned: held.current,\n fill,\n r,\n limit,\n minLinkPixels: DEFAULT_MIN_LINK_PIXELS,\n signal,\n }),\n );\n }, debounce);\n // The channels are dependencies rather than a ref, unlike `pinned`: a new one is a new question\n // and the effect below re-runs this the moment its identity changes. A pin is a gesture the host\n // reports, and it says when to ask again itself.\n }, [ask, debounce, fill, graphRef, hostRef, limit, r]);\n\n /**\n * How big it is — asked once per source, and the answer decides whether there is a query loop at\n * all: under the limit one slice covers everything and the camera is never consulted again. A\n * source that cannot say cheaply is treated as large, because an unknown corpus is more likely to\n * be the kind that needs bounding than not.\n *\n * The *asking* is the effect below rather than the tail of this one, and the split is what lets a\n * channel change re-ask: how big a corpus is belongs to the source and does not change with what\n * you want drawn, so counting again on every colour would be paying a `count(*)` for a question\n * nobody asked.\n */\n useEffect(() => {\n if (!source) {\n setSlice(null);\n setTotal(undefined);\n setCounted(null);\n return;\n }\n let live = true;\n void (async () => {\n let count: number | undefined;\n try {\n count = await source.total?.();\n } catch {\n // A source that will not count is a source that gets sliced.\n }\n if (!live) return;\n setTotal(count);\n const bounded = shouldSlice(count, limit);\n slicing.current = bounded;\n setSliced(bounded);\n setCounted(source);\n })();\n return () => {\n live = false;\n };\n }, [limit, source]);\n\n /**\n * What to draw — the opening question, and every later one that is not the camera's.\n *\n * It runs when the count lands, and again whenever the question itself changes: a channel is part\n * of the question, so `refresh` and this both carry them and both re-run. The counted source is\n * compared rather than a boolean, so an answer that arrives for a source already replaced cannot\n * open a slice against the new one.\n */\n useEffect(() => {\n if (!source || counted !== source) return;\n void (async () => {\n await frame(source);\n if (slicing.current) refresh();\n else\n await ask((s, signal) =>\n s.slice({\n view: EVERYTHING,\n pinned: held.current,\n fill,\n r,\n limit,\n minLinkPixels: DEFAULT_MIN_LINK_PIXELS,\n signal,\n }),\n );\n })();\n }, [ask, counted, fill, frame, limit, r, refresh, source]);\n\n /**\n * The other way an answer arrives: the page filtered something.\n *\n * A camera move is a *pull* — the loop asks, because only the loop knows where the camera is. A\n * filter change is a **push**: the coordinator re-runs the source's reads with the new predicate\n * the way it re-runs a histogram's, and what lands here is the finished slice. Re-asking instead\n * would issue those queries a second time to learn what is already in hand.\n *\n * The disposer is also the release, which is why this is wired even when the source is between\n * renders: `watch` is where a source lets go of a client registration, and a loop that calls it is\n * a loop that cannot leak one.\n */\n useEffect(() => source?.watch?.(setSlice), [source]);\n\n // A dropped canvas must not leave a timer holding a stale camera, or a request nobody will read.\n useEffect(\n () => () => {\n if (timer.current) clearTimeout(timer.current);\n inFlight.current?.abort();\n },\n [],\n );\n\n /**\n * The answer becomes the picture.\n *\n * Separate from the effect that builds the renderer, which is the whole point: a slice arrives on\n * every camera move, and rebuilding the instance for each one would destroy and recreate a WebGL\n * context sixty times a pan. Geometry is pushed; the instance outlives every slice it draws.\n */\n useEffect(() => {\n const graph = graphRef.current;\n if (!graph || !slice) return;\n /**\n * **Behind `ready`, because a non-null instance is not a usable one.**\n *\n * `whenReady` carries the whole argument; the cancel is what matters here. Slices arrive\n * faster than a frame during a pan, and every one of them but the last is superseded before it\n * could have been drawn.\n */\n return whenReady(graph, (ready) => {\n ready.setPointPositions(slice.positions);\n ready.setLinks(slice.links);\n ready.render();\n });\n }, [graphRef, slice]);\n\n // Memoised on the answer, because a fresh map per render would make every consumer that depends on\n // it re-run for a value that had not changed.\n const resident = useMemo(() => residentOf(slice), [slice]);\n\n return { slice, resident, pending, total, sliced, refresh };\n}\n\n/**\n * The stillness a camera has to hold before it is asked about — 120 ms.\n *\n * Under a gesture's own frame budget it would be one query per frame; far above it and letting go of\n * a pan feels like the canvas is thinking. The measured slice at 200,000 nodes is 30 ms, so this is\n * the larger half of the latency a reader actually feels, and it is the half we chose.\n */\nconst DEBOUNCE_MS = 120;\n"],"names":[],"mappings":";;;;;AAqGA;AAA6B;AACd;AACA;AACA;AAEf;AASA;AACE;AAGa;AACU;AACA;AACA;AACA;AAavB;AACF;AAEO;AACL;AAAM;AACO;AACX;AACA;AACA;AACQ;AACR;AACA;AACA;AACA;AAsBF;AACA;AACA;AAEA;AAAY;;AAER;AACA;AACA;AACA;AAEA;AACE;AACA;AACA;AAAe;AAgBf;AACA;AAA6B;AAE7B;AAEkB;AAEpB;AACF;AACO;AAoBK;;AAEV;AACA;AACA;AACA;AACE;AAAiB;AAOjB;AACA;AAAA;AAEF;AACA;AAKA;AAuBA;AAIyE;AAC3E;AACS;AAcT;AAGE;AACA;AAEA;AACA;AACA;AAAK;AACK;AACN;AACA;AACa;AACb;AACA;AACA;AACe;AACf;AACD;AAAA;AAEM;AAiBb;AACE;AACE;AAGA;AAAA;AAEF;AACA;;AACE;AACA;AACE;AAAc;AACR;AAGR;AACA;AACA;AACA;AAEiB;AAGjB;AAAO;AACT;AAYA;AAKU;AACI;AACA;AACO;AACb;AACA;AACA;AACe;AACf;AACD;AAAA;;AAiBO;AAAgB;AAGhC;;AAEI;AACkB;AACpB;AACA;AAWA;AACA;AAQA;AACE;AAEM;AACP;AAKH;AAEA;AACF;AASA;;;;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kanzo-tech/graph",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Kanzo graph rendering — a GPU force layout over cosmos.gl, the DuckDB relation that feeds it, and the Mosaic client that keeps it inside a crossfilter. Sibling of @kanzo-tech/ui, not part of it: the admission rules exclude graphs from the generic vocabulary by name.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,20 +26,26 @@
26
26
  },
27
27
  "./package.json": "./package.json"
28
28
  },
29
- "//peers": "cosmos.gl is a real, required peer — this package is a renderer and there is nothing left of it without one. The Mosaic half is optional and lives entirely on `./duckdb`: `duckBoundedSource` and `openCorpus` need it, the rendering hooks do not, and a consumer drawing a graph from arrays it already has should not pay for 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 stays absent for the reason it always was — nothing in any of our dist names it, and a host that boots its own DuckDB depends on it directly. `scripts/smoke-install.mjs` installs no optional peer and imports the root barrel, which is the check that reads this list rather than trusting it.",
30
+ "//fossil": "The reader is on npm, so the range is a range. This entry said LOCAL LINK, NOT A RELEASE STATE while `@fossil-lang/corpus` existed only in a checkout beside this one; it was published as `0.3.0-alpha.5` and the `link:` and the `*` went with the reason for them. The peer itself is not temporary: fossil's reader sits beside `@kanzo-tech/mosaic` on `./duckdb` and is wanted for the same reason — a host reading a corpus brings its own, and a host that only renders pays for neither. The residual cost this entry used to name is GONE rather than mitigated: `duckBoundedSource` shared the subpath and did not need this peer, so a host on it either installed the reader or the entry had to be split. There is nothing on `./duckdb` now that does not want fossil, so the subpath and the peer are the same decision and there is no entry to split.",
30
31
  "peerDependencies": {
31
32
  "@cosmos.gl/graph": "^3.4.0",
33
+ "@fossil-lang/corpus": "^0.3.0-alpha.5",
32
34
  "react": "^19.0.0",
33
- "@kanzo-tech/mosaic": "0.2.0",
34
- "@kanzo-tech/ui": "0.2.0"
35
+ "@kanzo-tech/mosaic": "0.4.0",
36
+ "@kanzo-tech/ui": "0.4.0"
35
37
  },
36
38
  "peerDependenciesMeta": {
39
+ "@fossil-lang/corpus": {
40
+ "optional": true
41
+ },
37
42
  "@kanzo-tech/mosaic": {
38
43
  "optional": true
39
44
  }
40
45
  },
41
46
  "devDependencies": {
42
47
  "@cosmos.gl/graph": "^3.4.0",
48
+ "@fossil-lang/corpus": "^0.3.0-alpha.5",
43
49
  "@testing-library/dom": "^10.4.1",
44
50
  "@testing-library/react": "^16.3.2",
45
51
  "@types/react": "^19.0.0",
@@ -50,7 +56,7 @@
50
56
  "react": "^19.0.0",
51
57
  "react-dom": "^19.0.0",
52
58
  "rollup-plugin-preserve-directives": "^0.4.0",
53
- "@kanzo-tech/mosaic": "0.2.0"
59
+ "@kanzo-tech/mosaic": "0.4.0"
54
60
  },
55
61
  "repository": {
56
62
  "type": "git",
@@ -1,32 +0,0 @@
1
- import { ExploringSource } from './bounded';
2
- /**
3
- * The trivial source: a host that already holds its arrays.
4
- *
5
- * ADR-0001 deletes `load()`, which means every consumer needs a source — including the ones for
6
- * which bounding buys nothing, because their graph fits. This is that source, and it exists so that
7
- * "wrap your arrays" is one import rather than a hundred lines each call site writes differently.
8
- *
9
- * It is also the only source we ship that is an [`ExploringSource`]. A rectangle is a map question
10
- * and any relation with a spatial predicate can answer it; a neighbourhood is the graph question,
11
- * and answering it needs adjacency. Having the links in hand, this one does.
12
- *
13
- * Everything derived is built on first use and kept: a graph small enough to hold is small enough
14
- * that an adjacency list is cheap, but a host that only ever pans should not pay to build one.
15
- */
16
- export interface MemoryGraph {
17
- /** Who each point is, parallel to the position pairs — `vertexId(type, dense)` per point. */
18
- vertices: BigUint64Array;
19
- /** `[x0, y0, x1, y1, …]`. */
20
- positions: Float32Array;
21
- /** `[src, dst, …]` as indices into `positions`. */
22
- links: Float32Array;
23
- /**
24
- * The subject IRI of each vertex, parallel to `vertices` — optional, and the same opt-in the SQL
25
- * source makes for the same reason: a host that never names a vertex should not carry the names.
26
- */
27
- subjects?: string[];
28
- categories?: Uint16Array;
29
- sizes?: Float32Array;
30
- }
31
- export declare function memorySource(graph: MemoryGraph): ExploringSource;
32
- //# sourceMappingURL=memory-source.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"memory-source.d.ts","sourceRoot":"","sources":["../src/memory-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,eAAe,EAIrB,MAAM,WAAW,CAAC;AAGnB;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,WAAW;IAC1B,6FAA6F;IAC7F,QAAQ,EAAE,cAAc,CAAC;IACzB,6BAA6B;IAC7B,SAAS,EAAE,YAAY,CAAC;IACxB,mDAAmD;IACnD,KAAK,EAAE,YAAY,CAAC;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,UAAU,CAAC,EAAE,WAAW,CAAC;IACzB,KAAK,CAAC,EAAE,YAAY,CAAC;CACtB;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,WAAW,GAAG,eAAe,CAmGhE"}
@@ -1,141 +0,0 @@
1
- function U(t) {
2
- const l = t.positions.length / 2;
3
- let u = null, x = null;
4
- const I = (o) => {
5
- if (!u) {
6
- u = /* @__PURE__ */ new Map();
7
- for (let s = 0; s < l; s++) u.set(t.vertices[s], s);
8
- }
9
- return u.get(o);
10
- }, m = (o) => {
11
- var s, c;
12
- if (!x) {
13
- x = Array.from({ length: l }, () => []);
14
- for (let i = 0; i < t.links.length; i += 2) {
15
- const f = t.links[i], e = t.links[i + 1];
16
- (s = x[f]) == null || s.push(e), (c = x[e]) == null || c.push(f);
17
- }
18
- }
19
- return x[o] ?? [];
20
- };
21
- return {
22
- total: () => Promise.resolve(l),
23
- // One pass over the array it is already holding, so the canvas frames the arrays rather than the
24
- // renderer's default box.
25
- extent: () => Promise.resolve(z(t.positions)),
26
- slice(o) {
27
- const { limit: s, minLinkPixels: c, perPixel: i, pinned: f, view: e } = o;
28
- return Promise.resolve(
29
- V(t, y(t, e, f), s, i, c)
30
- );
31
- },
32
- // The only source we ship that has this at all: a rectangle needs a spatial predicate, which
33
- // every source has, and a neighbourhood needs adjacency, which only a host holding its own links
34
- // does. So the bounded canvas is an explorer here while a SQL source leaves it a map.
35
- explore(o) {
36
- return Promise.resolve(
37
- V(
38
- t,
39
- w(o.seeds, o.depth),
40
- o.limit,
41
- o.perPixel,
42
- o.minLinkPixels
43
- )
44
- );
45
- }
46
- };
47
- function w(o, s) {
48
- const c = /* @__PURE__ */ new Set();
49
- let i = [];
50
- for (const f of o) {
51
- const e = I(f);
52
- e !== void 0 && !c.has(e) && (c.add(e), i.push(e));
53
- }
54
- for (let f = 0; f < s && i.length > 0; f++) {
55
- const e = [];
56
- for (const d of i)
57
- for (const a of m(d))
58
- c.has(a) || (c.add(a), e.push(a));
59
- i = e;
60
- }
61
- return [...c];
62
- }
63
- function y(o, s, c) {
64
- const i = [];
65
- for (let e = 0; e < l; e++) {
66
- const d = o.positions[e * 2], a = o.positions[e * 2 + 1];
67
- d >= s.xMin && d <= s.xMax && a >= s.yMin && a <= s.yMax && i.push(e);
68
- }
69
- if (!(c != null && c.length)) return i;
70
- const f = new Set(i);
71
- for (const e of c) {
72
- const d = I(e);
73
- d !== void 0 && f.add(d);
74
- }
75
- return [...f];
76
- }
77
- }
78
- function V(t, l, u, x, I) {
79
- var T, P, E;
80
- const m = l.length, w = Math.max(1, Math.ceil(m / u)), y = [];
81
- for (let n = 0; n < l.length && y.length < u; n += w)
82
- y.push(l[n]);
83
- const o = y.length, s = new Int32Array(t.positions.length / 2).fill(-1), c = new BigUint64Array(o), i = new Float32Array(o * 2), f = new Uint16Array(o), e = t.sizes ? new Float32Array(o) : void 0, d = t.subjects ? new Array(o) : void 0;
84
- for (let n = 0; n < o; n++) {
85
- const r = y[n];
86
- s[r] = n, c[n] = t.vertices[r], d && (d[n] = ((T = t.subjects) == null ? void 0 : T[r]) ?? ""), i[n * 2] = t.positions[r * 2], i[n * 2 + 1] = t.positions[r * 2 + 1], f[n] = ((P = t.categories) == null ? void 0 : P[r]) ?? 0, e && (e[n] = ((E = t.sizes) == null ? void 0 : E[r]) ?? 0);
87
- }
88
- const a = x !== void 0 && x > 0 ? I * x : 0, N = [], k = (n) => {
89
- const r = s[n];
90
- if (r >= 0) return r;
91
- const M = o + N.length;
92
- return s[n] = M, N.push(n), M;
93
- }, h = [];
94
- for (let n = 0; n < t.links.length; n += 2) {
95
- const r = t.links[n], M = t.links[n + 1];
96
- if (!(s[r] < 0 && s[M] < 0)) {
97
- if (a > 0) {
98
- const O = t.positions[r * 2] - t.positions[M * 2], S = t.positions[r * 2 + 1] - t.positions[M * 2 + 1];
99
- if (O * O + S * S < a * a) continue;
100
- }
101
- h.push(k(r), k(M));
102
- }
103
- }
104
- const A = o + N.length, v = new Float32Array(A * 2);
105
- v.set(i);
106
- const b = new BigUint64Array(A);
107
- b.set(c);
108
- const F = new Uint16Array(A);
109
- F.set(f);
110
- for (let n = 0; n < N.length; n++) {
111
- const r = N[n];
112
- v[(o + n) * 2] = t.positions[r * 2], v[(o + n) * 2 + 1] = t.positions[r * 2 + 1], b[o + n] = t.vertices[r], d && (d[o + n] = "");
113
- }
114
- return {
115
- n: m,
116
- marks: o,
117
- vertices: b,
118
- subjects: d,
119
- positions: v,
120
- links: Float32Array.from(h),
121
- categories: F,
122
- sizes: e ? j(e, A) : void 0
123
- };
124
- }
125
- function j(t, l) {
126
- if (t.length === l) return t;
127
- const u = new Float32Array(l);
128
- return u.set(t), u;
129
- }
130
- function z(t) {
131
- let l = Number.POSITIVE_INFINITY, u = Number.POSITIVE_INFINITY, x = Number.NEGATIVE_INFINITY, I = Number.NEGATIVE_INFINITY;
132
- for (let m = 0; m < t.length; m += 2) {
133
- const w = t[m], y = t[m + 1];
134
- w < l && (l = w), w > x && (x = w), y < u && (u = y), y > I && (I = y);
135
- }
136
- return Number.isFinite(l) ? { xMin: l, yMin: u, xMax: x, yMax: I } : { xMin: 0, yMin: 0, xMax: 0, yMax: 0 };
137
- }
138
- export {
139
- U as memorySource
140
- };
141
- //# sourceMappingURL=memory-source.js.map