@kanzo-tech/graph 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -7
- package/dist/bounded.d.ts +119 -69
- package/dist/bounded.d.ts.map +1 -1
- package/dist/bounded.js +13 -34
- package/dist/bounded.js.map +1 -1
- package/dist/duck-source.d.ts +60 -72
- package/dist/duck-source.d.ts.map +1 -1
- package/dist/duck-source.js +185 -255
- package/dist/duck-source.js.map +1 -1
- package/dist/graph-canvas.d.ts +1 -1
- package/dist/graph-canvas.js.map +1 -1
- package/dist/graph-looks.d.ts +63 -18
- package/dist/graph-looks.d.ts.map +1 -1
- package/dist/graph-looks.js +37 -26
- package/dist/graph-looks.js.map +1 -1
- package/dist/graph-model.d.ts +2 -2
- package/dist/graph-model.d.ts.map +1 -1
- package/dist/graph-model.js +19 -14
- package/dist/graph-model.js.map +1 -1
- package/dist/graph-sim.d.ts +15 -1
- package/dist/graph-sim.d.ts.map +1 -1
- package/dist/graph-sim.js +17 -13
- package/dist/graph-sim.js.map +1 -1
- package/dist/index.d.ts +14 -14
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -48
- package/dist/index.js.map +1 -1
- package/dist/obligations.d.ts +1 -3
- package/dist/obligations.d.ts.map +1 -1
- package/dist/shape-glyph.d.ts +30 -0
- package/dist/shape-glyph.d.ts.map +1 -0
- package/dist/shape-glyph.js +15 -0
- package/dist/shape-glyph.js.map +1 -0
- package/dist/slice-client.d.ts.map +1 -1
- package/dist/slice-client.js +36 -36
- package/dist/slice-client.js.map +1 -1
- package/dist/use-graph-overlays.d.ts +21 -30
- package/dist/use-graph-overlays.d.ts.map +1 -1
- package/dist/use-graph-overlays.js +39 -37
- package/dist/use-graph-overlays.js.map +1 -1
- package/dist/use-graph.d.ts +38 -12
- package/dist/use-graph.d.ts.map +1 -1
- package/dist/use-graph.js +62 -61
- package/dist/use-graph.js.map +1 -1
- package/dist/use-query-loop.d.ts +8 -4
- package/dist/use-query-loop.d.ts.map +1 -1
- package/dist/use-query-loop.js +93 -96
- package/dist/use-query-loop.js.map +1 -1
- package/dist/use-renderer.d.ts.map +1 -1
- package/dist/use-renderer.js +69 -66
- package/dist/use-renderer.js.map +1 -1
- package/package.json +11 -5
- package/dist/memory-source.d.ts +0 -32
- package/dist/memory-source.d.ts.map +0 -1
- package/dist/memory-source.js +0 -134
- package/dist/memory-source.js.map +0 -1
package/dist/slice-client.js
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
var p = (
|
|
3
|
-
throw TypeError(
|
|
2
|
+
var p = (t) => {
|
|
3
|
+
throw TypeError(t);
|
|
4
4
|
};
|
|
5
|
-
var m = (
|
|
6
|
-
var l = (
|
|
7
|
-
import { MosaicClient as
|
|
8
|
-
import {
|
|
9
|
-
var
|
|
10
|
-
class
|
|
5
|
+
var m = (t, r, e) => r.has(t) || p("Cannot " + e);
|
|
6
|
+
var l = (t, r, e) => (m(t, r, "read from private field"), e ? e.call(t) : r.get(t)), o = (t, r, e) => r.has(t) ? p("Cannot add the same private member more than once") : r instanceof WeakSet ? r.add(t) : r.set(t, e), i = (t, r, e, s) => (m(t, r, "write to private field"), s ? s.call(t, e) : r.set(t, e), e);
|
|
7
|
+
import { MosaicClient as y } from "@kanzo-tech/mosaic";
|
|
8
|
+
import { abortError as w } from "./bounded.js";
|
|
9
|
+
var u, c, n, a, h;
|
|
10
|
+
class x extends y {
|
|
11
11
|
/**
|
|
12
12
|
* @param filterBy The crossfilter this read lives inside, if any. The unfiltered reads — how big
|
|
13
13
|
* the corpus is, where it is, what its tile footers say — take none: those are facts about the
|
|
14
14
|
* corpus rather than about what the page is looking at, and filtering them would make
|
|
15
15
|
* "20,000 of 1,000,000" a fraction of itself.
|
|
16
16
|
*/
|
|
17
|
-
constructor(
|
|
17
|
+
constructor(e, s) {
|
|
18
18
|
super(s);
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
o(this, u);
|
|
20
|
+
o(this, c, null);
|
|
21
|
+
o(this, n, null);
|
|
22
|
+
o(this, a, null);
|
|
23
|
+
o(this, h, !1);
|
|
24
|
+
i(this, u, e);
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
27
|
* Pre-aggregation cannot help here, and saying so costs a getter instead of a query.
|
|
@@ -44,8 +44,8 @@ class R extends w {
|
|
|
44
44
|
* the client exemption by hand.
|
|
45
45
|
*/
|
|
46
46
|
get predicate() {
|
|
47
|
-
var
|
|
48
|
-
return ((
|
|
47
|
+
var e;
|
|
48
|
+
return ((e = this.filterBy) == null ? void 0 : e.predicate(this)) ?? [];
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
51
|
* Ask, and *keep* the question. The coordinator issues, consolidates, caches and re-runs it.
|
|
@@ -54,13 +54,13 @@ class R extends w {
|
|
|
54
54
|
* filters something the camera is still over the same rectangle, so the coordinator re-running this
|
|
55
55
|
* with a new predicate is precisely the query anybody would have written by hand.
|
|
56
56
|
*/
|
|
57
|
-
ask(
|
|
58
|
-
|
|
59
|
-
const s = new Promise((
|
|
60
|
-
var
|
|
61
|
-
(
|
|
57
|
+
ask(e) {
|
|
58
|
+
i(this, c, e);
|
|
59
|
+
const s = new Promise((d, q) => {
|
|
60
|
+
var f;
|
|
61
|
+
(f = l(this, n)) == null || f.reject(w("superseded: this read was re-aimed at a newer question")), i(this, n, { resolve: d, reject: q });
|
|
62
62
|
});
|
|
63
|
-
return l(this, h) ? this.requestQuery() : (
|
|
63
|
+
return l(this, h) ? this.requestQuery() : (i(this, h, !0), l(this, u).connect(this)), s;
|
|
64
64
|
}
|
|
65
65
|
/**
|
|
66
66
|
* Where an answer goes when nobody asked for it — which is the page's filters moving.
|
|
@@ -69,30 +69,30 @@ class R extends w {
|
|
|
69
69
|
* cannot disagree: an answer settles the promise when one is waiting, and is reported here when
|
|
70
70
|
* none is.
|
|
71
71
|
*/
|
|
72
|
-
set onAnswer(
|
|
73
|
-
|
|
72
|
+
set onAnswer(e) {
|
|
73
|
+
i(this, a, e);
|
|
74
74
|
}
|
|
75
75
|
/** Let go: no more queries, and the coordinator stops walking us on every selection change. */
|
|
76
76
|
release() {
|
|
77
|
-
var
|
|
78
|
-
(
|
|
77
|
+
var e;
|
|
78
|
+
(e = l(this, n)) == null || e.reject(w("released: this read let go of the coordinator")), i(this, n, null), i(this, a, null), i(this, c, null), l(this, h) && (i(this, h, !1), l(this, u).disconnect(this));
|
|
79
79
|
}
|
|
80
|
-
query(
|
|
80
|
+
query(e = []) {
|
|
81
81
|
var s;
|
|
82
|
-
return ((s = l(this, c)) == null ? void 0 : s.call(this,
|
|
82
|
+
return ((s = l(this, c)) == null ? void 0 : s.call(this, e)) ?? null;
|
|
83
83
|
}
|
|
84
|
-
queryResult(
|
|
85
|
-
var
|
|
84
|
+
queryResult(e) {
|
|
85
|
+
var d;
|
|
86
86
|
const s = l(this, n);
|
|
87
|
-
return
|
|
87
|
+
return i(this, n, null), s ? s.resolve(e) : (d = l(this, a)) == null || d.call(this, e), this;
|
|
88
88
|
}
|
|
89
|
-
queryError(
|
|
89
|
+
queryError(e) {
|
|
90
90
|
const s = l(this, n);
|
|
91
|
-
return
|
|
91
|
+
return i(this, n, null), s && s.reject(e), this;
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
-
|
|
94
|
+
u = new WeakMap(), c = new WeakMap(), n = new WeakMap(), a = new WeakMap(), h = new WeakMap();
|
|
95
95
|
export {
|
|
96
|
-
|
|
96
|
+
x as SliceRead
|
|
97
97
|
};
|
|
98
98
|
//# sourceMappingURL=slice-client.js.map
|
package/dist/slice-client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slice-client.js","sources":["../src/slice-client.ts"],"sourcesContent":["\"use client\";\n\nimport { MosaicClient } from \"@kanzo-tech/mosaic\";\nimport type { Coordinator, FilterExpr, Selection } from \"@kanzo-tech/mosaic\";\nimport {
|
|
1
|
+
{"version":3,"file":"slice-client.js","sources":["../src/slice-client.ts"],"sourcesContent":["\"use client\";\n\nimport { MosaicClient } from \"@kanzo-tech/mosaic\";\nimport type { Coordinator, FilterExpr, Selection } from \"@kanzo-tech/mosaic\";\nimport { abortError } from \"./bounded\";\n\n/**\n * One read of a corpus, as a client of the page's coordinator.\n *\n * **A source used to query outside the protocol, and that was the whole defect.** `onceQuery`\n * connected a throwaway `MosaicClient` per query, took the answer and disconnected — a second query\n * path beside the one every chart on the page uses, and one that no selection could reach. So a\n * graph inside a crossfilter had to be joined to it from outside: something else asked which ids\n * survived, and the canvas painted grey over the ones that had not. Two round trips, a full scan and\n * a texture upload, to express a `WHERE` clause.\n *\n * A read is the same protocol a histogram uses, in the two directions it runs:\n *\n * `query(filter)` — the coordinator hands us the page's predicate and we build the SQL around it,\n * so what comes back is *what survives* rather than everything with a mask beside it.\n * `queryResult(data)` — the answer, whether we asked for it or the page's filters moved.\n *\n * **The second direction is the one that pays.** A selection change re-runs this read with the new\n * predicate and no camera involvement at all: the coordinator already walks its clients on every\n * selection update, so a graph that is one of them is re-queried the way a plot is. Nothing\n * subscribes to anything, and there is no window where the picture and the page's filters disagree.\n *\n * **Not `makeClient`.** That helper takes a `query` and a `queryResult` as options and is right for a\n * client with one standing question. A slice is two reads that have to arrive together, over a\n * question the camera rewrites — so what is needed is a handle the source can re-aim, which is a\n * class with a method rather than a closure fixed at construction.\n */\nexport class SliceRead extends MosaicClient {\n #coordinator: Coordinator;\n #build: ((filter: FilterExpr) => string) | null = null;\n #settle: { resolve: (data: unknown) => void; reject: (error: unknown) => void } | null = null;\n #onAnswer: ((data: unknown) => void) | null = null;\n #connected = false;\n\n /**\n * @param filterBy The crossfilter this read lives inside, if any. The unfiltered reads — how big\n * the corpus is, where it is, what its tile footers say — take none: those are facts about the\n * corpus rather than about what the page is looking at, and filtering them would make\n * \"20,000 of 1,000,000\" a fraction of itself.\n */\n constructor(coordinator: Coordinator, filterBy?: Selection) {\n super(filterBy);\n this.#coordinator = coordinator;\n }\n\n /**\n * Pre-aggregation cannot help here, and saying so costs a getter instead of a query.\n *\n * `preaggColumns` reaches the same \"no\" by calling `query()` and finding a string where it wanted a\n * `SelectQuery` — a slice is a CTE over window functions and is not expressible in the builder —\n * but it calls `query()` to find out, on every selection change. The claim is true rather than\n * defensive: the filter decides which rows are numbered, so it moves the groupby domain outright,\n * which is exactly what this flag is asked about.\n */\n override get filterStable(): boolean {\n return false;\n }\n\n /**\n * What the rest of the page is filtering by, right now.\n *\n * The same value the coordinator would hand `query()`. A source that needs the predicate *before*\n * it can build SQL — because it has to know which relation to point at, or because it is assembling\n * one answer out of two reads — asks here rather than reaching into the selection and re-deriving\n * the client exemption by hand.\n */\n get predicate(): FilterExpr {\n return this.filterBy?.predicate(this) ?? [];\n }\n\n /**\n * Ask, and *keep* the question. The coordinator issues, consolidates, caches and re-runs it.\n *\n * `build` is retained rather than consumed, because it is the standing question: after the page\n * filters something the camera is still over the same rectangle, so the coordinator re-running this\n * with a new predicate is precisely the query anybody would have written by hand.\n */\n ask(build: (filter: FilterExpr) => string): Promise<unknown> {\n this.#build = build;\n const answer = new Promise<unknown>((resolve, reject) => {\n // A superseded question is settled rather than dropped. The camera moves faster than DuckDB\n // answers, so the previous promise has a caller awaiting it; leaving it unsettled leaves that\n // caller's `finally` unrun and the loop reporting a query in flight for the rest of the session.\n //\n // An `AbortError`, because that is what it is: the older caller does not want this answer any\n // more, and there is no signal to reach for — nobody aborted it, a newer question overtook it.\n // It used to be a `SUPERSEDED` symbol of ours, which meant a caller had to import a name from\n // this package to tell a cancellation from a failure. `bounded.ts` carries that argument.\n this.#settle?.reject(abortError(\"superseded: this read was re-aimed at a newer question\"));\n this.#settle = { resolve, reject };\n });\n if (this.#connected) {\n this.requestQuery();\n } else {\n // Connected on first use rather than at construction, and that is what keeps a null question\n // out of the protocol: a connected client is one the coordinator may re-query on any selection\n // change, and before the first camera question there is nothing to re-query it *with*.\n // `connect` initializes, and initializing requests a query — so the opening read is issued by\n // the coordinator's own lifecycle rather than beside it.\n this.#connected = true;\n this.#coordinator.connect(this);\n }\n return answer;\n }\n\n /**\n * Where an answer goes when nobody asked for it — which is the page's filters moving.\n *\n * Told apart from a pull by which of the two is outstanding rather than by a flag, so the two\n * cannot disagree: an answer settles the promise when one is waiting, and is reported here when\n * none is.\n */\n set onAnswer(handle: ((data: unknown) => void) | null) {\n this.#onAnswer = handle;\n }\n\n /** Let go: no more queries, and the coordinator stops walking us on every selection change. */\n release(): void {\n this.#settle?.reject(abortError(\"released: this read let go of the coordinator\"));\n this.#settle = null;\n this.#onAnswer = null;\n this.#build = null;\n if (this.#connected) {\n this.#connected = false;\n this.#coordinator.disconnect(this);\n }\n }\n\n override query(filter: FilterExpr = []): string | null {\n return this.#build?.(filter) ?? null;\n }\n\n override queryResult(data: unknown): this {\n const settle = this.#settle;\n this.#settle = null;\n if (settle) settle.resolve(data);\n else this.#onAnswer?.(data);\n return this;\n }\n\n override queryError(error: Error): this {\n const settle = this.#settle;\n this.#settle = null;\n if (settle) settle.reject(error);\n return this;\n }\n}\n"],"names":["_coordinator"],"mappings":";;;;;;;;;AAgCO;AAAqC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAcxC;AAbF;AACA;AACA;AACA;AACA;AAUE;AAAoB;AACtB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAYE;AAAO;AACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAWE;AAAyC;AAC3C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAUE;AACA;;AASE;AAC0B;AAE5B;AAWO;AACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAUE;AAAiB;AACnB;AAAA;;AAIE;AAMmC;AAErC;;AAGE;AAAgC;AAClC;;AAGE;AACA;AAGO;AACT;AAGE;AACA;AAEO;AAEX;AAtHEA;;;;"}
|
|
@@ -1,27 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
/**
|
|
4
|
-
* Everything that floats over the canvas and has to keep up with it: the hub labels, the hover
|
|
5
|
-
* card, and the grid's lock to the graph's own space.
|
|
6
|
-
*
|
|
7
|
-
* One rAF for all three, because they answer the same question — *where is the camera now* — and
|
|
8
|
-
* three independent loops would read the same transform three times a frame. React never runs: the
|
|
9
|
-
* overlays move by imperative style writes, and a re-render per frame would be a re-render per
|
|
10
|
-
* frame.
|
|
11
|
-
*
|
|
12
|
-
* **An overlay is attached to a vertex, not to a slot.** Everything here outlives an answer — a
|
|
13
|
-
* label element is kept across renders, a hover survives a query — so the tracked set is identities
|
|
14
|
-
* and the buffer index is resolved through `Resident` at the moment of painting. Held as indices, a
|
|
15
|
-
* label would keep its position and change which node it was naming the first time the resident set
|
|
16
|
-
* moved, with the text and the dot disagreeing and nothing raised.
|
|
17
|
-
*
|
|
18
|
-
* This lived inside the canvas component among seven other concerns, and that is not a filing
|
|
19
|
-
* detail: the scheduler below once kept a cancelled `requestAnimationFrame` handle in `frame`,
|
|
20
|
-
* which silently disabled every overlay for the life of the page. It took a long time to find in a
|
|
21
|
-
* 700-line component and would have been obvious here.
|
|
22
|
-
*/
|
|
23
|
-
/** Dot spacing at zoom 1. The painter keeps the on-screen spacing inside [GRID, 2·GRID). */
|
|
24
|
-
export declare const GRID = 22;
|
|
1
|
+
import { VertexId } from './resident';
|
|
2
|
+
import { GraphApi } from './use-graph';
|
|
25
3
|
export interface GraphOverlays {
|
|
26
4
|
/** The box the overlays are positioned within — the canvas' own bounds. */
|
|
27
5
|
hostRef: React.RefObject<HTMLDivElement | null>;
|
|
@@ -44,10 +22,23 @@ export interface GraphOverlays {
|
|
|
44
22
|
/** Ask for a repaint. Coalesced — many calls in a frame cost one. */
|
|
45
23
|
schedule: () => void;
|
|
46
24
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
25
|
+
/**
|
|
26
|
+
* **The api, not two getters off it.**
|
|
27
|
+
*
|
|
28
|
+
* This took `{ getGraph, getResident }` — an options object whose two members were copied out of
|
|
29
|
+
* `GraphApi` — and that shape is the reason `getGraph` and `getResident` are on the api at all
|
|
30
|
+
* beside the `slice` and `resident` values it already publishes. A host composing the two wrote the
|
|
31
|
+
* hook's argument by hand out of the object it had just been given, which is a re-statement rather
|
|
32
|
+
* than a decision: there is no useful call where the two come from different graphs.
|
|
33
|
+
*
|
|
34
|
+
* `GraphOverlayOptions` went with it. It named a shape a caller had to assemble, and what a caller
|
|
35
|
+
* has is the api.
|
|
36
|
+
*
|
|
37
|
+
* **The ordering follows, and it is the honest one.** This has to be called *after* `useGraph`,
|
|
38
|
+
* because it now takes what `useGraph` returns. The other half of the cycle — a look change owes the
|
|
39
|
+
* overlays a repaint, and a look change does not tick — stays where it was: `useGraph` takes a
|
|
40
|
+
* `schedule` callback, and a host bridges the two with one ref. One indirection, in the direction
|
|
41
|
+
* that genuinely needs one, instead of two accessors threaded around an object the host is holding.
|
|
42
|
+
*/
|
|
43
|
+
export declare function useGraphOverlays(api: GraphApi): GraphOverlays;
|
|
53
44
|
//# sourceMappingURL=use-graph-overlays.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-graph-overlays.d.ts","sourceRoot":"","sources":["../src/use-graph-overlays.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"use-graph-overlays.d.ts","sourceRoot":"","sources":["../src/use-graph-overlays.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AA8C5C,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IAChD,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IAChD,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IAChD,mDAAmD;IACnD,QAAQ,EAAE,CAAC,MAAM,EAAE,QAAQ,KAAK,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,CAAC;IACtE,gFAAgF;IAChF,aAAa,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,IAAI,CAAC;IAC9C,UAAU,EAAE,CAAC,MAAM,EAAE,QAAQ,GAAG,IAAI,KAAK,IAAI,CAAC;IAC9C;;;;;;;OAOG;IACH,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,qEAAqE;IACrE,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,QAAQ,GAAG,aAAa,CA+N7D"}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { useRef as r, useCallback as x, useEffect as
|
|
3
|
-
import { whenReady as
|
|
4
|
-
const
|
|
2
|
+
import { useRef as r, useCallback as x, useEffect as S } from "react";
|
|
3
|
+
import { whenReady as Q } from "./when-ready.js";
|
|
4
|
+
const T = 22, C = 14, w = 8, U = 10, V = 12, M = 3, F = (P, l, f) => Math.min(Math.max(P, l), Math.max(l, f));
|
|
5
5
|
function tt(P) {
|
|
6
|
-
const { getGraph: l, getResident: f } = P,
|
|
6
|
+
const { getGraph: l, getResident: f } = P, O = r(null), $ = r(null), _ = r(null), A = r(/* @__PURE__ */ new Map()), L = r(/* @__PURE__ */ new Map()), m = r([]), k = r(null), E = r(null), B = r(null), p = r(0), W = x(() => {
|
|
7
7
|
const t = l();
|
|
8
8
|
if (!t) return;
|
|
9
|
-
const o =
|
|
10
|
-
|
|
11
|
-
}, [l, f]),
|
|
9
|
+
const o = k.current, i = o === null || m.current.includes(o) ? m.current : [...m.current, o];
|
|
10
|
+
Q(t, (u) => u.trackPointPositionsByIndices(f().indicesOf(i)));
|
|
11
|
+
}, [l, f]), D = x(() => {
|
|
12
12
|
const t = l();
|
|
13
13
|
if (!t) return;
|
|
14
14
|
const o = f();
|
|
@@ -16,7 +16,7 @@ function tt(P) {
|
|
|
16
16
|
const u = (n) => {
|
|
17
17
|
const e = o.indexOf(n);
|
|
18
18
|
return e === void 0 ? null : (i ?? (i = t.getTrackedPointPositionsMap()), i.get(e) ?? null);
|
|
19
|
-
},
|
|
19
|
+
}, H = [], g = B.current;
|
|
20
20
|
for (const n of m.current) {
|
|
21
21
|
const e = A.current.get(n);
|
|
22
22
|
if (!e) continue;
|
|
@@ -25,72 +25,74 @@ function tt(P) {
|
|
|
25
25
|
e.style.opacity = "0";
|
|
26
26
|
continue;
|
|
27
27
|
}
|
|
28
|
-
const [c,
|
|
28
|
+
const [c, d] = t.spaceToScreenPosition(a);
|
|
29
29
|
let s = L.current.get(n);
|
|
30
30
|
s === void 0 && (s = e.offsetWidth, L.current.set(n, s));
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
31
|
+
const h = c - s / 2, z = h + s, b = d - U, R = b - V, K = g !== null && (z < 0 || b < 0 || h > g.width || R > g.height), N = H.some((v) => h < v[2] && z > v[0] && R < v[3] && b > v[1]);
|
|
32
|
+
if (K || N) {
|
|
33
33
|
e.style.opacity = "0";
|
|
34
34
|
continue;
|
|
35
35
|
}
|
|
36
|
-
|
|
36
|
+
H.push([h - M, R - M, z + M, b + M]), e.style.transform = `translate(${Math.round(h)}px, ${Math.round(R)}px)`, e.style.opacity = "1";
|
|
37
37
|
}
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
38
|
+
const G = $.current;
|
|
39
|
+
if (G) {
|
|
40
40
|
const n = t.getZoomLevel();
|
|
41
41
|
if (n > 0) {
|
|
42
|
-
const e =
|
|
43
|
-
|
|
42
|
+
const e = T * n / 2 ** Math.floor(Math.log2(n)), [a, c] = t.spaceToScreenPosition([0, 0]), d = (s) => (s % e + e) % e;
|
|
43
|
+
G.style.backgroundSize = `${e}px ${e}px`, G.style.backgroundPosition = `${d(a)}px ${d(c)}px`;
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
-
const y =
|
|
47
|
-
if (y &&
|
|
48
|
-
const n = u(
|
|
46
|
+
const y = _.current, I = k.current;
|
|
47
|
+
if (y && I !== null && g) {
|
|
48
|
+
const n = u(I);
|
|
49
49
|
if (n) {
|
|
50
50
|
const [e, a] = t.spaceToScreenPosition(n);
|
|
51
|
-
let c =
|
|
52
|
-
c || (c = { width: y.offsetWidth, height: y.offsetHeight },
|
|
53
|
-
const
|
|
51
|
+
let c = E.current;
|
|
52
|
+
c || (c = { width: y.offsetWidth, height: y.offsetHeight }, E.current = c);
|
|
53
|
+
const d = a - C - c.height, s = a + C, h = d >= w ? d : s;
|
|
54
54
|
y.style.transform = `translate(${Math.round(
|
|
55
|
-
|
|
56
|
-
)}px, ${Math.round(
|
|
55
|
+
F(e - c.width / 2, w, g.width - c.width - w)
|
|
56
|
+
)}px, ${Math.round(F(h, w, g.height - c.height - w))}px)`, y.style.opacity = "1";
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
}, [l, f]);
|
|
60
|
-
|
|
61
|
-
const t =
|
|
60
|
+
S(() => {
|
|
61
|
+
const t = O.current;
|
|
62
62
|
if (!t) return;
|
|
63
63
|
const o = new ResizeObserver(([i]) => {
|
|
64
64
|
const u = i == null ? void 0 : i.contentRect;
|
|
65
|
-
u && (
|
|
65
|
+
u && (B.current = { width: u.width, height: u.height });
|
|
66
66
|
});
|
|
67
67
|
return o.observe(t), () => o.disconnect();
|
|
68
|
+
}, []), S(() => {
|
|
69
|
+
const t = $.current;
|
|
70
|
+
t && (t.style.backgroundSize = `${T}px ${T}px`);
|
|
68
71
|
}, []);
|
|
69
|
-
const
|
|
72
|
+
const q = x(() => {
|
|
70
73
|
p.current || (p.current = requestAnimationFrame(() => {
|
|
71
|
-
p.current = 0,
|
|
74
|
+
p.current = 0, D();
|
|
72
75
|
}));
|
|
73
|
-
}, [
|
|
74
|
-
|
|
76
|
+
}, [D]);
|
|
77
|
+
S(
|
|
75
78
|
() => () => {
|
|
76
79
|
p.current && cancelAnimationFrame(p.current), p.current = 0;
|
|
77
80
|
},
|
|
78
81
|
[]
|
|
79
82
|
);
|
|
80
|
-
const
|
|
83
|
+
const Z = x(
|
|
81
84
|
(t) => (o) => {
|
|
82
85
|
o ? A.current.set(t, o) : A.current.delete(t);
|
|
83
86
|
},
|
|
84
87
|
[]
|
|
85
|
-
),
|
|
88
|
+
), j = x((t) => {
|
|
86
89
|
m.current = t, L.current.clear();
|
|
87
|
-
}, []),
|
|
88
|
-
|
|
90
|
+
}, []), J = x((t) => {
|
|
91
|
+
k.current = t, E.current = null;
|
|
89
92
|
}, []);
|
|
90
|
-
return { hostRef:
|
|
93
|
+
return { hostRef: O, gridRef: $, cardRef: _, labelRef: Z, setLabelOrder: j, setHovered: J, track: W, schedule: q };
|
|
91
94
|
}
|
|
92
95
|
export {
|
|
93
|
-
Q as GRID,
|
|
94
96
|
tt as useGraphOverlays
|
|
95
97
|
};
|
|
96
98
|
//# sourceMappingURL=use-graph-overlays.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-graph-overlays.js","sources":["../src/use-graph-overlays.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useRef } from \"react\";\nimport type { Graph } from \"@cosmos.gl/graph\";\nimport type { Resident, VertexId } from \"./resident\";\nimport { whenReady } from \"./when-ready\";\n\n/**\n * Everything that floats over the canvas and has to keep up with it: the hub labels, the hover\n * card, and the grid's lock to the graph's own space.\n *\n * One rAF for all three, because they answer the same question — *where is the camera now* — and\n * three independent loops would read the same transform three times a frame. React never runs: the\n * overlays move by imperative style writes, and a re-render per frame would be a re-render per\n * frame.\n *\n * **An overlay is attached to a vertex, not to a slot.** Everything here outlives an answer — a\n * label element is kept across renders, a hover survives a query — so the tracked set is identities\n * and the buffer index is resolved through `Resident` at the moment of painting. Held as indices, a\n * label would keep its position and change which node it was naming the first time the resident set\n * moved, with the text and the dot disagreeing and nothing raised.\n *\n * This lived inside the canvas component among seven other concerns, and that is not a filing\n * detail: the scheduler below once kept a cancelled `requestAnimationFrame` handle in `frame`,\n * which silently disabled every overlay for the life of the page. It took a long time to find in a\n * 700-line component and would have been obvious here.\n */\n\n/** Dot spacing at zoom 1. The painter keeps the on-screen spacing inside [GRID, 2·GRID). */\nexport const GRID = 22;\n\n/** How far the hover card clears its node, and the margin it keeps from the canvas edge. */\nconst CARD_GAP = 14;\nconst CARD_EDGE = 8;\n\n/** How far a label floats above its node, how tall its box is, and the air it demands around it. */\nconst LABEL_LIFT = 10;\nconst LABEL_HEIGHT = 12;\nconst LABEL_GAP = 3;\n\nconst clamp = (value: number, low: number, high: number) =>\n Math.min(Math.max(value, low), Math.max(low, high));\n\nexport interface GraphOverlays {\n /** The box the overlays are positioned within — the canvas' own bounds. */\n hostRef: React.RefObject<HTMLDivElement | null>;\n gridRef: React.RefObject<HTMLDivElement | null>;\n cardRef: React.RefObject<HTMLDivElement | null>;\n /** A `ref` callback for a given vertex's label. */\n labelRef: (vertex: VertexId) => (element: HTMLElement | null) => void;\n /** The labelled vertices, in the order the declutter pass should place them. */\n setLabelOrder: (vertices: VertexId[]) => void;\n setHovered: (vertex: VertexId | null) => void;\n /**\n * Re-register the tracked points with cosmos.gl.\n *\n * Call it after the graph exists and whenever the set of overlaid nodes changes. It has to be\n * driven from outside because effects run in declaration order, so this hook's own effects cannot\n * see a graph that a later hook is about to construct — and that construction is exactly what\n * clears the registration.\n */\n track: () => void;\n /** Ask for a repaint. Coalesced — many calls in a frame cost one. */\n schedule: () => void;\n}\n\nexport interface GraphOverlayOptions {\n getGraph: () => Graph | null;\n /** Who is drawn right now, for turning a tracked vertex into the buffer index cosmos.gl wants. */\n getResident: () => Resident;\n}\n\nexport function useGraphOverlays(options: GraphOverlayOptions): GraphOverlays {\n const { getGraph, getResident } = options;\n const hostRef = useRef<HTMLDivElement>(null);\n const gridRef = useRef<HTMLDivElement>(null);\n const cardRef = useRef<HTMLDivElement>(null);\n const labelEls = useRef(new Map<VertexId, HTMLElement>());\n /** Label widths, measured once each — reading `offsetWidth` every frame would force layout. */\n const widths = useRef(new Map<VertexId, number>());\n const order = useRef<VertexId[]>([]);\n const hoveredRef = useRef<VertexId | null>(null);\n /** The card's box, measured once per hover, for the same reason. */\n const cardSize = useRef<{ width: number; height: number } | null>(null);\n /** The canvas' own box, kept by a `ResizeObserver` — see the effect below. */\n const box = useRef<{ width: number; height: number } | null>(null);\n const frame = useRef(0);\n\n /**\n * Tell cosmos.gl which points the overlays are watching: the labelled ones, plus the hovered one.\n *\n * Registration is *not* self-maintaining. `Points.updatePositions()` ends in an argument-less\n * `trackPointsByIndices()` that clears it, and that runs whenever `isPointPositionsUpdateNeeded`\n * is set — which only `setPointPositions` does. So the registration survives every look change and\n * every slider, and is lost exactly once per graph: at construction, on the `render()` that\n * follows `setPointPositions`. Hence a caller-driven re-register rather than a one-shot.\n */\n const track = useCallback(() => {\n const graph = getGraph();\n if (!graph) return;\n const hovered = hoveredRef.current;\n const watched =\n hovered === null || order.current.includes(hovered)\n ? order.current\n : [...order.current, hovered];\n // Registration is a device call like any other, and this one is *only* reached before the\n // device in the case that matters: a host registers its labels the moment the first slice\n // lands, which is the same commit the graph is still being built in. Dropped there, the\n // tracked map stays empty and every overlay sits at `opacity: 0` for ever.\n whenReady(graph, (ready) => ready.trackPointPositionsByIndices(getResident().indicesOf(watched)));\n }, [getGraph, getResident]);\n\n const paint = useCallback(() => {\n const graph = getGraph();\n if (!graph) return;\n const resident = getResident();\n /**\n * Positions come from the tracking API, not from `getPointPositions()`.\n *\n * The difference is what gets read back per frame. `getPointPositions()` is a synchronous\n * `readPixels` of the *whole* position framebuffer — 10,000 bytes at this corpus size, plus an\n * O(n) array build — on every animation frame the simulation runs. Tracking reads a\n * `ceil(√k)²` texture for the k points that actually carry an overlay: 576 bytes for Atlas'\n * 26 labels and a hovered node. It also caches while the simulation is stopped, so a settled\n * graph costs no readback at all until something moves.\n */\n // Read lazily: with labels off and nothing hovered, the only overlay left is the grid, which\n // needs the transform and not the points.\n let positions: ReadonlyMap<number, [number, number]> | null = null;\n /**\n * Where a vertex is on screen, or `null` when it is not drawn at all.\n *\n * Two ways to be absent and they are one answer here: not resident — the query moved on and this\n * vertex is not in the current buffers — or resident and not yet tracked. Both mean *do not draw\n * an overlay for it*, and the alternative to asking is drawing it at whatever the stale index now\n * holds, which is a label on the wrong node.\n */\n const at = (vertex: VertexId): [number, number] | null => {\n const index = resident.indexOf(vertex);\n if (index === undefined) return null;\n positions ??= graph.getTrackedPointPositionsMap();\n return positions.get(index) ?? null;\n };\n\n // Placed boxes, in importance order. A label that would land on one already down is dropped\n // rather than drawn over it — an unreadable pile of overlapping names is worse than a sparser\n // set of legible ones.\n const placed: [number, number, number, number][] = [];\n const bounds = box.current;\n for (const vertex of order.current) {\n const element = labelEls.current.get(vertex);\n if (!element) continue;\n const point = at(vertex);\n if (!point) {\n element.style.opacity = \"0\";\n continue;\n }\n const [x, y] = graph.spaceToScreenPosition(point);\n let width = widths.current.get(vertex);\n if (width === undefined) {\n width = element.offsetWidth;\n widths.current.set(vertex, width);\n }\n const x1 = x - width / 2;\n const x2 = x1 + width;\n const y2 = y - LABEL_LIFT;\n const y1 = y2 - LABEL_HEIGHT;\n const offscreen =\n bounds !== null && (x2 < 0 || y2 < 0 || x1 > bounds.width || y1 > bounds.height);\n const collides = placed.some((r) => x1 < r[2] && x2 > r[0] && y1 < r[3] && y2 > r[1]);\n if (offscreen || collides) {\n element.style.opacity = \"0\";\n continue;\n }\n placed.push([x1 - LABEL_GAP, y1 - LABEL_GAP, x2 + LABEL_GAP, y2 + LABEL_GAP]);\n // Positioned at the box that was just tested, in pixels. The previous version measured a\n // rectangle here and then drew the label somewhere else — `translate(-50%, -160%)` offsets by\n // percentages of the element's own size, so the collision box sat about nine pixels below the\n // text it was meant to protect and neighbouring labels overlapped anyway.\n element.style.transform = `translate(${Math.round(x1)}px, ${Math.round(y1)}px)`;\n element.style.opacity = \"1\";\n }\n // The grid belongs to the graph's space, not to the viewport: it slides with a pan and\n // subdivides on zoom, so the spacing on screen never leaves [GRID, 2·GRID). Without that a\n // fixed grid reads as wallpaper and the canvas stops feeling like somewhere you can move.\n const grid = gridRef.current;\n if (grid) {\n const k = graph.getZoomLevel();\n if (k > 0) {\n const step = (GRID * k) / 2 ** Math.floor(Math.log2(k));\n const [ox, oy] = graph.spaceToScreenPosition([0, 0]);\n const wrap = (v: number) => ((v % step) + step) % step;\n grid.style.backgroundSize = `${step}px ${step}px`;\n grid.style.backgroundPosition = `${wrap(ox)}px ${wrap(oy)}px`;\n }\n }\n\n // The card sits above the node, flips below when the top runs out, and is held inside the\n // canvas on both axes. It used to be centred with percentage transforms, which cannot know\n // about an edge — and since this layer clips, a node near a border showed half a tooltip.\n const card = cardRef.current;\n const hovered = hoveredRef.current;\n if (card && hovered !== null && bounds) {\n const point = at(hovered);\n if (point) {\n const [x, y] = graph.spaceToScreenPosition(point);\n let size = cardSize.current;\n if (!size) {\n size = { width: card.offsetWidth, height: card.offsetHeight };\n cardSize.current = size;\n }\n const above = y - CARD_GAP - size.height;\n const below = y + CARD_GAP;\n const top = above >= CARD_EDGE ? above : below;\n card.style.transform = `translate(${Math.round(\n clamp(x - size.width / 2, CARD_EDGE, bounds.width - size.width - CARD_EDGE),\n )}px, ${Math.round(clamp(top, CARD_EDGE, bounds.height - size.height - CARD_EDGE))}px)`;\n card.style.opacity = \"1\";\n }\n }\n }, [getGraph, getResident]);\n\n // The canvas' box, measured when it changes rather than when it is read. `getBoundingClientRect()`\n // inside `paint` was one forced layout per animation frame, in a painter that caches `offsetWidth`\n // for exactly that reason.\n useEffect(() => {\n const host = hostRef.current;\n if (!host) return;\n const observer = new ResizeObserver(([entry]) => {\n const size = entry?.contentRect;\n if (size) box.current = { width: size.width, height: size.height };\n });\n observer.observe(host);\n return () => observer.disconnect();\n }, []);\n\n const schedule = useCallback(() => {\n if (frame.current) return;\n frame.current = requestAnimationFrame(() => {\n frame.current = 0;\n paint();\n });\n }, [paint]);\n\n useEffect(\n () => () => {\n if (frame.current) cancelAnimationFrame(frame.current);\n // Clearing the handle is the whole point of this cleanup, not the cancel. `frame` doubles as\n // the \"a paint is already queued\" flag, and StrictMode runs setup → cleanup → setup on the\n // same instance, so the refs survive. Leaving a cancelled handle behind made every later\n // `schedule()` believe a frame was still pending and return early — permanently.\n frame.current = 0;\n },\n [],\n );\n\n const labelRef = useCallback(\n (vertex: VertexId) => (element: HTMLElement | null) => {\n if (element) labelEls.current.set(vertex, element);\n else labelEls.current.delete(vertex);\n },\n [],\n );\n\n const setLabelOrder = useCallback((vertices: VertexId[]) => {\n order.current = vertices;\n widths.current.clear();\n }, []);\n\n const setHovered = useCallback((vertex: VertexId | null) => {\n hoveredRef.current = vertex;\n cardSize.current = null;\n }, []);\n\n return { hostRef, gridRef, cardRef, labelRef, setLabelOrder, setHovered, track, schedule };\n}\n"],"names":[],"mappings":";;;AA6BO;AA2CA;AACL;AAyBE;AACA;AACA;AASA;AAAgG;AAIhG;AACA;AACA;AAaA;AASA;AACE;AACA;AAE+B;AAQjC;AACE;AACA;AACA;AACA;AACE;AACA;AAAA;AAEF;AACA;AACA;AAIA;AAOA;AACE;AACA;AAAA;AAEF;AAMwB;AAK1B;AACA;AACE;AACA;AACE;AAGA;AACyD;AAC3D;AAMF;AAEA;AACE;AACA;AACE;AACA;AACA;AAIA;AAGA;AAAyC;AACmC;AAEvD;AACvB;AACF;AAMF;AACE;AACA;AACA;AACE;AACA;AAA0D;AAE5D;AACsB;AAGxB;AACE;AAEE;AACA;AACD;AAGH;AAAA;AAEI;AAKgB;AAClB;AACA;AAGF;AAAiB;AAEb;AACmC;AACrC;AACA;AAIA;AACe;AAIf;AACmB;AAGrB;AACF;;;;;"}
|
|
1
|
+
{"version":3,"file":"use-graph-overlays.js","sources":["../src/use-graph-overlays.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useRef } from \"react\";\nimport type { VertexId } from \"./resident\";\nimport type { GraphApi } from \"./use-graph\";\nimport { whenReady } from \"./when-ready\";\n\n/**\n * Everything that floats over the canvas and has to keep up with it: the hub labels, the hover\n * card, and the grid's lock to the graph's own space.\n *\n * One rAF for all three, because they answer the same question — *where is the camera now* — and\n * three independent loops would read the same transform three times a frame. React never runs: the\n * overlays move by imperative style writes, and a re-render per frame would be a re-render per\n * frame.\n *\n * **An overlay is attached to a vertex, not to a slot.** Everything here outlives an answer — a\n * label element is kept across renders, a hover survives a query — so the tracked set is identities\n * and the buffer index is resolved through `Resident` at the moment of painting. Held as indices, a\n * label would keep its position and change which node it was naming the first time the resident set\n * moved, with the text and the dot disagreeing and nothing raised.\n *\n * This lived inside the canvas component among seven other concerns, and that is not a filing\n * detail: the scheduler below once kept a cancelled `requestAnimationFrame` handle in `frame`,\n * which silently disabled every overlay for the life of the page. It took a long time to find in a\n * 700-line component and would have been obvious here.\n */\n\n/**\n * Dot spacing at zoom 1. The painter keeps the on-screen spacing inside [GRID, 2·GRID).\n *\n * **Not on the barrel any more.** Both call sites that imported it wrote the same line —\n * `backgroundSize: \\`${GRID}px ${GRID}px\\`` — as the *initial* value of a style `paint` overwrites\n * on its first frame. So the number was public to spell a value this hook was about to replace, and\n * the effect below writes it instead: the element the hook owns is seeded by the hook that owns it.\n */\nconst GRID = 22;\n\n/** How far the hover card clears its node, and the margin it keeps from the canvas edge. */\nconst CARD_GAP = 14;\nconst CARD_EDGE = 8;\n\n/** How far a label floats above its node, how tall its box is, and the air it demands around it. */\nconst LABEL_LIFT = 10;\nconst LABEL_HEIGHT = 12;\nconst LABEL_GAP = 3;\n\nconst clamp = (value: number, low: number, high: number) =>\n Math.min(Math.max(value, low), Math.max(low, high));\n\nexport interface GraphOverlays {\n /** The box the overlays are positioned within — the canvas' own bounds. */\n hostRef: React.RefObject<HTMLDivElement | null>;\n gridRef: React.RefObject<HTMLDivElement | null>;\n cardRef: React.RefObject<HTMLDivElement | null>;\n /** A `ref` callback for a given vertex's label. */\n labelRef: (vertex: VertexId) => (element: HTMLElement | null) => void;\n /** The labelled vertices, in the order the declutter pass should place them. */\n setLabelOrder: (vertices: VertexId[]) => void;\n setHovered: (vertex: VertexId | null) => void;\n /**\n * Re-register the tracked points with cosmos.gl.\n *\n * Call it after the graph exists and whenever the set of overlaid nodes changes. It has to be\n * driven from outside because effects run in declaration order, so this hook's own effects cannot\n * see a graph that a later hook is about to construct — and that construction is exactly what\n * clears the registration.\n */\n track: () => void;\n /** Ask for a repaint. Coalesced — many calls in a frame cost one. */\n schedule: () => void;\n}\n\n/**\n * **The api, not two getters off it.**\n *\n * This took `{ getGraph, getResident }` — an options object whose two members were copied out of\n * `GraphApi` — and that shape is the reason `getGraph` and `getResident` are on the api at all\n * beside the `slice` and `resident` values it already publishes. A host composing the two wrote the\n * hook's argument by hand out of the object it had just been given, which is a re-statement rather\n * than a decision: there is no useful call where the two come from different graphs.\n *\n * `GraphOverlayOptions` went with it. It named a shape a caller had to assemble, and what a caller\n * has is the api.\n *\n * **The ordering follows, and it is the honest one.** This has to be called *after* `useGraph`,\n * because it now takes what `useGraph` returns. The other half of the cycle — a look change owes the\n * overlays a repaint, and a look change does not tick — stays where it was: `useGraph` takes a\n * `schedule` callback, and a host bridges the two with one ref. One indirection, in the direction\n * that genuinely needs one, instead of two accessors threaded around an object the host is holding.\n */\nexport function useGraphOverlays(api: GraphApi): GraphOverlays {\n // Both are built once by `useGraph` and are stable for the life of the component, which is what\n // makes them safe to name in the dependency arrays below.\n const { getGraph, getResident } = api;\n const hostRef = useRef<HTMLDivElement>(null);\n const gridRef = useRef<HTMLDivElement>(null);\n const cardRef = useRef<HTMLDivElement>(null);\n const labelEls = useRef(new Map<VertexId, HTMLElement>());\n /** Label widths, measured once each — reading `offsetWidth` every frame would force layout. */\n const widths = useRef(new Map<VertexId, number>());\n const order = useRef<VertexId[]>([]);\n const hoveredRef = useRef<VertexId | null>(null);\n /** The card's box, measured once per hover, for the same reason. */\n const cardSize = useRef<{ width: number; height: number } | null>(null);\n /** The canvas' own box, kept by a `ResizeObserver` — see the effect below. */\n const box = useRef<{ width: number; height: number } | null>(null);\n const frame = useRef(0);\n\n /**\n * Tell cosmos.gl which points the overlays are watching: the labelled ones, plus the hovered one.\n *\n * Registration is *not* self-maintaining. `Points.updatePositions()` ends in an argument-less\n * `trackPointsByIndices()` that clears it, and that runs whenever `isPointPositionsUpdateNeeded`\n * is set — which only `setPointPositions` does. So the registration survives every look change and\n * every slider, and is lost exactly once per graph: at construction, on the `render()` that\n * follows `setPointPositions`. Hence a caller-driven re-register rather than a one-shot.\n */\n const track = useCallback(() => {\n const graph = getGraph();\n if (!graph) return;\n const hovered = hoveredRef.current;\n const watched =\n hovered === null || order.current.includes(hovered)\n ? order.current\n : [...order.current, hovered];\n // Registration is a device call like any other, and this one is *only* reached before the\n // device in the case that matters: a host registers its labels the moment the first slice\n // lands, which is the same commit the graph is still being built in. Dropped there, the\n // tracked map stays empty and every overlay sits at `opacity: 0` for ever.\n whenReady(graph, (ready) => ready.trackPointPositionsByIndices(getResident().indicesOf(watched)));\n }, [getGraph, getResident]);\n\n const paint = useCallback(() => {\n const graph = getGraph();\n if (!graph) return;\n const resident = getResident();\n /**\n * Positions come from the tracking API, not from `getPointPositions()`.\n *\n * The difference is what gets read back per frame. `getPointPositions()` is a synchronous\n * `readPixels` of the *whole* position framebuffer — 10,000 bytes at this corpus size, plus an\n * O(n) array build — on every animation frame the simulation runs. Tracking reads a\n * `ceil(√k)²` texture for the k points that actually carry an overlay: 576 bytes for Atlas'\n * 26 labels and a hovered node. It also caches while the simulation is stopped, so a settled\n * graph costs no readback at all until something moves.\n */\n // Read lazily: with labels off and nothing hovered, the only overlay left is the grid, which\n // needs the transform and not the points.\n let positions: ReadonlyMap<number, [number, number]> | null = null;\n /**\n * Where a vertex is on screen, or `null` when it is not drawn at all.\n *\n * Two ways to be absent and they are one answer here: not resident — the query moved on and this\n * vertex is not in the current buffers — or resident and not yet tracked. Both mean *do not draw\n * an overlay for it*, and the alternative to asking is drawing it at whatever the stale index now\n * holds, which is a label on the wrong node.\n */\n const at = (vertex: VertexId): [number, number] | null => {\n const index = resident.indexOf(vertex);\n if (index === undefined) return null;\n positions ??= graph.getTrackedPointPositionsMap();\n return positions.get(index) ?? null;\n };\n\n // Placed boxes, in importance order. A label that would land on one already down is dropped\n // rather than drawn over it — an unreadable pile of overlapping names is worse than a sparser\n // set of legible ones.\n const placed: [number, number, number, number][] = [];\n const bounds = box.current;\n for (const vertex of order.current) {\n const element = labelEls.current.get(vertex);\n if (!element) continue;\n const point = at(vertex);\n if (!point) {\n element.style.opacity = \"0\";\n continue;\n }\n const [x, y] = graph.spaceToScreenPosition(point);\n let width = widths.current.get(vertex);\n if (width === undefined) {\n width = element.offsetWidth;\n widths.current.set(vertex, width);\n }\n const x1 = x - width / 2;\n const x2 = x1 + width;\n const y2 = y - LABEL_LIFT;\n const y1 = y2 - LABEL_HEIGHT;\n const offscreen =\n bounds !== null && (x2 < 0 || y2 < 0 || x1 > bounds.width || y1 > bounds.height);\n const collides = placed.some((r) => x1 < r[2] && x2 > r[0] && y1 < r[3] && y2 > r[1]);\n if (offscreen || collides) {\n element.style.opacity = \"0\";\n continue;\n }\n placed.push([x1 - LABEL_GAP, y1 - LABEL_GAP, x2 + LABEL_GAP, y2 + LABEL_GAP]);\n // Positioned at the box that was just tested, in pixels. The previous version measured a\n // rectangle here and then drew the label somewhere else — `translate(-50%, -160%)` offsets by\n // percentages of the element's own size, so the collision box sat about nine pixels below the\n // text it was meant to protect and neighbouring labels overlapped anyway.\n element.style.transform = `translate(${Math.round(x1)}px, ${Math.round(y1)}px)`;\n element.style.opacity = \"1\";\n }\n // The grid belongs to the graph's space, not to the viewport: it slides with a pan and\n // subdivides on zoom, so the spacing on screen never leaves [GRID, 2·GRID). Without that a\n // fixed grid reads as wallpaper and the canvas stops feeling like somewhere you can move.\n const grid = gridRef.current;\n if (grid) {\n const k = graph.getZoomLevel();\n if (k > 0) {\n const step = (GRID * k) / 2 ** Math.floor(Math.log2(k));\n const [ox, oy] = graph.spaceToScreenPosition([0, 0]);\n const wrap = (v: number) => ((v % step) + step) % step;\n grid.style.backgroundSize = `${step}px ${step}px`;\n grid.style.backgroundPosition = `${wrap(ox)}px ${wrap(oy)}px`;\n }\n }\n\n // The card sits above the node, flips below when the top runs out, and is held inside the\n // canvas on both axes. It used to be centred with percentage transforms, which cannot know\n // about an edge — and since this layer clips, a node near a border showed half a tooltip.\n const card = cardRef.current;\n const hovered = hoveredRef.current;\n if (card && hovered !== null && bounds) {\n const point = at(hovered);\n if (point) {\n const [x, y] = graph.spaceToScreenPosition(point);\n let size = cardSize.current;\n if (!size) {\n size = { width: card.offsetWidth, height: card.offsetHeight };\n cardSize.current = size;\n }\n const above = y - CARD_GAP - size.height;\n const below = y + CARD_GAP;\n const top = above >= CARD_EDGE ? above : below;\n card.style.transform = `translate(${Math.round(\n clamp(x - size.width / 2, CARD_EDGE, bounds.width - size.width - CARD_EDGE),\n )}px, ${Math.round(clamp(top, CARD_EDGE, bounds.height - size.height - CARD_EDGE))}px)`;\n card.style.opacity = \"1\";\n }\n }\n }, [getGraph, getResident]);\n\n // The canvas' box, measured when it changes rather than when it is read. `getBoundingClientRect()`\n // inside `paint` was one forced layout per animation frame, in a painter that caches `offsetWidth`\n // for exactly that reason.\n useEffect(() => {\n const host = hostRef.current;\n if (!host) return;\n const observer = new ResizeObserver(([entry]) => {\n const size = entry?.contentRect;\n if (size) box.current = { width: size.width, height: size.height };\n });\n observer.observe(host);\n return () => observer.disconnect();\n }, []);\n\n /**\n * The grid's spacing at rest, written before the camera has said anything.\n *\n * `paint` sets this every frame from the live zoom, but only once there is a graph and a zoom to\n * read — and the element is mounted well before that. The two hosts that drew a grid were each\n * writing this same line inline off an exported `GRID`, which is a constant published so a call\n * site could spell the value this hook was about to overwrite. Seeding it here is the same picture\n * with the number staying where the painter that maintains it lives.\n *\n * The `backgroundImage` is not seeded: what the dots are made of is the host's decision — the\n * border colour, a gradient, whatever the surface wants — and only the *spacing* has to agree with\n * the camera.\n */\n useEffect(() => {\n const grid = gridRef.current;\n if (grid) grid.style.backgroundSize = `${GRID}px ${GRID}px`;\n }, []);\n\n const schedule = useCallback(() => {\n if (frame.current) return;\n frame.current = requestAnimationFrame(() => {\n frame.current = 0;\n paint();\n });\n }, [paint]);\n\n useEffect(\n () => () => {\n if (frame.current) cancelAnimationFrame(frame.current);\n // Clearing the handle is the whole point of this cleanup, not the cancel. `frame` doubles as\n // the \"a paint is already queued\" flag, and StrictMode runs setup → cleanup → setup on the\n // same instance, so the refs survive. Leaving a cancelled handle behind made every later\n // `schedule()` believe a frame was still pending and return early — permanently.\n frame.current = 0;\n },\n [],\n );\n\n const labelRef = useCallback(\n (vertex: VertexId) => (element: HTMLElement | null) => {\n if (element) labelEls.current.set(vertex, element);\n else labelEls.current.delete(vertex);\n },\n [],\n );\n\n const setLabelOrder = useCallback((vertices: VertexId[]) => {\n order.current = vertices;\n widths.current.clear();\n }, []);\n\n const setHovered = useCallback((vertex: VertexId | null) => {\n hoveredRef.current = vertex;\n cardSize.current = null;\n }, []);\n\n return { hostRef, gridRef, cardRef, labelRef, setLabelOrder, setHovered, track, schedule };\n}\n"],"names":[],"mappings":";;;AAoCA;AAuDO;AAGL;AAyBE;AACA;AACA;AASA;AAAgG;AAIhG;AACA;AACA;AAaA;AASA;AACE;AACA;AAE+B;AAQjC;AACE;AACA;AACA;AACA;AACE;AACA;AAAA;AAEF;AACA;AACA;AAIA;AAOA;AACE;AACA;AAAA;AAEF;AAMwB;AAK1B;AACA;AACE;AACA;AACE;AAGA;AACyD;AAC3D;AAMF;AAEA;AACE;AACA;AACE;AACA;AACA;AAIA;AAGA;AAAyC;AACmC;AAEvD;AACvB;AACF;AAMF;AACE;AACA;AACA;AACE;AACA;AAA0D;AAE5D;AACsB;AAiBtB;AACA;AAAuD;AAGzD;AACE;AAEE;AACA;AACD;AAGH;AAAA;AAEI;AAKgB;AAClB;AACA;AAGF;AAAiB;AAEb;AACmC;AACrC;AACA;AAIA;AACe;AAIf;AACmB;AAGrB;AACF;;;;"}
|
package/dist/use-graph.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Graph } from '@cosmos.gl/graph';
|
|
2
2
|
import { RefObject } from 'react';
|
|
3
3
|
import { BoundedSource, Slice } from './bounded';
|
|
4
|
-
import {
|
|
4
|
+
import { LookPatch } from './graph-looks';
|
|
5
5
|
import { Resident, VertexId } from './resident';
|
|
6
6
|
import { Sim } from './graph-sim';
|
|
7
7
|
import { Motion } from './types';
|
|
@@ -11,10 +11,10 @@ import { Motion } from './types';
|
|
|
11
11
|
* **This is Ark's `useX(props) → api`, and the reason it exists is a host that could not adopt
|
|
12
12
|
* `GraphCanvas`.** The component owned the renderer, the query loop and the look, and published them
|
|
13
13
|
* through a context — which a legend or an inspector can read, because those are `children`. What
|
|
14
|
-
* cannot read a context is anything that sits *above* the element: `useGraphOverlays` wants
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* cannot read a context is anything that sits *above* the element: `useGraphOverlays` wants the api
|
|
15
|
+
* itself, the `events` block wants the accessors on it, and both are arguments to a hook called in
|
|
16
|
+
* the component that renders the canvas rather than inside it. The workspace needed them in thirty
|
|
17
|
+
* places and so kept its own copy of all three.
|
|
18
18
|
*
|
|
19
19
|
* The general answer to that circularity is Ark's, and it is a shape rather than a feature: the
|
|
20
20
|
* factory builds the api where the host can hold it, the provider takes it and renders. What the
|
|
@@ -58,9 +58,23 @@ export interface UseGraphProps {
|
|
|
58
58
|
* **One appearance input**, where there were two: a `Display` rode beside this with its own
|
|
59
59
|
* defaults, spelling the edge layer, the backdrop and two multipliers over numbers the look
|
|
60
60
|
* already computes. `lookFrom(values)` resolves the whole picture from the axes a person chose.
|
|
61
|
+
*
|
|
62
|
+
* **A patch, not a whole `Look` — which is what `DEFAULT_LOOK` was for.** This took the complete
|
|
63
|
+
* object, so a host that wanted the vignette on wrote `{ ...DEFAULT_LOOK, vignette: true }` and
|
|
64
|
+
* the package exported the default to make that expressible. That spread is a *copy*: the host
|
|
65
|
+
* now holds every number this package chose, and stops tracking any of them the moment one moves
|
|
66
|
+
* here. Passing `{ vignette: true }` says what the host decided and leaves the rest ours. `link`
|
|
67
|
+
* merges one level down, so `{ link: { render: false } }` keeps the measured opacity and width.
|
|
68
|
+
*
|
|
69
|
+
* Memoise it, or pass a literal only when it does not change: the reference is what the buffers
|
|
70
|
+
* are rebuilt on. Omitted entirely, it costs nothing — the shared default is returned by identity.
|
|
71
|
+
*/
|
|
72
|
+
look?: LookPatch;
|
|
73
|
+
/**
|
|
74
|
+
* The force coefficients, as a patch over this package's own — `look`'s twin in every respect,
|
|
75
|
+
* including why `DEFAULT_SIM` is no longer exported to spread from.
|
|
61
76
|
*/
|
|
62
|
-
|
|
63
|
-
sim?: Sim;
|
|
77
|
+
sim?: Partial<Sim>;
|
|
64
78
|
/**
|
|
65
79
|
* Off by default, and that is the correct default rather than a cautious one: a bounded source
|
|
66
80
|
* hands back the coordinates its next spatial query is expressed in, so a force moves the picture
|
|
@@ -131,9 +145,9 @@ export interface UseGraphProps {
|
|
|
131
145
|
* off. A host drawing no labels passes nothing.
|
|
132
146
|
*
|
|
133
147
|
* It goes through the props rather than being read off the api because `useGraphOverlays` is
|
|
134
|
-
* declared *after* this hook — it
|
|
135
|
-
*
|
|
136
|
-
*
|
|
148
|
+
* declared *after* this hook — it takes what this hook returns. A host bridges the two with one
|
|
149
|
+
* ref, which is the smallest honest answer to a cycle that is genuinely mutual: the overlays need
|
|
150
|
+
* the graph, and the graph's repaint owes the overlays a nudge.
|
|
137
151
|
*/
|
|
138
152
|
schedule?: () => void;
|
|
139
153
|
}
|
|
@@ -156,6 +170,20 @@ export interface GraphApi {
|
|
|
156
170
|
* attaches it itself, and nothing works until something does.
|
|
157
171
|
*/
|
|
158
172
|
hostRef: RefObject<HTMLDivElement | null>;
|
|
173
|
+
/**
|
|
174
|
+
* The renderer and the current map, read from inside a callback that was created once.
|
|
175
|
+
*
|
|
176
|
+
* **These two nearly left with `GraphOverlayOptions`, and the census is why they stayed.** The
|
|
177
|
+
* reason they were on the api was that `useGraphOverlays` asked for them as a pair, so every host
|
|
178
|
+
* copied them out of this object into that hook's argument — which is a re-statement, and that
|
|
179
|
+
* hook takes the api now. What kept them is a different set of readers: `useGraphSelection` takes
|
|
180
|
+
* both (its `commit` is a policy only a product can write, so it cannot be folded into the api the
|
|
181
|
+
* way the overlays were), and a real consumer outside this repository reads `getGraph` and
|
|
182
|
+
* `getResident` off `useGraphContext()` to paint its own mask over the buffers. Both are reads
|
|
183
|
+
* from inside a callback registered once, which is exactly the case `slice` and `resident` cannot
|
|
184
|
+
* serve — a value would re-render every consumer on every camera move to hand back a reference
|
|
185
|
+
* they only dereference when something is clicked.
|
|
186
|
+
*/
|
|
159
187
|
getGraph: () => Graph | null;
|
|
160
188
|
getResident: () => Resident;
|
|
161
189
|
/** The answer currently drawn, or `null` before the first one. */
|
|
@@ -170,8 +198,6 @@ export interface GraphApi {
|
|
|
170
198
|
sliced: boolean;
|
|
171
199
|
/** Ask again about wherever the camera is now. Wired to the camera already. */
|
|
172
200
|
refresh: () => void;
|
|
173
|
-
/** Ask a topological question instead of a spatial one, when the source supports one. */
|
|
174
|
-
explore: (seeds: VertexId[], depth: number) => void;
|
|
175
201
|
}
|
|
176
202
|
export declare function useGraph(props: UseGraphProps): GraphApi;
|
|
177
203
|
//# sourceMappingURL=use-graph.d.ts.map
|
package/dist/use-graph.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-graph.d.ts","sourceRoot":"","sources":["../src/use-graph.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAgC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;AACtD,OAAO,
|
|
1
|
+
{"version":3,"file":"use-graph.d.ts","sourceRoot":"","sources":["../src/use-graph.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAgC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;AACtD,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAE5D,OAAO,EAAc,KAAK,QAAQ,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtE,OAAO,EAAc,KAAK,GAAG,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAKtC;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,WAAW;IAC1B,iBAAiB,CAAC,EAAE,MAAM,IAAI,CAAC;IAC/B,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvE,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1D,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,QAAQ,KAAK,IAAI,CAAC;IACvC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,MAAM,EAAE,aAAa,GAAG,IAAI,CAAC;IAC7B;;;;;;;;;;;;;;;;OAgBG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB;;;OAGG;IACH,GAAG,CAAC,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,CAAC;IAClC;;;;;;;;;;;;;;;OAeG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,CAAC,CAAC,EAAE,MAAM,CAAC;IACX;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;CACvB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,QAAQ;IACvB;;;OAGG;IACH,OAAO,EAAE,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IAC1C;;;;;;;;;;;;;OAaG;IACH,QAAQ,EAAE,MAAM,KAAK,GAAG,IAAI,CAAC;IAC7B,WAAW,EAAE,MAAM,QAAQ,CAAC;IAC5B,kEAAkE;IAClE,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,oFAAoF;IACpF,QAAQ,EAAE,QAAQ,CAAC;IACnB,0DAA0D;IAC1D,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,yCAAyC;IACzC,OAAO,EAAE,OAAO,CAAC;IACjB,+FAA+F;IAC/F,MAAM,EAAE,OAAO,CAAC;IAChB,+EAA+E;IAC/E,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAKD,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,GAAG,QAAQ,CAiJvD"}
|