diff --git a/.claude/skills/documenting-typescript-code/SKILL.md b/.claude/skills/documenting-typescript-code/SKILL.md new file mode 100644 index 00000000000..0984bf10b33 --- /dev/null +++ b/.claude/skills/documenting-typescript-code/SKILL.md @@ -0,0 +1,275 @@ +--- +name: documenting-typescript-code +description: TypeScript documentation and comment-writing guide for doc comments (TSDoc), file headers, and inline comments. Use when writing or reviewing doc comments, documenting functions/types/modules, writing inline comments, explaining invariants, or auditing comment quality in TypeScript code. +license: AGPL-3.0 +metadata: + triggers: + type: domain + enforcement: suggest + priority: high + keywords: + - tsdoc + - jsdoc + - doc comment + - inline comment + - documentation + intent-patterns: + - "\\bdocument(ing|ation)?\\b.*?\\b(typescript|function|type|interface|class|module)\\b" + - "\\b(write|add|create|review|clean)\\b.*?\\bcomments?\\b" +--- + +# TypeScript Documentation Guide + +## Purpose + +Use this skill to write or revise TypeScript documentation — TSDoc comments, file headers, and inline `//` comments — that is practical, user-oriented, explicit about contracts, and honest about tradeoffs. The goal is documentation that helps a reader make a correct decision quickly, then gives them enough detail to avoid surprises later. + +The key trait is explicitness without fuss. Docs should feel like an experienced maintainer sitting next to the reader saying, "Here is the thing, here is how you use it, and here are the traps." + +## Maintaining this skill + +This is a living document. When a doc comment gets corrected or reworded in review, or a pattern emerges that is not captured here, update the skill to reflect it. The same applies in reverse: if a rule consistently produces worse docs than ignoring it, rework or remove that section. The goal is a skill that stays useful, not one that stays unchanged. + +## The three comment layers + +TypeScript code has three distinct documentation layers, each with its own job: + +1. **File header block** — a `/** ... */` (or plain block) comment at the top of a module. States what the module owns, the mental model, and the invariants that span the whole file. Required for modules with non-trivial responsibilities; skip for trivial re-export or single-component files. +2. **TSDoc on exported items** — `/** ... */` on exported functions, classes, interfaces, and non-obvious constants. This is the contract surface. +3. **Inline `//` comments** — explain _why_, record invariants and non-obvious behavior at the point where a reader would otherwise be surprised. Never narrate _what_ the code already says. + +The test for what goes where: **"Would a maintainer need to understand this to judge whether a change is safe?"** If yes, it belongs in the doc comment or file header. If it just explains how the sausage is made, it belongs inline — or nowhere. + +## Doc comments (TSDoc) + +### Shape + +Use this structure for exported items, in order, dropping sections that do not apply: + +1. Summary sentence. +2. Behavior paragraph — the mental model, in terms of caller intent. +3. Guarantees, defaults, and caveats. +4. Failure modes (`@throws`), invariants, complexity. +5. `@example` when the usage is non-obvious. +6. `@see` links to alternatives. + +### Summary sentence + +Everything before the first blank line is the reader's first impression. Make it say what the item _is_ (types: noun phrase) or _does_ (functions: verb phrase) without restating the name. + +Good: + +```ts +/** Returns the index of the first entity whose circle contains the point. */ +``` + +Weak (restates the signature): + +```ts +/** Gets the entity index. */ +``` + +### Behavior paragraph + +Explain the operation in terms of caller intent. Include what is returned when nothing is found, whether returned values are copies or live views, whether the operation mutates its input, and whether it allocates when that matters. + +Do not restate what the signature already shows. Instead of "returns a `number | undefined`", say "Returns `undefined` when the entity has not been assigned a cluster yet." + +### Guarantees + +State guarantees explicitly and sparingly — a guarantee is a contract: + +- "The returned array is sorted by entity index." +- "The view remains valid across in-place buffer growth; it is republished only on reallocation." +- "This runs in `O(members)` time and does not allocate." +- "Safe to call before `init`; the call is a no-op until the worker is ready." + +### `@throws` and rejection + +Document concrete conditions for thrown errors and rejected promises. TypeScript has no checked errors, so the doc comment is the only place a caller learns what can fail: + +```ts +/** + * @throws When `byteLength` exceeds the buffer's `maxByteLength`; + * callers must republish through {@link RepublishHandler} instead. + */ +``` + +Avoid "Throws an error if something goes wrong." + +### Invariants + +TypeScript's unsafe corners — `as` casts, non-null `!`, `SharedArrayBuffer`/`Atomics` protocols, index arithmetic into typed arrays, branded-type constructions — need caller obligations stated as invariants, not suggestions: + +```ts +/** + * Invariant: callers must only pass indices previously returned by + * `insert`; the store does not bounds-check in production builds. + */ +``` + +Every `as` cast at a boundary needs a comment justifying why it is sound. An unexplained cast is a review defect. + +### Tags + +- `{@link TypeName}` on first mention of a type, function, or module in prose. Plain backticks are for code snippets, keywords, and literal values — not type references. +- `@param` only when the parameter's role is not obvious from its name and type. Never write `@param count - The count`. +- `@returns` only when the return needs explanation beyond the behavior paragraph. Prefer describing the return inline in prose. +- `@defaultValue` on configuration fields and options. +- `@example` for non-obvious usage; examples should demonstrate why the API matters, not just that it can be called. App-internal code rarely needs them; shared utilities and tricky pure functions often do. +- `@deprecated` with a pointer to the replacement. +- `@internal` for private/internal APIs that are not part of the public interface. + +### Configuration and options + +Every config knob documents three things: what the setting changes, its default, and the tradeoff of turning it. If enabling a mode worsens some behavior, say so directly: + +```ts +/** + * Maximum solver epochs spent in the separation phase before giving up. + * + * @defaultValue 40. Raising it improves overlap removal on dense graphs + * at the cost of longer settle times; lowering it can leave residual + * overlaps that the renderer must tolerate. + */ +``` + +## Inline comments + +Inline comments are the highest-leverage and most abused layer. The rules: + +### Why, not what + +A comment that narrates the next line is noise. A comment that explains why the code is shaped this way — the constraint, the bug it avoids, the profile that motivated it — is load-bearing. + +Weak: + +```ts +// Increment the version counter. +version += 1; +``` + +Good: + +```ts +// Bump version before writing positions so a torn read on the main +// thread fails the seqlock check and retries, rather than rendering +// a half-written frame. +version += 1; +``` + +### Affirmative, present tense + +State what IS, not what WAS or what ISN'T. Never comment what was removed or changed (`// removed X`, `// previously…`, `// no longer needed`) — history is git's job. Never describe roads not taken in code comments; contrastive rationale ("we chose X over Y because…") is fine when the alternative is the _obvious_ choice a maintainer would otherwise reach for. + +This includes "current state framed as a change". The reader was not there for the change; describe the role, not the transition: + +Weak (narrates the transition): + +```ts +/** Only exercised by benches/tests now that production's community-force + * tier runs the stress layout instead of FA2. */ +``` + +Good (states the present role and where to look): + +```ts +/** FA2 reference engine for the community-force tier, reachable from benches + * and tests only: production selects `stress-layout.ts` for this tier. */ +``` + +### Place comments at the point of surprise + +- Per-branch comments beat a block comment above a `switch`/`if`-chain when each branch needs its own explanation. +- A magic number gets its justification on the same line or the line above — including where it came from ("matches Deck.gl's default pick radius", "empirically flat above 8 on M1/Chrome profiles"). +- Performance-motivated distortions (loop hoisting, manual inlining, typed-array pooling, avoiding closures in hot paths) get a comment naming the hot path and why the straightforward version was not used. Otherwise the next maintainer will "clean it up". + +### Concurrency and protocol comments + +Code that coordinates across threads (workers, `SharedArrayBuffer`, `Atomics`) documents its protocol where the coordination happens: who writes, who reads, what ordering guarantees hold, and what happens on the failure path. A memory-ordering decision without a comment is unreviewable. + +### Comment hygiene + +- No ASCII banners or decorative separators. +- No ALL-CAPS emphasis in prose (keep legitimate acronyms such as `SAB`, `LOD`, `GPU`). +- Section labels and inline emphasis use sentence case; domain terms may use backticks (e.g. `packing-bound`). +- Debug hook and flag names in comments use kebab-case to match the code. +- No commented-out code. Delete it; git remembers. +- No caller narration ("Used by Scene to…"). Describe what the code provides. +- No composition narration ("wraps a Foo", "this is a thin wrapper around"). State the semantic purpose. +- Comments must be self-contained; do not defer essential context to external markdown or spec files (`PERFORMANCE.md`, `MANIFESTO.md`, `LAYOUT-MODES.md`, and similar). When migrating legacy comments that cite such docs, inline the contract or invariant instead of deleting the pointer. +- TODOs name the condition under which they get resolved, not just a wish: `// TODO: fold into CutIndex once link entities join type-set groups.` A TODO with no trigger is a lie with a timestamp. +- Prose punctuation must be ASCII. When a comment used Unicode dashes or ellipses, rewrite the sentence in plain English rather than substituting `---`, `--`, spaced hyphens, or semicolons as stand-ins for em dashes. Split into two sentences, use commas or parentheses, or rephrase so the comment reads naturally. + +### Math notation in comments + +**Math and formula notation are exempt from ASCII-only hygiene.** Keep standard mathematical symbols wherever they make a formula easier to read. Do not "flatten" them into prose. + +Symbols that belong in formulas include: + +- Set operators: `∩`, `∪` +- Relations and mappings: `≤`, `≥`, `≈`, `≪`, `≫`, `→`, `↔`, `⇒`, `⇔`, `←` +- Operators: `×`, `·`, `Σ`, `-` (do not use unicode minus inside a formula) +- Superscripts, subscripts, and constants in formulas: `d²`, `rᵢ`, `2²⁶`, `ε`, `π`, `norm²`, etc. + +Examples: + +```ts +/** Jaccard similarity: |A ∩ B| / |A ∪ B|. Returns 1 if both empty. */ +/** separation w → ×(1 + 2w); cohesion w → ×1/(1 + 2w) */ +/** L·x = b_x solved per dimension; majorize→project limit cycle */ +``` + +Use ASCII for ordinary prose punctuation (commas, semicolons between clauses) and for identifiers that are not part of a formula (`EntityIdx->EntityId` join maps, log arrows, protocol names). When a comment is clearly a mapping or formula (`f: A → B`, `scale_c = max(1, R_packing / R_hop-ideal)`), keep `→`, `×`, `·`, and relation symbols. + +**Do not** replace helpful math glyphs with spelled-out words (`intersect`, `union`, `times`, `less than or equal`, `much less than`) unless the line is pure narrative with no formula structure. + +## Narration anti-patterns + +These phrases are smells. When you catch yourself writing them, rewrite to state the guarantee or contract instead. + +| Smell | Problem | Rewrite as | +| ----------------------------------- | ------------------------ | --------------------------------------- | +| "Wraps a `Foo`" / "Backed by `Foo`" | Narrates structure | State what the item IS | +| "Contains the bytes for" | Narrates storage | State what the item provides | +| "Used by X to…" / "Called from Y" | Narrates callers | Describe the provided behavior | +| "Stores the value in a Map" | Narrates implementation | State the performance guarantee, if any | +| "This is a thin wrapper around" | Narrates composition | State the semantic purpose | +| "Helper function for…" | Narrates role, not value | Say what it computes and when to use it | +| "Removed the old…" / "No longer…" | Narrates history | Delete; git remembers | + +## File headers + +A strong file header reads like a short tour, roughly in this order: + +1. One sentence: what this module owns. +2. The mental model — primary types/operations and their relationship, stated operationally (what a caller _does_ with them, not a struct listing). +3. Invariants and protocols that span the file (threading, buffer lifecycles, ordering requirements). +4. Honest limits: what this module intentionally does not handle, when that omission is central to correct expectations. + +Correctness-critical implementation details belong here — the ones that explain why the approach is sound and when it would break. Incidental details (which collection type backs a map) do not. + +## Editing pass + +After drafting, check at three distances: + +1. First screen: can a reader identify what the item/module is and when to use it? +2. Middle: is the mental model clear? Are defaults and failure modes explicit? +3. Details: are invariants, complexity, and unsupported cases stated where a maintainer would look before changing the code? + +Then remove generic filler. The style is detailed, but every detail earns its place. + +## Review checklist + +Before finalizing documentation or a comment pass: + +- First sentence says what the item is; no doc merely restates the signature or name. +- Fallible operations document concrete failure conditions. +- Casts, non-null assertions, and index arithmetic carry invariant justifications. +- Config knobs state default + tradeoff. +- Inline comments explain why, are affirmative and present-tense, and sit at the point of surprise. +- No banners, no commented-out code, no history narration, no caller narration, no ALL-CAPS emphasis in prose. +- Comments are self-contained; no pointers to external markdown/spec files for essential context. +- Prose punctuation is ASCII; rewrite Unicode dash/ellipsis prose instead of substituting `---`, `--`, or semicolons for em dashes. +- Concurrency protocols are documented where the coordination happens. +- Every `{@link}` resolves; plain backticks are not used for type references. diff --git a/.claude/skills/skill-rules.json b/.claude/skills/skill-rules.json index ebeb6f166aa..a708fc01fff 100644 --- a/.claude/skills/skill-rules.json +++ b/.claude/skills/skill-rules.json @@ -50,6 +50,33 @@ "blockMessage": "Skill is required to proceed", "skipConditions": {} }, + "documenting-typescript-code": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "description": "TypeScript documentation and comment-writing guide for doc comments (TSDoc), file headers, and inline comments. Use when writing or reviewing doc comments, documenting functions/types/modules, writing inline comments, explaining invariants, or auditing comment quality in TypeScript code.", + "promptTriggers": { + "keywords": [ + "tsdoc", + "jsdoc", + "doc comment", + "inline comment", + "documentation" + ], + "intentPatterns": [ + "\\bdocument(ing|ation)?\\b.*?\\b(typescript|function|type|interface|class|module)\\b", + "\\b(write|add|create|review|clean)\\b.*?\\bcomments?\\b" + ] + }, + "fileTriggers": { + "include": [], + "exclude": [], + "content": [], + "create-only": false + }, + "blockMessage": "Skill is required to proceed", + "skipConditions": {} + }, "exploring-rust-crates": { "type": "domain", "enforcement": "suggest", diff --git a/.cursor/rules/meaningful-identifiers.mdc b/.cursor/rules/meaningful-identifiers.mdc index bb81eac03eb..6ca9dede10c 100644 --- a/.cursor/rules/meaningful-identifiers.mdc +++ b/.cursor/rules/meaningful-identifiers.mdc @@ -18,12 +18,29 @@ metadata: Callback parameters and local variables must describe the value they hold. +## Abbreviations + +Spell identifiers out unless the abbreviation is genuinely well known. + +- Acceptable: `src`, `dst`, `id`, `idx` (index), `min`/`max`, `rng`. +- Not acceptable: `tgt` (write `target`), `hw` (write `highway`), `cnt`, `mgr`, + `deps` (write `dependencies`, e.g. `#dependencies` / `FooDependencies`), + `seg` (write `segment`, e.g. `segmentCount` / `instanceSegmentRange`), + ad-hoc truncations in general. +- Math-heavy code is exempt where the short names mirror the notation of the + algorithm or paper (`x`, `y`, `k1`, `h1`, `wij`); keep a comment or doc link + naming the algorithm so the notation has a referent. + ## Rules - Name array callback params after the element's domain type: `batches.map((batch) => …)`, `bins.filter((bin) => …)`, `checkpoints.find((checkpoint) => …)`. Never reuse `right`/`left`/`column` for values that are not sort operands or table columns. +- Don't suffix parameter-object type names with `Args` (`FooArgs`); name the + noun the object is: `CorridorPlan`, `BubbleCellPack`, or `FooOptions` when + it's genuinely optional knobs. Likewise name the parameter itself after + that noun (`plan`, `pack`, `options`) — `args` says nothing. - Name `reduce` accumulators after what they accumulate: `sum`/`total` for a running total, not `step`. - Name percentile/ratio arguments for the quantity they represent diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 8a824c166ab..867aced9ae0 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -70,6 +70,9 @@ jobs: with: scope: ${{ matrix.name }} + - name: Warm up repository + uses: ./.github/actions/warm-up-repo + - name: Build the benchmark target run: turbo run build:codspeed --filter=${{ matrix.name }} diff --git a/.yarn/patches/@sigma-edge-curve-npm-3.0.0-beta.16-3d8b985284.patch b/.yarn/patches/@sigma-edge-curve-npm-3.0.0-beta.16-3d8b985284.patch deleted file mode 100644 index 0cbdb651060..00000000000 --- a/.yarn/patches/@sigma-edge-curve-npm-3.0.0-beta.16-3d8b985284.patch +++ /dev/null @@ -1,93 +0,0 @@ -diff --git a/dist/sigma-edge-curve.esm.js b/dist/sigma-edge-curve.esm.js -index 7bbaeefb9262d9f2a766524c4152930d04132762..4809bb666244f28b076e8a90c6962ba903e5b7f6 100644 ---- a/dist/sigma-edge-curve.esm.js -+++ b/dist/sigma-edge-curve.esm.js -@@ -302,8 +302,87 @@ function createDrawCurvedEdgeLabel(_ref) { - - function getFragmentShader(_ref) { - var arrowHead = _ref.arrowHead; -+ /** -+ * Patched to add a border to the edges -+ */ - // language=GLSL -- var SHADER = /*glsl*/"\nprecision highp float;\n\nvarying vec4 v_color;\nvarying float v_thickness;\nvarying float v_feather;\nvarying vec2 v_cpA;\nvarying vec2 v_cpB;\nvarying vec2 v_cpC;\n".concat(arrowHead ? "\nvarying float v_targetSize;\nvarying vec2 v_targetPoint;\n\nuniform float u_lengthToThicknessRatio;\nuniform float u_widenessToThicknessRatio;" : "", "\n\nfloat det(vec2 a, vec2 b) {\n return a.x * b.y - b.x * a.y;\n}\n\nvec2 getDistanceVector(vec2 b0, vec2 b1, vec2 b2) {\n float a = det(b0, b2), b = 2.0 * det(b1, b0), d = 2.0 * det(b2, b1);\n float f = b * d - a * a;\n vec2 d21 = b2 - b1, d10 = b1 - b0, d20 = b2 - b0;\n vec2 gf = 2.0 * (b * d21 + d * d10 + a * d20);\n gf = vec2(gf.y, -gf.x);\n vec2 pp = -f * gf / dot(gf, gf);\n vec2 d0p = b0 - pp;\n float ap = det(d0p, d20), bp = 2.0 * det(d10, d0p);\n float t = clamp((ap + bp) / (2.0 * a + b + d), 0.0, 1.0);\n return mix(mix(b0, b1, t), mix(b1, b2, t), t);\n}\n\nfloat distToQuadraticBezierCurve(vec2 p, vec2 b0, vec2 b1, vec2 b2) {\n return length(getDistanceVector(b0 - p, b1 - p, b2 - p));\n}\n\nconst vec4 transparent = vec4(0.0, 0.0, 0.0, 0.0);\n\nvoid main(void) {\n float dist = distToQuadraticBezierCurve(gl_FragCoord.xy, v_cpA, v_cpB, v_cpC);\n float thickness = v_thickness;\n").concat(arrowHead ? "\n float distToTarget = length(gl_FragCoord.xy - v_targetPoint);\n float arrowLength = v_targetSize + thickness * u_lengthToThicknessRatio;\n if (distToTarget < arrowLength) {\n thickness = (distToTarget - v_targetSize) / (arrowLength - v_targetSize) * u_widenessToThicknessRatio * thickness;\n }" : "", "\n\n float halfThickness = thickness / 2.0;\n if (dist < halfThickness) {\n #ifdef PICKING_MODE\n gl_FragColor = v_color;\n #else\n float t = smoothstep(\n halfThickness - v_feather,\n halfThickness,\n dist\n );\n\n gl_FragColor = mix(v_color, transparent, t);\n #endif\n } else {\n gl_FragColor = transparent;\n }\n}\n"); -+ var SHADER = /*glsl*/`precision highp float; -+ -+varying vec4 v_color; -+varying float v_thickness; -+varying float v_feather; -+varying vec2 v_cpA; -+varying vec2 v_cpB; -+varying vec2 v_cpC; -+${ -+ arrowHead -+ ? ` -+varying float v_targetSize; -+varying vec2 v_targetPoint; -+ -+uniform float u_lengthToThicknessRatio; -+uniform float u_widenessToThicknessRatio;` -+ : "" -+ } -+ -+const vec4 borderColor = vec4(0.0, 0.0, 0.0, 0.4); -+const float borderWidth = 1.0; -+ -+float det(vec2 a, vec2 b) { -+ return a.x * b.y - b.x * a.y; -+} -+ -+vec2 getDistanceVector(vec2 b0, vec2 b1, vec2 b2) { -+ float a = det(b0, b2), b = 2.0 * det(b1, b0), d = 2.0 * det(b2, b1); -+ float f = b * d - a * a; -+ vec2 d21 = b2 - b1, d10 = b1 - b0, d20 = b2 - b0; -+ vec2 gf = 2.0 * (b * d21 + d * d10 + a * d20); -+ gf = vec2(gf.y, -gf.x); -+ vec2 pp = -f * gf / dot(gf, gf); -+ vec2 d0p = b0 - pp; -+ float ap = det(d0p, d20), bp = 2.0 * det(d10, d0p); -+ float t = clamp((ap + bp) / (2.0 * a + b + d), 0.0, 1.0); -+ return mix(mix(b0, b1, t), mix(b1, b2, t), t); -+} -+ -+float distToQuadraticBezierCurve(vec2 p, vec2 b0, vec2 b1, vec2 b2) { -+ return length(getDistanceVector(b0 - p, b1 - p, b2 - p)); -+} -+ -+const vec4 transparent = vec4(0.0, 0.0, 0.0, 0.0); -+ -+void main(void) { -+ float dist = distToQuadraticBezierCurve(gl_FragCoord.xy, v_cpA, v_cpB, v_cpC); -+ -+ float mainThickness = v_thickness / 2.0; -+ -+${ -+ arrowHead -+ ? ` -+ float distToTarget = length(gl_FragCoord.xy - v_targetPoint); -+ float arrowLength = v_targetSize + mainThickness * u_lengthToThicknessRatio; -+ if (distToTarget < arrowLength) { -+ mainThickness = (distToTarget - v_targetSize) / (arrowLength - v_targetSize) * u_widenessToThicknessRatio * mainThickness; -+ }` -+ : "" -+ } -+ -+ float feather = 1.5; // Anti-aliasing feathering value -+ -+ // Render the border with anti-aliasing -+ if (dist < mainThickness + borderWidth && dist > mainThickness) { -+ float borderEdge = smoothstep(mainThickness + borderWidth, mainThickness + borderWidth - feather, dist); -+ gl_FragColor = mix(transparent, borderColor, borderEdge); -+ } -+ // Render the main edge inside the border -+ else if (dist < mainThickness) { -+ gl_FragColor = v_color; -+ } else { -+ // Outside the edge and border region, make it transparent -+ gl_FragColor = transparent; -+ } -+} -+`; - return SHADER; - } - diff --git a/Cargo.lock b/Cargo.lock index c0d06b1b2d3..1503da65e31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3527,6 +3527,7 @@ dependencies = [ "jsonwebtoken", "mimalloc", "multiaddr", + "rayon", "regex", "reqwest", "simple-mermaid", @@ -3677,9 +3678,14 @@ dependencies = [ name = "hash-graph-embeddings" version = "0.0.0" dependencies = [ + "codspeed-criterion-compat", + "darwin-kperf-criterion", "derive_more", "error-stack", "hash-graph-types", + "rand 0.10.1", + "rand_xoshiro", + "rayon", "reqwest", "reqwest-middleware", "reqwest-retry", @@ -3700,6 +3706,7 @@ dependencies = [ "hash-graph-store", "hash-graph-temporal-versioning", "hash-graph-test-data", + "hash-graph-types", "hash-status", "hash-telemetry", "pretty_assertions", @@ -3770,6 +3777,7 @@ dependencies = [ "futures-sink", "hash-codec", "hash-graph-authorization", + "hash-graph-embeddings", "hash-graph-migrations", "hash-graph-store", "hash-graph-temporal-versioning", @@ -3782,6 +3790,7 @@ dependencies = [ "indoc", "postgres-types", "pretty_assertions", + "rayon", "refinery", "regex", "semver", @@ -8032,6 +8041,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_xoshiro" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662effc7698e08ea324d3acccf8d9d7f7bf79b9785e270a174ea36e56900c91d" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rapidfuzz" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index e97c0971742..49b388cbcf2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -224,6 +224,7 @@ quote = { version = "1.0.41", default-features = fa rand = { version = "0.10.0", default-features = false } rand_core = { version = "0.10.0", default-features = false } rand_distr = { version = "0.6.0", default-features = false } +rand_xoshiro = { version = "0.8.1" } rapidfuzz = { version = "0.5.0", default-features = false } ratatui = { version = "0.30.0" } rayon = { version = "1.11.0", default-features = false } diff --git a/apps/hash-api/src/graph/knowledge/primitive/cluster-entities.ts b/apps/hash-api/src/graph/knowledge/primitive/cluster-entities.ts new file mode 100644 index 00000000000..c7587e22bf1 --- /dev/null +++ b/apps/hash-api/src/graph/knowledge/primitive/cluster-entities.ts @@ -0,0 +1,33 @@ +import { getActorIdFromRequest } from "../../../auth/get-actor-id"; + +import type { ClusterEntitiesParams } from "@local/hash-graph-client"; +import type { RequestHandler } from "express"; + +export const clusterEntitiesHandler: RequestHandler< + Record, + | { + clusters: { + clusterId: number; + entityIds: string[]; + centroid: number[]; + }[]; + missingEmbeddings: string[]; + } + | { error: string }, + ClusterEntitiesParams +> = async (req, res) => { + const actorId = getActorIdFromRequest(req); + + try { + const { data } = await req.context.graphApi.clusterEntities( + actorId, + req.body, + ); + + res.status(200).json(data); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; diff --git a/apps/hash-api/src/index.ts b/apps/hash-api/src/index.ts index e8bbafb5fce..935318a7810 100644 --- a/apps/hash-api/src/index.ts +++ b/apps/hash-api/src/index.ts @@ -63,6 +63,7 @@ import { kratosPublicUrl } from "./auth/ory-kratos"; import { setupBlockProtocolExternalServiceMethodProxy } from "./block-protocol-external-service-method-proxy"; import { createEmailTransporter } from "./email/create-email-transporter"; import { ensureSystemGraphIsInitialized } from "./graph/ensure-system-graph-is-initialized"; +import { clusterEntitiesHandler } from "./graph/knowledge/primitive/cluster-entities"; import { ensureHashSystemAccountExists } from "./graph/system-account"; import { createApolloServer } from "./graphql/create-apollo-server"; import { otelSetup } from "./instrument.mjs"; @@ -843,6 +844,9 @@ const main = async () => { next(); }); + // Entity clustering + app.post("/entities/embeddings/clusters", clusterEntitiesHandler); + // Integrations app.get("/oauth/linear", authRouteRateLimiter, oAuthLinear); app.get("/oauth/linear/callback", authRouteRateLimiter, oAuthLinearCallback); diff --git a/apps/hash-frontend/next.config.js b/apps/hash-frontend/next.config.js index 62d9b0e6860..09a913356fd 100644 --- a/apps/hash-frontend/next.config.js +++ b/apps/hash-frontend/next.config.js @@ -158,6 +158,25 @@ export default withSentryConfig( }, ], }, + { + /** + * Enable SharedArrayBuffer for the graph visualizer worker. + * COOP: same-origin isolates the browsing context. + * COEP: credentialless is less restrictive than require-corp + * (no need for Cross-Origin-Resource-Policy on every resource). + */ + source: "/:path*", + headers: [ + { + key: "Cross-Origin-Opener-Policy", + value: "same-origin", + }, + { + key: "Cross-Origin-Embedder-Policy", + value: "credentialless", + }, + ], + }, ]; }, pageExtensions: ["page.tsx", "page.ts", "page.jsx", "page.jsx", "api.ts"], diff --git a/apps/hash-frontend/package.json b/apps/hash-frontend/package.json index f2fb883088e..3ef1e9a5fa9 100644 --- a/apps/hash-frontend/package.json +++ b/apps/hash-frontend/package.json @@ -28,6 +28,11 @@ "@blockprotocol/hook": "0.1.8", "@blockprotocol/service": "0.1.5", "@blockprotocol/type-system": "workspace:*", + "@deck.gl/core": "9.3.4", + "@deck.gl/extensions": "9.3.4", + "@deck.gl/layers": "9.3.4", + "@deck.gl/react": "9.3.4", + "@deck.gl/widgets": "9.3.4", "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "7.0.2", "@dnd-kit/utilities": "3.2.2", @@ -52,6 +57,8 @@ "@local/hash-graph-sdk": "workspace:*", "@local/hash-isomorphic-utils": "workspace:*", "@local/status": "workspace:*", + "@luma.gl/core": "9.3.5", + "@luma.gl/engine": "9.3.5", "@mantine/hooks": "8.3.5", "@mui/icons-material": "5.18.0", "@mui/material": "5.18.0", @@ -59,19 +66,16 @@ "@ory/client": "1.22.7", "@ory/integrations": "1.3.1", "@popperjs/core": "2.11.8", - "@react-sigma/core": "4.0.3", "@sentry/nextjs": "10.54.0", "@sentry/react": "10.54.0", "@sentry/webpack-plugin": "5.3.0", - "@sigma/edge-curve": "patch:@sigma/edge-curve@npm%3A3.0.0-beta.16#~/.yarn/patches/@sigma-edge-curve-npm-3.0.0-beta.16-3d8b985284.patch", - "@sigma/node-border": "3.0.0", - "@sigma/node-image": "3.0.0", "@svgr/webpack": "8.1.0", "@tanstack/react-table": "8.21.3", "@tldraw/editor": "patch:@tldraw/editor@npm%3A2.0.0-alpha.12#~/.yarn/patches/@tldraw-editor-npm-2.0.0-alpha.12-ba59bf001c.patch", "@tldraw/primitives": "2.0.0-alpha.12", "@tldraw/tldraw": "2.0.0-alpha.12", "@tldraw/tlvalidate": "2.0.0-alpha.12", + "@types/d3-force": "3.0.10", "@types/prismjs": "1.26.5", "@vercel/edge-config": "0.4.1", "@xyflow/react": "12.10.1", @@ -79,6 +83,7 @@ "axios": "1.16.1", "buffer": "6.0.3", "clsx": "2.1.1", + "d3-force": "3.0.0", "date-fns": "4.1.0", "dompurify": "3.4.11", "dotenv-flow": "3.3.0", @@ -87,10 +92,7 @@ "fractional-indexing": "3.2.0", "framer-motion": "11.18.2", "graphology": "0.26.0", - "graphology-layout": "0.6.1", - "graphology-layout-forceatlas2": "0.10.1", - "graphology-shortest-path": "2.1.0", - "graphology-simple-path": "0.2.0", + "graphology-communities-louvain": "2.0.2", "graphql": "16.11.0", "iframe-resizer": "4.4.5", "immer": "10.1.3", @@ -132,13 +134,13 @@ "remark-gfm": "4.0.1", "safe-stable-stringify": "2.5.0", "setimmediate": "1.0.5", - "sigma": "3.0.2", "signia": "0.1.5", "signia-react": "0.1.5", "url-regex-safe": "4.0.0", "use-font-face-observer": "1.3.0", "uuid": "14.0.0", "web-worker": "1.4.1", + "webcola": "3.4.0", "zod": "4.4.3" }, "devDependencies": { @@ -170,7 +172,7 @@ "rimraf": "6.1.3", "sass": "1.93.2", "typescript": "5.9.3", - "vitest": "4.1.8", + "vitest": "4.1.9", "wait-on": "9.0.1", "webpack": "5.104.1" } diff --git a/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/outputs.tsx b/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/outputs.tsx index d93ab6d7469..a546e45e62c 100644 --- a/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/outputs.tsx +++ b/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/outputs.tsx @@ -648,6 +648,10 @@ export const Outputs = ({ persistedEntitiesTypesInfo?.entityTypes ?? proposedEntitiesTypesInfo?.entityTypes } + definitions={ + persistedEntitiesTypesInfo?.definitions ?? + proposedEntitiesTypesInfo?.definitions + } entities={entitiesForGraph} /> ))} diff --git a/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/outputs/entity-result-graph.tsx b/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/outputs/entity-result-graph.tsx index 8202ef93c8e..bae5900c455 100644 --- a/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/outputs/entity-result-graph.tsx +++ b/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/outputs/entity-result-graph.tsx @@ -2,8 +2,11 @@ import { useTheme } from "@mui/material"; import { useMemo } from "react"; import { LoadingSpinner } from "@hashintel/design-system"; +import { HashEntity } from "@local/hash-graph-sdk/entity"; -import { EntityGraphVisualizer } from "../../../../../shared/entity-graph-visualizer"; +import { useFlowRunsContext } from "../../../../../shared/flow-runs-context"; +import { useOwnedFrontierStore } from "../../../../../shared/graph-visualizer/entity-graph/use-frontier-expansion"; +import { EntityGraphVisualizer } from "../../../../../shared/graph-visualizer/entity-graph/visualizer"; import { useSlideStack } from "../../../../../shared/slide-stack"; import { EmptyOutputBox } from "./shared/empty-output-box"; import { outputIcons } from "./shared/icons"; @@ -11,18 +14,71 @@ import { OutputContainer } from "./shared/output-container"; import type { EntityId } from "@blockprotocol/type-system"; import type { EntityForGraphChart } from "@hashintel/block-design-system"; -import type { ClosedMultiEntityTypesRootMap } from "@local/hash-graph-sdk/ontology"; +import type { Entity as GraphApiEntity } from "@local/hash-graph-client"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; type EntityResultGraphProps = { closedMultiEntityTypesRootMap?: ClosedMultiEntityTypesRootMap; + definitions?: ClosedMultiEntityTypesDefinitions; entities: EntityForGraphChart[]; }; +/** + * Placeholder provenance/versioning for PROPOSED entities (not yet in the + * database, so they have none). Only shape-required by {@link HashEntity}; + * the graph reads identity, types, links, and properties. + */ +const PLACEHOLDER_TIMESTAMP = "1970-01-01T00:00:00Z"; +const PLACEHOLDER_ACTOR = "00000000-0000-0000-0000-000000000000"; + +const placeholderInterval = { + start: { kind: "inclusive" as const, limit: PLACEHOLDER_TIMESTAMP }, + end: { kind: "unbounded" as const }, +}; + +/** Persisted entities pass through; proposed ones get wrapped for the visualizer. */ +function toHashEntity(entity: EntityForGraphChart): HashEntity { + if (entity instanceof HashEntity) { + return entity; + } + + const plain: GraphApiEntity = { + metadata: { + archived: false, + entityTypeIds: [...entity.metadata.entityTypeIds], + provenance: { + createdById: PLACEHOLDER_ACTOR, + createdAtDecisionTime: PLACEHOLDER_TIMESTAMP, + createdAtTransactionTime: PLACEHOLDER_TIMESTAMP, + edition: { + createdById: PLACEHOLDER_ACTOR, + actorType: "machine", + origin: { type: "flow" }, + }, + }, + temporalVersioning: entity.metadata.temporalVersioning ?? { + decisionTime: placeholderInterval, + transactionTime: placeholderInterval, + }, + recordId: entity.metadata.recordId, + }, + properties: entity.properties, + linkData: entity.linkData, + }; + + return new HashEntity(plain); +} + export const EntityResultGraph = ({ closedMultiEntityTypesRootMap, + definitions, entities, }: EntityResultGraphProps) => { const { pushToSlideStack } = useSlideStack(); + const { selectedFlowRunId } = useFlowRunsContext(); /** * If a Flow updates the same entity as non-draft multiple times, it will have a record of persisting @@ -31,10 +87,8 @@ export const EntityResultGraph = ({ * will help to detect where update / deduplication logic can be improved in the inference process. */ const deduplicatedEntities = useMemo(() => { - const deduplicatedLatestEntitiesByEntityId: Record< - EntityId, - EntityForGraphChart - > = {}; + const deduplicatedLatestEntitiesByEntityId: Record = + {}; for (const entity of entities) { const entityId = entity.metadata.recordId.entityId; @@ -46,18 +100,31 @@ export const EntityResultGraph = ({ * If these are persisted entities, they will have temporal versions, and we can take the latest. * If they are proposed entities, they won't have temporal versioning (nor should they be duplicated) */ - (existing.metadata.temporalVersioning && - entity.metadata.temporalVersioning && + (entity.metadata.temporalVersioning && existing.metadata.temporalVersioning.decisionTime.start.limit < entity.metadata.temporalVersioning.decisionTime.start.limit) ) { - deduplicatedLatestEntitiesByEntityId[entityId] = entity; + deduplicatedLatestEntitiesByEntityId[entityId] = toHashEntity(entity); } } return Object.values(deduplicatedLatestEntitiesByEntityId); }, [entities]); + /** + * The worker's ingest is additive, so the data source's identity must + * change whenever the entity set is REPLACED rather than appended to: a + * different flow run, or the same run's graph flipping from proposed + * entities to persisted ones (different entity ids for the same results). + */ + const sourceKey = `${selectedFlowRunId ?? "no-run"}-${ + entities[0] instanceof HashEntity ? "persisted" : "proposed" + }`; + + // Flow results are a fixed set (no frontier), but the visualizer's + // expansion store is owned by the surface that owns the query. + const frontierStore = useOwnedFrontierStore(sourceKey); + const theme = useTheme(); if (!closedMultiEntityTypesRootMap && !entities.length) { @@ -76,7 +143,10 @@ export const EntityResultGraph = ({ {closedMultiEntityTypesRootMap && ( } @@ -86,12 +156,6 @@ export const EntityResultGraph = ({ itemId: entityId, }); }} - onEntityTypeClick={(entityTypeId) => { - pushToSlideStack({ - kind: "entityType", - itemId: entityTypeId, - }); - }} /> )} diff --git a/apps/hash-frontend/src/pages/_app.page.tsx b/apps/hash-frontend/src/pages/_app.page.tsx index d849418ebb8..6d5f8006c43 100644 --- a/apps/hash-frontend/src/pages/_app.page.tsx +++ b/apps/hash-frontend/src/pages/_app.page.tsx @@ -304,6 +304,7 @@ const AppWithTypeSystemContextProvider: AppPage = ( // The list of page pathnames that should be accessible whether or not the user is authenticated const publiclyAccessiblePagePathnames = [ "/[shortname]/[page-slug]", + ...(process.env.NODE_ENV === "development" ? ["/dev-graph-visualizer"] : []), "/signin", "/signup", "/verification", diff --git a/apps/hash-frontend/src/pages/dev-graph-visualizer.page.tsx b/apps/hash-frontend/src/pages/dev-graph-visualizer.page.tsx new file mode 100644 index 00000000000..f32059585cb --- /dev/null +++ b/apps/hash-frontend/src/pages/dev-graph-visualizer.page.tsx @@ -0,0 +1,22 @@ +import { Box } from "@mui/material"; + +import { DevHarness } from "./shared/graph-visualizer/dev-harness/harness"; + +import type { NextPageWithLayout } from "../shared/layout"; + +/** + * Dev-only route hosting the graph-visualizer dev harness full-viewport, with no app chrome + * (`getLayout` returns the page unchanged). Lets a developer iterate on + * {@link EntityGraphVisualizerV2} against synthetic data. + */ +const DevGraphVisualizerPage: NextPageWithLayout = () => { + return ( + + + + ); +}; + +DevGraphVisualizerPage.getLayout = (page) => page; + +export default DevGraphVisualizerPage; diff --git a/apps/hash-frontend/src/pages/entities.page.tsx b/apps/hash-frontend/src/pages/entities.page.tsx index 271b1e37a84..394e5295faf 100644 --- a/apps/hash-frontend/src/pages/entities.page.tsx +++ b/apps/hash-frontend/src/pages/entities.page.tsx @@ -346,6 +346,7 @@ const EntitiesPage: NextPageWithLayout = () => { entityTypeBaseUrl={entityTypeBaseUrl} entityTypeId={entityTypeId} hideColumns={entityTypeId ? ["entityTypes"] : []} + persistFilterStateInUrl /> diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx index d90152846c9..483d2b297ae 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx @@ -1,135 +1,67 @@ -import { Box, Stack, useTheme } from "@mui/material"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +/** + * Displays the entities matching a filterable query as a table, a grid of + * file previews, or an interactive graph. + * + * This component is a composition root: the moving parts live in + * `entities-visualizer/` as focused hooks. Query inputs (filters, sort, + * conversions, view) are owned by {@link useEntitiesQueryState}; the fetched + * result pages (including pagination) by {@link useEntitiesVisualizerData}; + * everything else is derived from those, so there is no state + * synchronization between them. + */ +import { Box, Stack, Typography, useTheme } from "@mui/material"; +import { useCallback, useEffect, useMemo, useState } from "react"; -import { extractBaseUrl, isBaseUrl } from "@blockprotocol/type-system"; +import { extractBaseUrl } from "@blockprotocol/type-system"; import { LoadingSpinner } from "@hashintel/design-system"; import { typedEntries } from "@local/advanced-types/typed-entries"; -import { - getClosedMultiEntityTypeFromMap, - type HashEntity, -} from "@local/hash-graph-sdk/entity"; import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; -import { useEntityTypesContextRequired } from "../../shared/entity-types-context/hooks/use-entity-types-context-required"; -import { HEADER_HEIGHT } from "../../shared/layout/layout-with-header/page-header"; +import { useSnackbar } from "../../components/hooks/use-snackbar"; import { tableContentSx } from "../../shared/table-content"; -import { BulkActionsDropdown } from "../../shared/table-header/bulk-actions-dropdown"; -import { useMemoCompare } from "../../shared/use-memo-compare"; -import { useAuthenticatedUser } from "./auth-info-context"; +import { Button } from "../../shared/ui/button"; import { EntitiesTable, toolbarHeight, } from "./entities-visualizer/entities-table"; +import { withExpansionRows } from "./entities-visualizer/expansion-table-data"; import { GridView } from "./entities-visualizer/grid-view"; import { - FilterRibbon, - QueryCount, - VisualizerHeader, - visualizerHeaderHeight, -} from "./entities-visualizer/header"; -import { createDefaultFilterState } from "./entities-visualizer/shared/filter-state"; + downloadFilterConfigFile, + parseFilterConfigFile, +} from "./entities-visualizer/shared/filter-config-file"; +import { + parseFilterStateFromQuery, + serializeFilterStateToQuery, + titleFromBaseUrl, +} from "./entities-visualizer/shared/filter-state-url"; import { useAvailableTypes } from "./entities-visualizer/shared/use-available-types"; +import { useEntitiesQueryState } from "./entities-visualizer/use-entities-query-state"; import { useEntitiesVisualizerData } from "./entities-visualizer/use-entities-visualizer-data"; -import { EntityGraphVisualizer } from "./entity-graph-visualizer"; -import { useSlideStack } from "./slide-stack"; -import { TableHeaderToggle } from "./table-header-toggle"; -import { TOP_CONTEXT_BAR_HEIGHT } from "./top-context-bar"; -import { visualizerViewIcons } from "./visualizer-views"; - -import type { ColumnSort } from "../../components/grid/utils/sorting"; -import type { - EntitiesTableRow, - SortableEntitiesTableColumnKey, -} from "./entities-visualizer/entities-table-data"; -import type { EntitiesFilterState } from "./entities-visualizer/shared/filter-state"; -import type { EntityEditorProps } from "./entity/entity-editor"; -import type { VisualizerView } from "./visualizer-views"; +import { useInternalWebs } from "./entities-visualizer/use-internal-webs"; +import { usePruneStaleFilters } from "./entities-visualizer/use-prune-stale-filters"; +import { useSlideStackHandlers } from "./entities-visualizer/use-slide-stack-handlers"; +import { useVisualizerHeights } from "./entities-visualizer/use-visualizer-heights"; +import { useVisualizerView } from "./entities-visualizer/use-visualizer-view"; +import { VisualizerToolbar } from "./entities-visualizer/visualizer-toolbar"; +import { useFrontierExpansionStore } from "./graph-visualizer/entity-graph/frontier-expansion-store"; +import { useOwnedFrontierStore } from "./graph-visualizer/entity-graph/use-frontier-expansion"; +import { EntityGraphVisualizer } from "./graph-visualizer/entity-graph/visualizer"; +import { useSlideStackOcclusion } from "./slide-stack"; + +import type { EntitiesTableRow } from "./entities-visualizer/entities-table-data"; import type { BaseUrl, - ClosedMultiEntityType, EntityId, VersionedUrl, - WebId, } from "@blockprotocol/type-system"; -import type { SizedGridColumn } from "@glideapps/glide-data-grid"; -import type { - EntityQueryCursor, - EntityQuerySortingPath, - EntityQuerySortingRecord, - EntityQuerySortingToken, - NullOrdering, - Ordering, -} from "@local/hash-graph-client"; -import type { Dispatch, FunctionComponent, SetStateAction } from "react"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { FunctionComponent } from "react"; -/** - * @todo: avoid having to maintain this list, potentially by - * adding an `isFile` boolean to the generated ontology IDs file. - */ -const allFileEntityTypeOntologyIds = [ - systemEntityTypes.file, - systemEntityTypes.imageFile, - systemEntityTypes.documentFile, - systemEntityTypes.docxDocument, - systemEntityTypes.pdfDocument, - systemEntityTypes.presentationFile, - systemEntityTypes.pptxPresentation, -]; - -const allFileEntityTypeIds = allFileEntityTypeOntologyIds.map( - ({ entityTypeId }) => entityTypeId, -) as VersionedUrl[]; - -const allFileEntityTypeBaseUrl = allFileEntityTypeOntologyIds.map( - ({ entityTypeBaseUrl }) => entityTypeBaseUrl, -); - -const generateGraphSort = ( - columnKey: SortableEntitiesTableColumnKey, - direction: "asc" | "desc", - convertTo?: BaseUrl, -): EntityQuerySortingRecord => { - const nulls: NullOrdering = direction === "asc" ? "last" : "first"; - const ordering: Ordering = direction === "asc" ? "ascending" : "descending"; - - let path: EntityQuerySortingPath; - - switch (columnKey) { - case "entityLabel": - path = ["label" satisfies EntityQuerySortingToken]; - break; - case "lastEdited": - path = [ - "editionCreatedAtTransactionTime" satisfies EntityQuerySortingToken, - ]; - break; - case "created": - path = ["createdAtTransactionTime" satisfies EntityQuerySortingToken]; - break; - case "entityTypes": - path = ["typeTitle" satisfies EntityQuerySortingToken]; - break; - case "archived": - path = ["archived" satisfies EntityQuerySortingToken]; - break; - default: { - if (!isBaseUrl(columnKey)) { - throw new Error(`Unexpected sorting column key: ${columnKey}`); - } - path = ["properties" satisfies EntityQuerySortingToken, columnKey]; +/** How many entities each request fetches; "Show more" appends another page of this size. */ +const entitiesPageSize = 500; - if (convertTo) { - path.push("convert", convertTo); - } - } - } - - return { - path, - nulls, - ordering, - }; -}; +const noSelectedRows: EntitiesTableRow[] = []; export const EntitiesVisualizer: FunctionComponent<{ /** @@ -144,209 +76,156 @@ export const EntitiesVisualizer: FunctionComponent<{ * Hide specific columns from the table */ hideColumns?: (keyof EntitiesTableRow)[]; -}> = ({ entityTypeBaseUrl, entityTypeId, hideColumns }) => { + /** + * Persist the active filter state in the URL query string (hydrating from it + * on mount), so that refreshing, bookmarking, or sharing the page preserves + * the filters. Defaults to `false` for embedded usages (e.g. an entity type's + * entities tab) that should not take over the URL. + */ + persistFilterStateInUrl?: boolean; +}> = ({ + entityTypeBaseUrl, + entityTypeId, + hideColumns, + persistFilterStateInUrl = false, +}) => { const theme = useTheme(); - const { authenticatedUser } = useAuthenticatedUser(); + const internalWebs = useInternalWebs(); - const { isSpecialEntityTypeLookup } = useEntityTypesContextRequired(); + const isTypePinned = !!entityTypeBaseUrl || !!entityTypeId; - const internalWebs = useMemoCompare( - () => { - return [ - { - webId: authenticatedUser.accountId as WebId, - name: `@${authenticatedUser.shortname}`, - }, - ...authenticatedUser.memberOf.map(({ org }) => ({ - webId: org.webId, - name: `@${org.shortname}`, - })), - ]; - }, - [authenticatedUser], - (oldValue, newValue) => { - return ( - oldValue.length === newValue.length && - oldValue.every((oldWeb) => - newValue.some( - (newWeb) => - oldWeb.webId === newWeb.webId && oldWeb.name === newWeb.name, - ), - ) - ); - }, - ); + const { + activeConversions, + conversionRequests, + entitySetKey, + filterState, + graphSort, + queryView, + selectedView, + setActiveConversions, + setFilterState, + setSelectedView, + setSort, + sort, + } = useEntitiesQueryState({ + entityTypeBaseUrl, + entityTypeId, + internalWebs, + isTypePinned, + persistFilterStateInUrl, + }); - const [filterState, _setFilterState] = useState(() => - createDefaultFilterState(internalWebs.map(({ webId }) => webId)), + const entityTypeIds = useMemo( + () => (entityTypeId ? [entityTypeId] : undefined), + [entityTypeId], ); - const [cursor, setCursor] = useState(); - const [activeConversionsWithoutTitle, _setActiveConversions] = useState<{ - [columnBaseUrl: BaseUrl]: VersionedUrl; - } | null>(null); + /** + * Frontier expansions from the Graph view ("OR n entities"), owned here, + * above the visualizer, so they persist across view switches and surface + * outside the graph (the filter pill, table rows). Dismissing the pill + * bumps the epoch: that swaps in a fresh store and recreates the graph + * worker (via `graphSourceKey` below), since the worker's additive ingest + * cannot retract the expanded entities. + */ + const [expansionEpoch, setExpansionEpoch] = useState(0); + const graphSourceKey = `${entitySetKey}::${expansionEpoch}`; + const frontierStore = useOwnedFrontierStore(graphSourceKey); + const frontierSnapshot = useFrontierExpansionStore(frontierStore); - const setActiveConversions = useCallback< - Dispatch< - SetStateAction<{ - [columnBaseUrl: BaseUrl]: VersionedUrl; - } | null> - > - >( - (newConversionsOrUpdater) => { - _setActiveConversions(newConversionsOrUpdater); - setCursor(undefined); - }, - [setCursor], - ); + const clearFrontierAdditions = useCallback(() => { + setExpansionEpoch((epoch) => epoch + 1); + }, []); - const setFilterState = useCallback( - ( - newFilterStateOrUpdater: - | EntitiesFilterState - | ((prev: EntitiesFilterState) => EntitiesFilterState), - ) => { - _setFilterState((prev) => - typeof newFilterStateOrUpdater === "function" - ? newFilterStateOrUpdater(prev) - : newFilterStateOrUpdater, - ); - setCursor(undefined); - }, - [setCursor], - ); + const { triggerSnackbar } = useSnackbar(); - const [view, _setView] = useState("Table"); + /** + * Expansion ids waiting to be replayed into the frontier store, set by a + * filter-config import. Import bumps the epoch (and possibly the filter), + * which swaps in a fresh store during the same render that sets this; the + * effect below then expands into that new generation, so the imported + * additions never mix with the pre-import ones. + */ + const [pendingExpansionIds, setPendingExpansionIds] = useState< + EntityId[] | null + >(null); - const setView = useCallback( - (newView: VisualizerView) => { - _setView(newView); - setCursor(undefined); - }, - [setCursor], - ); + useEffect(() => { + if (!pendingExpansionIds) { + return; + } + setPendingExpansionIds(null); - const [sort, _setSort] = useState< - ColumnSort & { convertTo?: BaseUrl } - >({ - columnKey: "entityLabel", - direction: "asc", - }); + if (pendingExpansionIds.length > 0) { + void frontierStore.expand(pendingExpansionIds); + } + }, [pendingExpansionIds, frontierStore]); - const setSort = useCallback( - ( - newSort: ColumnSort & { - convertTo?: BaseUrl; - }, - ) => { - _setSort(newSort); - setCursor(undefined); + /** + * Export/import of the whole filter configuration (filter state plus the + * graph-expansion ids) as a file. A file rather than the URL because the + * expansion ids are far too long for one (see + * `entities-visualizer/shared/filter-config-file.ts`); filter state alone + * still syncs to the URL as usual. + */ + const exportFilters = useCallback(() => { + downloadFilterConfigFile({ + filters: serializeFilterStateToQuery({ + filterState, + internalWebIds: internalWebs.map(({ webId }) => webId), + isTypePinned, + }), + expandedEntityIds: [...frontierSnapshot.expandedRoots], + }); + }, [filterState, internalWebs, isTypePinned, frontierSnapshot.expandedRoots]); + + const importFilters = useCallback( + (fileText: string) => { + const config = parseFilterConfigFile(fileText); + if (!config) { + triggerSnackbar.error( + "That file is not a HASH filter export (or is from a newer version).", + ); + return; + } + + setFilterState(() => + parseFilterStateFromQuery({ + query: config.filters, + internalWebIds: internalWebs.map(({ webId }) => webId), + isTypePinned, + }), + ); + + // Fresh store generation so the imported additions replace (rather + // than pile onto) whatever was expanded before the import. + setExpansionEpoch((epoch) => epoch + 1); + setPendingExpansionIds(config.expandedEntityIds); }, - [setCursor], + [internalWebs, isTypePinned, setFilterState, triggerSnackbar], ); - const graphSort = useMemo( - () => generateGraphSort(sort.columnKey, sort.direction, sort.convertTo), - [sort], - ); + /** + * Pause the graph simulation while a slide is open over this page: the + * slide may itself contain a visualizer, and only the one the user sees + * should spend CPU. + */ + const occluded = useSlideStackOcclusion(); const entitiesData = useEntitiesVisualizerData({ - conversions: activeConversionsWithoutTitle - ? typedEntries(activeConversionsWithoutTitle).map( - ([columnBaseUrl, dataTypeId]) => ({ - path: [columnBaseUrl], - dataTypeId, - }), - ) - : undefined, - cursor, + conversions: conversionRequests, entityTypeBaseUrl, - entityTypeIds: entityTypeId ? [entityTypeId] : undefined, + entityTypeIds, filterState, hideColumns, internalWebs, - limit: 500, + limit: entitiesPageSize, sort: graphSort, - view, + view: queryView, }); - const [dataLoading, setDataLoading] = useState(entitiesData.loading); - const [visualizerData, setVisualizerData] = useState(entitiesData); - - const { - cursor: nextCursor, - definitions, - entities, - closedMultiEntityTypes: closedMultiEntityTypesRootMap, - subgraph, - } = visualizerData; - - const closedMultiEntityTypes = useMemo(() => { - if (!entities || !definitions || !closedMultiEntityTypesRootMap) { - return []; - } - - const relevantEntityTypesMap = new Map(); - - for (const { metadata } of entities) { - const closedMultiEntityType = getClosedMultiEntityTypeFromMap( - closedMultiEntityTypesRootMap, - metadata.entityTypeIds, - ); - - const key = metadata.entityTypeIds.toSorted().join(","); - - relevantEntityTypesMap.set(key, closedMultiEntityType); - } - - const relevantTypes = Array.from(relevantEntityTypesMap.values()); - - return relevantTypes; - }, [entities, definitions, closedMultiEntityTypesRootMap]); - - const activeConversions = useMemo(() => { - return activeConversionsWithoutTitle - ? Object.fromEntries( - typedEntries(activeConversionsWithoutTitle).map( - ([columnBaseUrl, dataTypeId]) => { - const dataType = definitions?.dataTypes[dataTypeId]; - - if (!dataType) { - throw new Error( - `No data type found for column base URL: ${columnBaseUrl}`, - ); - } - - return [ - columnBaseUrl, - { - dataTypeId, - title: dataType.schema.title, - }, - ]; - }, - ), - ) - : null; - }, [activeConversionsWithoutTitle, definitions]); - - /** - * We don't want to clear the old table data when a new request is triggered, - * so we hold the visualizerData here rather than relying on the useEntitiesVisualizerData hook directly, - * as it will clear the data when a new request is triggered. - * - * An alternative would be to have an onComplete callback in the hook. - */ - useEffect(() => { - setDataLoading(entitiesData.loading); - - if (!entitiesData.loading) { - setVisualizerData(entitiesData); - } - }, [entitiesData]); - - const isTypePinned = !!entityTypeBaseUrl || !!entityTypeId; + const readyData = entitiesData.status === "ready" ? entitiesData : undefined; const { availableEntityTypes, @@ -356,263 +235,240 @@ export const EntitiesVisualizer: FunctionComponent<{ filterState, internalWebs, entityTypeBaseUrl, - entityTypeIds: entityTypeId ? [entityTypeId] : undefined, + entityTypeIds, }); - useEffect(() => { - if (availableTypesLoading) { - return; - } - - let nextSelectedTypeIds = filterState.type.selectedTypeIds; - - if (!isTypePinned && filterState.type.selectedTypeIds) { - const availableEntityTypeIds = new Set( - availableEntityTypes.map( - ({ entityTypeId: availableEntityTypeId }) => availableEntityTypeId, - ), - ); - - const retainedSelectedTypeIds = [ - ...filterState.type.selectedTypeIds, - ].filter((selectedTypeId) => availableEntityTypeIds.has(selectedTypeId)); - - if ( - retainedSelectedTypeIds.length !== filterState.type.selectedTypeIds.size - ) { - nextSelectedTypeIds = - retainedSelectedTypeIds.length === 0 - ? null - : new Set(retainedSelectedTypeIds); - } - } - - let nextPropertyFilters = filterState.propertyFilters; - if (filterState.propertyFilters.length) { - const filterablePropertyKindsByBaseUrl = new Map( - propertyFilterData - .filter((property) => property.filterable) - .map((property) => [property.baseUrl, property.kind]), - ); - - nextPropertyFilters = filterState.propertyFilters.filter( - ({ baseUrl, kind }) => - filterablePropertyKindsByBaseUrl.get(baseUrl) === kind, - ); - } - - const typeFilterChanged = - nextSelectedTypeIds !== filterState.type.selectedTypeIds; - const propertyFiltersChanged = - nextPropertyFilters.length !== filterState.propertyFilters.length; - - if (!typeFilterChanged && !propertyFiltersChanged) { - return; - } - - setFilterState((prev) => ({ - ...prev, - type: typeFilterChanged - ? { selectedTypeIds: nextSelectedTypeIds } - : prev.type, - propertyFilters: propertyFiltersChanged - ? nextPropertyFilters - : prev.propertyFilters, - })); - }, [ + usePruneStaleFilters({ availableEntityTypes, availableTypesLoading, - filterState.propertyFilters, - filterState.type.selectedTypeIds, + filterState, isTypePinned, propertyFilterData, setFilterState, - ]); + }); - const isDisplayingFilesOnly = useMemo( - () => - /** - * To allow the `Grid` view to come into view on first render where - * possible, we check whether `entityTypeId` or `entityTypeBaseUrl` - * matches a `File` entity type from a statically defined list. - */ - (entityTypeId && allFileEntityTypeIds.includes(entityTypeId)) || - (entityTypeBaseUrl && - allFileEntityTypeBaseUrl.includes(entityTypeBaseUrl)) || - /** - * Otherwise we check the fetched `entityTypes` as a fallback. - */ - (closedMultiEntityTypes.length && - closedMultiEntityTypes.every(({ allOf }) => - allOf.some(({ $id }) => isSpecialEntityTypeLookup?.[$id]?.isFile), - )), - [ - entityTypeBaseUrl, - entityTypeId, - closedMultiEntityTypes, - isSpecialEntityTypeLookup, - ], - ); + const { view, viewOptions } = useVisualizerView({ + closedMultiEntityTypesRootMap: readyData?.closedMultiEntityTypesRootMap, + definitions: readyData?.definitions, + entities: readyData?.entities, + entityTypeBaseUrl, + entityTypeId, + selectedView, + }); - const supportGridView = isDisplayingFilesOnly; + const { contentTopRef, availableHeight, tableHeight } = + useVisualizerHeights(); - useEffect(() => { - if (isDisplayingFilesOnly) { - setView("Grid"); - } else { - setView("Table"); - } - }, [isDisplayingFilesOnly, setView]); + const { handleEntityClick, handleEntityTypeClick, handleOpenLinkTable } = + useSlideStackHandlers(); - const isViewingOnlyPages = - entityTypeBaseUrl === systemEntityTypes.page.entityTypeBaseUrl || - entityTypeId === systemEntityTypes.page.entityTypeId; + /** + * Row selection, tied to the entity set it was made against: when the + * filters change, the rows the selection referred to are no longer the + * displayed set, so it is implicitly dropped (no reset effect needed). + * Sorting, unit conversions and pagination keep the same set, so the + * selection survives those. + */ + const [tableSelection, setTableSelection] = useState<{ + forEntitySetKey: string; + rows: EntitiesTableRow[]; + } | null>(null); - const { pushToSlideStack } = useSlideStack(); - - const handleEntityClick = useCallback( - ( - entityId: EntityId, - options?: Pick, - ) => { - pushToSlideStack({ - kind: "entity", - itemId: entityId, - defaultOutgoingLinkFilters: options?.defaultOutgoingLinkFilters, - }); - }, - [pushToSlideStack], - ); + const selectedTableRows = + tableSelection !== null && tableSelection.forEntitySetKey === entitySetKey + ? tableSelection.rows + : noSelectedRows; - const handleEntityTypeClick = useCallback( - ({ entityTypeId: itemId }: { entityTypeId: VersionedUrl }) => { - pushToSlideStack({ kind: "entityType", itemId }); + const setSelectedTableRows = useCallback( + (rows: EntitiesTableRow[]) => { + setTableSelection({ forEntitySetKey: entitySetKey, rows }); }, - [pushToSlideStack], + [entitySetKey], ); - const currentlyDisplayedColumnsRef = useRef(null); - const currentlyDisplayedRowsRef = useRef(null); + const entities = readyData?.entities; - const contentTopRef = useRef(null); - const [contentTop, setContentTop] = useState(null); + const { expandedById } = frontierSnapshot; - useEffect(() => { - const el = contentTopRef.current; - if (!el) { - return; + const selectedEntities = useMemo(() => { + if (view !== "Table" || selectedTableRows.length === 0) { + return []; } - const measure = () => { - setContentTop(el.getBoundingClientRect().top); - }; + const selectedEntityIds = new Set( + selectedTableRows.map(({ entityId }) => entityId), + ); - measure(); + const selectedById = new Map(); - const observer = new ResizeObserver(measure); - observer.observe(document.documentElement); - return () => observer.disconnect(); - }, []); + for (const entity of entities ?? []) { + if (selectedEntityIds.has(entity.metadata.recordId.entityId)) { + selectedById.set(entity.metadata.recordId.entityId, entity); + } + } - const availableHeight = `calc(100vh - ${ - contentTop != null - ? `${contentTop}px - ${theme.spacing(5)}` - : `(${ - HEADER_HEIGHT + TOP_CONTEXT_BAR_HEIGHT + 230 + visualizerHeaderHeight - }px + ${theme.spacing(5)} + ${theme.spacing(5)}` - })`; - - const tableHeight = `min(${availableHeight}, 1000px)`; - - const isPrimaryEntity = useCallback( - (entity: { metadata: Pick }) => - entityTypeBaseUrl - ? entity.metadata.entityTypeIds.some( - (typeId) => extractBaseUrl(typeId) === entityTypeBaseUrl, - ) - : entityTypeId - ? entity.metadata.entityTypeIds.includes(entityTypeId) - : false, - [entityTypeId, entityTypeBaseUrl], - ); + // Rows appended by graph expansion aren't in the query result; resolve + // them from the expansion state instead. + for (const entityId of selectedEntityIds) { + if (!selectedById.has(entityId)) { + const context = expandedById.get(entityId); + if (context) { + selectedById.set(entityId, context.entity); + } + } + } - const [showTableSearch, setShowTableSearch] = useState(false); + return [...selectedById.values()]; + }, [entities, expandedById, selectedTableRows, view]); - const [selectedTableRows, setSelectedTableRows] = useState< - EntitiesTableRow[] - >([]); + const refresh = readyData?.refresh; - const nextPage = useCallback(() => { - setCursor(nextCursor ?? undefined); - }, [nextCursor]); + const handleBulkActionCompleted = useCallback(() => { + refresh?.(); + setTableSelection(null); + }, [refresh]); - const selectedEntities = useMemo(() => { - if (view !== "Table" || selectedTableRows.length === 0 || !entities) { - return []; + const definitions = readyData?.definitions; + + /** + * The active conversions enriched with each target data type's display + * title. `definitions` comes from the last-loaded response, so a + * just-activated conversion's target may not be in the pool until the + * converted response lands; fall back to a title derived from the type's + * URL slug for that window. + */ + const activeConversionsWithTitles = useMemo(() => { + if (!activeConversions) { + return null; } - const selectedEntityIds = new Set( - selectedTableRows.map(({ entityId }) => entityId), + return Object.fromEntries( + typedEntries(activeConversions).map(([columnBaseUrl, dataTypeId]) => [ + columnBaseUrl, + { + dataTypeId, + title: + definitions?.dataTypes[dataTypeId]?.schema.title ?? + titleFromBaseUrl(extractBaseUrl(dataTypeId)), + }, + ]), ); + }, [activeConversions, definitions]); - return entities.filter((entity) => - selectedEntityIds.has(entity.metadata.recordId.entityId), - ); - }, [entities, selectedTableRows, view]); + const isViewingOnlyPages = + entityTypeBaseUrl === systemEntityTypes.page.entityTypeBaseUrl || + entityTypeId === systemEntityTypes.page.entityTypeId; - const handleBulkActionCompleted = useCallback(() => { - void entitiesData.refetch(); - setSelectedTableRows([]); - }, [entitiesData]); + const queryTableData = readyData?.tableData ?? null; - const showLoading = !subgraph || !closedMultiEntityTypesRootMap; + /** + * The table shows the query's rows plus the graph-expansion additions + * (each generated against the type maps its expansion arrived with). + * Identity-stable when there are no additions. + */ + const tableDataWithAdditions = useMemo(() => { + if (!queryTableData) { + return null; + } - const { totalResultCount } = visualizerData; + return withExpansionRows(queryTableData, frontierSnapshot.records, { + hideColumns, + hideArchivedColumn: !filterState.includeArchived, + }); + }, [ + queryTableData, + frontierSnapshot.records, + hideColumns, + filterState.includeArchived, + ]); + + /** + * How many entities the graph expansions add beyond the query's own + * results, measured as the extra table rows so it can never disagree with + * what is displayed (an expanded entity a later "Show more" page fetched + * as a query root no longer counts). Drives the "OR n entities" pill and + * is folded into both sides of the "m of n entities" count, so the header + * describes the whole displayed set: query matches OR these additions. + */ + const frontierAdditionsCount = + tableDataWithAdditions && queryTableData + ? tableDataWithAdditions.rows.length - queryTableData.rows.length + : 0; + + // A stable element, so passing it to the memoized graph visualizer doesn't + // re-render it every time this component renders. + const loadingSpinner = useMemo( + () => , + [theme], + ); return ( - 0 ? ( - - ) : ( - setFilterState(updater)} - /> - ) + - - ({ - icon: visualizerViewIcons[optionValue], - label: `${optionValue} view`, - value: optionValue, - }))} - /> - + onBulkActionCompleted={handleBulkActionCompleted} + onClearFrontierAdditions={clearFrontierAdditions} + onExportFilters={exportFilters} + onImportFilters={importFilters} + propertyFilterData={propertyFilterData} + selectedEntities={selectedEntities} + setFilterState={setFilterState} + setView={setSelectedView} + totalResultCount={ + entitiesData.totalResultCount !== null + ? entitiesData.totalResultCount + frontierAdditionsCount + : null } + totalResultCountLoading={entitiesData.fetching} + view={view} + viewOptions={viewOptions} /> - {showLoading ? ( + {entitiesData.status === "error" ? ( + + palette.red[90], fontWeight: 600 }} + > + Could not load entities + + palette.gray[70], + maxWidth: 560, + mt: 0.75, + textAlign: "center", + }} + > + {entitiesData.error.message} + + + + ) : !readyData ? ( - - - + {loadingSpinner} ) : view === "Graph" ? ( + entities={readyData.entities} + rootEntityIds={readyData.rootEntityIds} + sourceKey={graphSourceKey} + occluded={occluded} + frontierStore={frontierStore} + closedMultiEntityTypesRootMap={ + readyData.closedMultiEntityTypesRootMap } - isPrimaryEntity={isPrimaryEntity} + definitions={readyData.definitions} + loadingComponent={loadingSpinner} onEntityClick={handleEntityClick} + onOpenLinkTable={handleOpenLinkTable} /> ) : view === "Grid" ? ( - + ) : ( )} diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table-data.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table-data.ts index 676dbf30a59..9fddfa8d221 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table-data.ts +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table-data.ts @@ -1,4 +1,3 @@ -import type { EntitiesVisualizerData } from "./use-entities-visualizer-data"; import type { ActorEntityUuid, BaseUrl, @@ -127,13 +126,3 @@ export type EntitiesTableData = { rows: EntitiesTableRow[]; visibleDataTypeIdsByPropertyBaseUrl: VisibleDataTypeIdsByPropertyBaseUrl; }; - -export type UpdateTableDataFn = ( - params: Pick< - EntitiesVisualizerData, - "definitions" | "entities" | "subgraph" - > & { - appendRows: boolean; - closedMultiEntityTypesRootMap: ClosedMultiEntityTypesRootMap; - }, -) => void; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table.tsx index 2c0d8c27d1e..1fe2185b824 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table.tsx @@ -3,7 +3,7 @@ import { GridCellKind } from "@glideapps/glide-data-grid"; import { Box, Stack, useTheme } from "@mui/material"; import * as Sentry from "@sentry/nextjs"; import { useRouter } from "next/router"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { extractBaseUrl, @@ -55,7 +55,6 @@ import type { EntitiesTableRow, SortableEntitiesTableColumnKey, } from "./entities-table-data"; -import type { EntitiesVisualizerData } from "./use-entities-visualizer-data"; import type { ActorEntityUuid, BaseUrl, @@ -70,13 +69,7 @@ import type { SizedGridColumn, TextCell, } from "@glideapps/glide-data-grid"; -import type { - Dispatch, - FunctionComponent, - MutableRefObject, - RefObject, - SetStateAction, -} from "react"; +import type { Dispatch, SetStateAction } from "react"; export { toolbarHeight } from "./entities-table/table-toolbar"; @@ -90,841 +83,865 @@ const emptyTableData: EntitiesTableData = { visibleDataTypeIdsByPropertyBaseUrl: {}, }; -export const EntitiesTable: FunctionComponent< - Pick & { - activeConversions: { - [columnBaseUrl: BaseUrl]: { - dataTypeId: VersionedUrl; - title: string; - }; - } | null; - csvFileTitle: string; - currentlyDisplayedColumnsRef: MutableRefObject; - currentlyDisplayedRowsRef: RefObject; - disableTypeClick?: boolean; - handleEntityClick: (entityId: EntityId) => void; - loading: boolean; - isViewingOnlyPages: boolean; - maxHeight: string | number; - hasMoreRowsAvailable: boolean; - loadMoreRows?: () => void; - selectedRows: EntitiesTableRow[]; - setActiveConversions: Dispatch< - SetStateAction<{ - [columnBaseUrl: BaseUrl]: VersionedUrl; - } | null> - >; - setSelectedRows: (rows: EntitiesTableRow[]) => void; - setSelectedEntityType: (params: { entityTypeId: VersionedUrl }) => void; - setShowSearch: (showSearch: boolean) => void; - showSearch: boolean; - sort: GridSort; - setSort: ( - sort: GridSort & { - convertTo?: BaseUrl; - }, - ) => void; - tableData: EntitiesTableData | null; - totalResultCount: number | null; - } -> = ({ - activeConversions, - csvFileTitle, - currentlyDisplayedColumnsRef, - currentlyDisplayedRowsRef, - disableTypeClick, - handleEntityClick, - loading: entityDataLoading, - isViewingOnlyPages, - maxHeight, - hasMoreRowsAvailable, - loadMoreRows, - selectedRows, - setActiveConversions, - setSelectedRows, - showSearch, - setShowSearch, - setSelectedEntityType, - setSort, - sort, - tableData, - totalResultCount, -}) => { - const router = useRouter(); - - const getOwnerForEntity = useGetOwnerForEntity(); - - const { - columns, - dataTypeDefinitions, - entityTypesWithMultipleVersionsPresent, - rows, - visibleDataTypeIdsByPropertyBaseUrl, - } = tableData ?? emptyTableData; - - const editorActorIds = useMemo(() => { - const editorIds = new Set(); - for (const row of rows) { - editorIds.add(row.lastEditedById); - editorIds.add(row.createdById); - } - return [...editorIds]; - }, [rows]); - - const { actors } = useActors({ - accountIds: editorActorIds, - }); - - const actorsByAccountId: Record = - useMemo(() => { - if (!actors) { - return {}; +interface EntitiesTableProps { + readonly activeConversions: { + [columnBaseUrl: BaseUrl]: { + dataTypeId: VersionedUrl; + title: string; + }; + } | null; + readonly csvFileTitle: string; + readonly disableTypeClick?: boolean; + readonly handleEntityClick: (entityId: EntityId) => void; + readonly loading: boolean; + readonly isViewingOnlyPages: boolean; + readonly maxHeight: string | number; + readonly hasMoreRowsAvailable: boolean; + readonly loadMoreRows?: () => void; + readonly selectedRows: EntitiesTableRow[]; + readonly setActiveConversions: Dispatch< + SetStateAction<{ + [columnBaseUrl: BaseUrl]: VersionedUrl; + } | null> + >; + readonly setSelectedRows: (rows: EntitiesTableRow[]) => void; + readonly setSelectedEntityType: (params: { + entityTypeId: VersionedUrl; + }) => void; + readonly sort: GridSort; + readonly setSort: ( + sort: GridSort & { + convertTo?: BaseUrl; + }, + ) => void; + readonly tableData: EntitiesTableData | null; + readonly totalResultCount: number | null; + /** + * How many rows came from the paginated query itself, when `tableData` + * also carries appended rows from another source (graph-expansion + * additions). Drives the "N remaining" count next to "Show more", which + * compares the query's progress against `totalResultCount`; appended + * rows are outside that total. + * + * @defaultValue all rows, i.e. every row is the query's. + */ + readonly queryRowCount?: number; +} + +export const EntitiesTable: React.FC = memo( + ({ + activeConversions, + csvFileTitle, + disableTypeClick, + handleEntityClick, + loading: entityDataLoading, + isViewingOnlyPages, + maxHeight, + hasMoreRowsAvailable, + loadMoreRows, + selectedRows, + setActiveConversions, + setSelectedRows, + setSelectedEntityType, + setSort, + sort, + tableData, + totalResultCount, + queryRowCount, + }) => { + const router = useRouter(); + + const getOwnerForEntity = useGetOwnerForEntity(); + + const [showSearch, setShowSearch] = useState(false); + + /** + * The columns/rows currently rendered by the grid (after its internal + * sort/filter), captured so the CSV export matches what's on screen. + */ + const currentlyDisplayedColumnsRef = useRef(null); + const currentlyDisplayedRowsRef = useRef(null); + + const { + columns, + dataTypeDefinitions, + entityTypesWithMultipleVersionsPresent, + rows, + visibleDataTypeIdsByPropertyBaseUrl, + } = tableData ?? emptyTableData; + + const editorActorIds = useMemo(() => { + const editorIds = new Set(); + for (const row of rows) { + editorIds.add(row.lastEditedById); + editorIds.add(row.createdById); } + return [...editorIds]; + }, [rows]); - const actorsByAccount: Record = {}; + const { actors } = useActors({ + accountIds: editorActorIds, + }); - for (const actor of actors) { - actorsByAccount[actor.accountId] = actor; - } + const actorsByAccountId: Record = + useMemo(() => { + if (!actors) { + return {}; + } - return actorsByAccount; - }, [actors]); + const actorsByAccount: Record = + {}; - const webNameByWebId = useMemo(() => { - if (!rows.length) { - return {}; - } + for (const actor of actors) { + actorsByAccount[actor.accountId] = actor; + } - const webNameByOwner: Record = {}; + return actorsByAccount; + }, [actors]); - const webIds = rows.map((row) => row.webId); - for (const webId of webIds) { - const owner = getOwnerForEntity({ webId }); - webNameByOwner[webId] = owner.shortname; - } + const webNameByWebId = useMemo(() => { + if (!rows.length) { + return {}; + } - return webNameByOwner; - }, [getOwnerForEntity, rows]); + const webNameByOwner: Record = {}; - const visibleDataTypeIds = useMemoCompare( - () => { - return Array.from( - new Set( - Object.values(visibleDataTypeIdsByPropertyBaseUrl).flatMap((types) => - [...types].map((type) => type.schema.$id), - ), - ), - ); - }, - [visibleDataTypeIdsByPropertyBaseUrl], - (oldValue, newValue) => { - const oldSet = new Set(oldValue); - const newSet = new Set(newValue); - return oldSet.size === newSet.size && oldSet.isSubsetOf(newSet); - }, - ); + const webIds = rows.map((row) => row.webId); + for (const webId of webIds) { + const owner = getOwnerForEntity({ webId }); + webNameByOwner[webId] = owner.shortname; + } - /** - * Although this is derived from the query data return, we don't want to do it in a useMemo because the data becomes undefined temporarily. - * useQuery has a `previousData` property which we could fall back to, but there's a brief moment where going from a converted column - * to a non-converted column will mean the conversion targets are out of sync with the entity data. - * We rely on knowing that a column has conversion targets in order to show the conversion button, and don't want it to flicker on and off. - * - * @todo H-3939 we can simplify a lot of this logic when the Graph API doesn't error if not all rows can be converted to a desired target. - */ - const [conversionTargetsByColumnKey, setConversionTargetsByColumnKey] = - useState({}); - - useQuery< - FindDataTypeConversionTargetsQuery, - FindDataTypeConversionTargetsQueryVariables - >(findDataTypeConversionTargetsQuery, { - fetchPolicy: "cache-first", - variables: { - dataTypeIds: visibleDataTypeIds, - }, - skip: visibleDataTypeIds.length === 0, - onCompleted: (data) => { - const conversionMap = data.findDataTypeConversionTargets; - - const conversionData: ConversionTargetsByColumnKey = {}; - - /** - * For each property, we need to find the conversion targets which are valid across all of the possible data types. - * - * A conversion target which isn't present for one of the dataTypeIds cannot be included. - */ - for (const [propertyBaseUrl, [...dataTypes]] of typedEntries( - visibleDataTypeIdsByPropertyBaseUrl, - )) { - const targetsByTargetTypeId: Record< - VersionedUrl, - { - title: string; - dataTypeId: VersionedUrl; - guessedAsCanonical?: boolean; - }[] - > = {}; - - for (const [index, sourceDataType] of dataTypes.entries()) { - const sourceDataTypeId = sourceDataType.schema.$id; - - const conversionsByTargetId = conversionMap[sourceDataTypeId]; - - if (!conversionsByTargetId) { - /** - * We don't have any conversion targets for this dataTypeId, so there can't be any shared conversion targets across all of the data types. - */ - continue; - } + return webNameByOwner; + }, [getOwnerForEntity, rows]); - for (const [targetTypeId, { title, conversions }] of typedEntries( - conversionsByTargetId, - )) { - if (index === 0) { - targetsByTargetTypeId[targetTypeId] ??= []; - targetsByTargetTypeId[targetTypeId].push({ - dataTypeId: targetTypeId, - title, - guessedAsCanonical: conversions.length === 1, - }); - } else if ( - !targetsByTargetTypeId[targetTypeId] && - !dataTypes.some( - (dataType) => dataType.schema.$id === targetTypeId, - ) - ) { + const visibleDataTypeIds = useMemoCompare( + () => { + return Array.from( + new Set( + Object.values(visibleDataTypeIdsByPropertyBaseUrl).flatMap( + (types) => [...types].map((type) => type.schema.$id), + ), + ), + ); + }, + [visibleDataTypeIdsByPropertyBaseUrl], + (oldValue, newValue) => { + const oldSet = new Set(oldValue); + const newSet = new Set(newValue); + return oldSet.size === newSet.size && oldSet.isSubsetOf(newSet); + }, + ); + + /** + * Although this is derived from the query data return, we don't want to do it in a useMemo because the data becomes undefined temporarily. + * useQuery has a `previousData` property which we could fall back to, but there's a brief moment where going from a converted column + * to a non-converted column will mean the conversion targets are out of sync with the entity data. + * We rely on knowing that a column has conversion targets in order to show the conversion button, and don't want it to flicker on and off. + * + * @todo H-3939 we can simplify a lot of this logic when the Graph API doesn't error if not all rows can be converted to a desired target. + */ + const [conversionTargetsByColumnKey, setConversionTargetsByColumnKey] = + useState({}); + + useQuery< + FindDataTypeConversionTargetsQuery, + FindDataTypeConversionTargetsQueryVariables + >(findDataTypeConversionTargetsQuery, { + fetchPolicy: "cache-first", + variables: { + dataTypeIds: visibleDataTypeIds, + }, + skip: visibleDataTypeIds.length === 0, + onCompleted: (data) => { + const conversionMap = data.findDataTypeConversionTargets; + + const conversionData: ConversionTargetsByColumnKey = {}; + + /** + * For each property, we need to find the conversion targets which are valid across all of the possible data types. + * + * A conversion target which isn't present for one of the dataTypeIds cannot be included. + */ + for (const [propertyBaseUrl, [...dataTypes]] of typedEntries( + visibleDataTypeIdsByPropertyBaseUrl, + )) { + const targetsByTargetTypeId: Record< + VersionedUrl, + { + title: string; + dataTypeId: VersionedUrl; + guessedAsCanonical?: boolean; + }[] + > = {}; + + for (const [index, sourceDataType] of dataTypes.entries()) { + const sourceDataTypeId = sourceDataType.schema.$id; + + const conversionsByTargetId = conversionMap[sourceDataTypeId]; + + if (!conversionsByTargetId) { /** - * If we haven't seen this target before, and we already have some targets, it is not a shared target. - * If the target is in the source dataTypeIds, we retain it because we assume conversion is reciprocal. - * This may not always hold. + * We don't have any conversion targets for this dataTypeId, so there can't be any shared conversion targets across all of the data types. */ continue; } - } - /** - * Any target which is present from previous sources but not for this source is not a shared target. - * We exempt this source dataTypeId from deletion because we assume conversion is reciprocal. - * This may not always hold. - */ - for (const existingTarget of typedKeys(targetsByTargetTypeId)) { - if ( - !typedKeys(conversionsByTargetId).includes(existingTarget) && - existingTarget !== sourceDataTypeId - ) { - delete targetsByTargetTypeId[existingTarget]; + for (const [targetTypeId, { title, conversions }] of typedEntries( + conversionsByTargetId, + )) { + if (index === 0) { + targetsByTargetTypeId[targetTypeId] ??= []; + targetsByTargetTypeId[targetTypeId].push({ + dataTypeId: targetTypeId, + title, + guessedAsCanonical: conversions.length === 1, + }); + } else if ( + !targetsByTargetTypeId[targetTypeId] && + !dataTypes.some( + (dataType) => dataType.schema.$id === targetTypeId, + ) + ) { + /** + * If we haven't seen this target before, and we already have some targets, it is not a shared target. + * If the target is in the source dataTypeIds, we retain it because we assume conversion is reciprocal. + * This may not always hold. + */ + continue; + } } - } - } - conversionData[propertyBaseUrl] = Object.values( - targetsByTargetTypeId, - ).flat(); - setConversionTargetsByColumnKey(conversionData); - } - }, - }); - - // eslint-disable-next-line no-param-reassign - currentlyDisplayedColumnsRef.current = columns; - - const theme = useTheme(); - - const createGetCellContent = useCallback( - (entityRows: EntitiesTableRow[]) => - ([colIndex, rowIndex]: Item): - | TextIconCell - | TextCell - | NumberCell - | BlankCell - | CustomCell => { - const columnId = columns[colIndex]?.id; - - const blankCell: TextCell = { - kind: GridCellKind.Text, - allowOverlay: false, - readonly: true, - displayData: "", - data: "", - }; - - if (columnId) { - const row = entityRows[rowIndex]; - - if (!row) { /** - * This can occur when `createGetCellContent` is called - * for a row that has just been filtered out, so we handle - * this by briefly not displaying anything in the cell. + * Any target which is present from previous sources but not for this source is not a shared target. + * We exempt this source dataTypeId from deletion because we assume conversion is reciprocal. + * This may not always hold. */ - return blankCell; + for (const existingTarget of typedKeys(targetsByTargetTypeId)) { + if ( + !typedKeys(conversionsByTargetId).includes(existingTarget) && + existingTarget !== sourceDataTypeId + ) { + delete targetsByTargetTypeId[existingTarget]; + } + } } + conversionData[propertyBaseUrl] = Object.values( + targetsByTargetTypeId, + ).flat(); + } + + setConversionTargetsByColumnKey(conversionData); + }, + }); + + currentlyDisplayedColumnsRef.current = columns; + + const theme = useTheme(); + + const createGetCellContent = useCallback( + (entityRows: EntitiesTableRow[]) => + ([colIndex, rowIndex]: Item): + | TextIconCell + | TextCell + | NumberCell + | BlankCell + | CustomCell => { + const columnId = columns[colIndex]?.id; + + const blankCell: TextCell = { + kind: GridCellKind.Text, + allowOverlay: false, + readonly: true, + displayData: "", + data: "", + }; - if (isBaseUrl(columnId)) { - const propertyCell = columnId && row[columnId]; + if (columnId) { + const row = entityRows[rowIndex]; - if (propertyCell) { - const { isArray, value, propertyMetadata } = propertyCell; + if (!row) { + /** + * This can occur when `createGetCellContent` is called + * for a row that has just been filtered out, so we handle + * this by briefly not displaying anything in the cell. + */ + return blankCell; + } + + if (isBaseUrl(columnId)) { + const propertyCell = columnId && row[columnId]; - let isUrl = false; - try { - const url = new URL(value as string); - if (url.protocol === "http:" || url.protocol === "https:") { - isUrl = true; + if (propertyCell) { + const { isArray, value, propertyMetadata } = propertyCell; + + let isUrl = false; + try { + const url = new URL(value as string); + if (url.protocol === "http:" || url.protocol === "https:") { + isUrl = true; + } + } catch { + // not a URL + } + + if (isUrl) { + return { + kind: GridCellKind.Custom, + data: { + kind: "url-cell", + url: value as string, + } satisfies UrlCellProps, + copyData: stringifyPropertyValue(value), + allowOverlay: false, + readonly: true, + }; + } + + /** + * Belt-and-braces against `formatValue` throwing: if any data + * type the value depends on is missing from the pool, render the + * fallback rather than crashing the whole grid. With the pool now + * bundled into `tableData` this should not happen in normal flow, + * but it still guards against a genuinely inconsistent response. + */ + const unresolvedDataTypeId = getReferencedDataTypeIds( + propertyMetadata, + ).find((dataTypeId) => !dataTypeDefinitions[dataTypeId]); + + if (unresolvedDataTypeId) { + Sentry.captureException( + new Error( + `Data type not found for ${unresolvedDataTypeId} when rendering value`, + ), + ); + return blankCell; } - } catch { - // not a URL - } - if (isUrl) { return { kind: GridCellKind.Custom, - data: { - kind: "url-cell", - url: value as string, - } satisfies UrlCellProps, - copyData: stringifyPropertyValue(value), - allowOverlay: false, + allowOverlay: true, readonly: true, + copyData: stringifyPropertyValue(value), + data: { + kind: "entities-table-value-cell", + isArray, + value, + propertyMetadata, + dataTypeDefinitions, + } satisfies EntitiesTableValueCellProps, }; } - /** - * Belt-and-braces against `formatValue` throwing: if any data - * type the value depends on is missing from the pool, render the - * fallback rather than crashing the whole grid. With the pool now - * bundled into `tableData` this should not happen in normal flow, - * but it still guards against a genuinely inconsistent response. - */ - const unresolvedDataTypeId = getReferencedDataTypeIds( - propertyMetadata, - ).find((dataTypeId) => !dataTypeDefinitions[dataTypeId]); - - if (unresolvedDataTypeId) { - Sentry.captureException( - new Error( - `Data type not found for ${unresolvedDataTypeId} when rendering value`, - ), - ); - return blankCell; - } + const appliesToEntity = + row.applicableProperties.includes(columnId); + + const data = appliesToEntity ? "–" : "Does not apply"; return { - kind: GridCellKind.Custom, - allowOverlay: true, + kind: GridCellKind.Text, + allowOverlay: false, readonly: true, - copyData: stringifyPropertyValue(value), - data: { - kind: "entities-table-value-cell", - isArray, - value, - propertyMetadata, - dataTypeDefinitions, - } satisfies EntitiesTableValueCellProps, + displayData: data, + data, + themeOverride: appliesToEntity + ? { + textDark: theme.palette.gray[50], + } + : { + bgCell: theme.palette.gray[5], + textDark: theme.palette.gray[50], + }, }; } - const appliesToEntity = row.applicableProperties.includes(columnId); - - const data = appliesToEntity ? "–" : "Does not apply"; + if (columnId === "entityLabel") { + return { + kind: GridCellKind.Custom, + allowOverlay: false, + readonly: true, + copyData: row.entityLabel, + cursor: "pointer", + data: { + kind: "chip-cell", + chips: [ + { + text: row.entityLabel, + icon: row.entityIcon + ? { entityTypeIcon: row.entityIcon } + : { + inbuiltIcon: row.sourceEntity + ? "bpLink" + : "bpAsterisk", + }, + iconFill: theme.palette.gray[50], + onClick: () => { + if (isViewingOnlyPages) { + void router.push( + `/${row.webId}/${extractEntityUuidFromEntityId( + row.entityId, + )}`, + ); + } else { + handleEntityClick(row.entityId); + } + }, + }, + ], + color: "white", + variant: "outlined", + }, + }; + } else if (columnId === "entityTypes") { + return { + kind: GridCellKind.Custom, + allowOverlay: false, + readonly: true, + copyData: row.entityTypes.map((type) => type.title).join(", "), + cursor: disableTypeClick ? "default" : "pointer", + data: { + kind: "chip-cell", + chips: row.entityTypes.map((value) => ({ + text: value.title, + icon: value.icon + ? { entityTypeIcon: value.icon } + : { inbuiltIcon: value.isLink ? "bpLink" : "bpAsterisk" }, + iconFill: theme.palette.blue[70], + suffix: entityTypesWithMultipleVersionsPresent.has( + value.entityTypeId, + ) + ? `v${value.version.toString()}` + : undefined, + onClick: disableTypeClick + ? undefined + : () => { + setSelectedEntityType({ + entityTypeId: value.entityTypeId, + }); + }, + })), + color: "white", + variant: "outlined", + } satisfies ChipCellProps, + }; + } else if (columnId === "webId") { + const shortname = webNameByWebId[row.webId]; - return { - kind: GridCellKind.Text, - allowOverlay: false, - readonly: true, - displayData: data, - data, - themeOverride: appliesToEntity - ? { - textDark: theme.palette.gray[50], - } - : { + return { + kind: GridCellKind.Custom, + allowOverlay: false, + readonly: true, + cursor: "pointer", + copyData: shortname ?? "", + data: { + kind: "text-icon-cell", + icon: null, + value: `@${shortname}`, + onClick: shortname + ? () => { + void router.push(`/@${shortname}`); + } + : undefined, + }, + }; + } else if ( + columnId === "sourceEntity" || + columnId === "targetEntity" + ) { + const entity = row[columnId] as EntitiesTableRow["sourceEntity"]; + if (!entity) { + const data = "Does not apply"; + return { + kind: GridCellKind.Text, + allowOverlay: true, + readonly: true, + displayData: data, + data, + themeOverride: { bgCell: theme.palette.gray[5], textDark: theme.palette.gray[50], }, - }; - } - - if (columnId === "entityLabel") { - return { - kind: GridCellKind.Custom, - allowOverlay: false, - readonly: true, - copyData: row.entityLabel, - cursor: "pointer", - data: { - kind: "chip-cell", - chips: [ - { - text: row.entityLabel, - icon: row.entityIcon - ? { entityTypeIcon: row.entityIcon } - : { - inbuiltIcon: row.sourceEntity - ? "bpLink" - : "bpAsterisk", - }, - iconFill: theme.palette.gray[50], - onClick: () => { - if (isViewingOnlyPages) { - void router.push( - `/${row.webId}/${extractEntityUuidFromEntityId( - row.entityId, - )}`, - ); - } else { - handleEntityClick(row.entityId); - } - }, - }, - ], - color: "white", - variant: "outlined", - }, - }; - } else if (columnId === "entityTypes") { - return { - kind: GridCellKind.Custom, - allowOverlay: false, - readonly: true, - copyData: row.entityTypes.map((type) => type.title).join(", "), - cursor: disableTypeClick ? "default" : "pointer", - data: { - kind: "chip-cell", - chips: row.entityTypes.map((value) => ({ - text: value.title, - icon: value.icon - ? { entityTypeIcon: value.icon } - : { inbuiltIcon: value.isLink ? "bpLink" : "bpAsterisk" }, - iconFill: theme.palette.blue[70], - suffix: entityTypesWithMultipleVersionsPresent.has( - value.entityTypeId, - ) - ? `v${value.version.toString()}` - : undefined, - onClick: disableTypeClick - ? undefined - : () => { - setSelectedEntityType({ - entityTypeId: value.entityTypeId, - }); - }, - })), - color: "white", - variant: "outlined", - } satisfies ChipCellProps, - }; - } else if (columnId === "webId") { - const shortname = webNameByWebId[row.webId]; + }; + } - return { - kind: GridCellKind.Custom, - allowOverlay: false, - readonly: true, - cursor: "pointer", - copyData: shortname ?? "", - data: { - kind: "text-icon-cell", - icon: null, - value: `@${shortname}`, - onClick: shortname - ? () => { - void router.push(`/@${shortname}`); - } - : undefined, - }, - }; - } else if ( - columnId === "sourceEntity" || - columnId === "targetEntity" - ) { - const entity = row[columnId] as EntitiesTableRow["sourceEntity"]; - if (!entity) { - const data = "Does not apply"; return { - kind: GridCellKind.Text, - allowOverlay: true, + kind: GridCellKind.Custom, + allowOverlay: false, readonly: true, - displayData: data, - data, - themeOverride: { - bgCell: theme.palette.gray[5], - textDark: theme.palette.gray[50], + copyData: entity.label, + cursor: "pointer", + data: { + kind: "chip-cell", + chips: [ + { + icon: entity.icon + ? { entityTypeIcon: entity.icon } + : { + inbuiltIcon: entity.isLink + ? "bpLink" + : "bpAsterisk", + }, + iconFill: theme.palette.gray[50], + text: entity.label, + onClick: () => { + handleEntityClick(entity.entityId); + }, + }, + ], + color: "white", + variant: "outlined", }, }; } + if (columnId === "archived") { + const value = row.archived ? "Yes" : "No"; + return { + kind: GridCellKind.Text, + readonly: true, + allowOverlay: false, + displayData: String(value), + data: value, + }; + } else if (columnId === "lastEdited") { + return { + kind: GridCellKind.Text, + readonly: true, + allowOverlay: false, + displayData: String(row.lastEdited), + data: row.lastEdited, + }; + } else if (columnId === "created") { + return { + kind: GridCellKind.Text, + readonly: true, + allowOverlay: false, + displayData: String(row.created), + data: row.lastEdited, + }; + } else { + const actorId = + columnId === "lastEditedById" + ? row.lastEditedById + : row.createdById; - return { - kind: GridCellKind.Custom, - allowOverlay: false, - readonly: true, - copyData: entity.label, - cursor: "pointer", - data: { - kind: "chip-cell", - chips: [ - { - icon: entity.icon - ? { entityTypeIcon: entity.icon } - : { - inbuiltIcon: entity.isLink ? "bpLink" : "bpAsterisk", - }, - iconFill: theme.palette.gray[50], - text: entity.label, - onClick: () => { - handleEntityClick(entity.entityId); - }, - }, - ], - color: "white", - variant: "outlined", - }, - }; - } - if (columnId === "archived") { - const value = row.archived ? "Yes" : "No"; - return { - kind: GridCellKind.Text, - readonly: true, - allowOverlay: false, - displayData: String(value), - data: value, - }; - } else if (columnId === "lastEdited") { - return { - kind: GridCellKind.Text, - readonly: true, - allowOverlay: false, - displayData: String(row.lastEdited), - data: row.lastEdited, - }; - } else if (columnId === "created") { - return { - kind: GridCellKind.Text, - readonly: true, - allowOverlay: false, - displayData: String(row.created), - data: row.lastEdited, - }; - } else { - const actorId = - columnId === "lastEditedById" - ? row.lastEditedById - : row.createdById; + const actor = actorsByAccountId[actorId]; + + if (!actor) { + return { + kind: GridCellKind.Text, + readonly: true, + allowOverlay: false, + displayData: "Loading...", + data: "Loading...", + }; + } - const actor = actorsByAccountId[actorId]; + const actorIcon = + actor.kind === "machine" + ? isAiMachineActor(actor) + ? "wandMagicSparklesRegular" + : "hashSolid" + : ("userRegular" satisfies CustomIcon); - if (!actor) { return { - kind: GridCellKind.Text, + kind: GridCellKind.Custom, readonly: true, allowOverlay: false, - displayData: "Loading...", - data: "Loading...", + copyData: String(actor.displayName), + data: { + kind: "chip-cell", + chips: actor.displayName + ? [ + { + text: actor.displayName, + icon: { inbuiltIcon: actorIcon }, + }, + ] + : [], + color: "gray", + variant: "filled", + } satisfies ChipCellProps, }; } + } - const actorIcon = - actor.kind === "machine" - ? isAiMachineActor(actor) - ? "wandMagicSparklesRegular" - : "hashSolid" - : ("userRegular" satisfies CustomIcon); - - return { - kind: GridCellKind.Custom, - readonly: true, - allowOverlay: false, - copyData: String(actor.displayName), - data: { - kind: "chip-cell", - chips: actor.displayName - ? [ - { - text: actor.displayName, - icon: { inbuiltIcon: actorIcon }, - }, - ] - : [], - color: "gray", - variant: "filled", - } satisfies ChipCellProps, - }; + return blankCell; + }, + [ + actorsByAccountId, + columns, + dataTypeDefinitions, + disableTypeClick, + entityTypesWithMultipleVersionsPresent, + handleEntityClick, + isViewingOnlyPages, + router, + setSelectedEntityType, + theme.palette.blue, + theme.palette.gray, + webNameByWebId, + ], + ); + + const sortableColumns: SortableEntitiesTableColumnKey[] = useMemo(() => { + return [ + "archived", + "created", + "entityLabel", + "entityTypes", + "lastEdited", + ...columns.map((column) => column.id).filter((key) => isBaseUrl(key)), + ]; + }, [columns]); + + const onConversionTargetSelected = useCallback( + ({ + columnKey, + dataTypeId, + }: { + columnKey: BaseUrl; + dataTypeId: VersionedUrl | null; + }) => { + if (!dataTypeId) { + if (!activeConversions) { + return; } - } - return blankCell; - }, - [ - actorsByAccountId, - columns, - dataTypeDefinitions, - disableTypeClick, - entityTypesWithMultipleVersionsPresent, - handleEntityClick, - isViewingOnlyPages, - router, - setSelectedEntityType, - theme.palette.blue, - theme.palette.gray, - webNameByWebId, - ], - ); - - const sortableColumns: SortableEntitiesTableColumnKey[] = useMemo(() => { - return [ - "archived", - "created", - "entityLabel", - "entityTypes", - "lastEdited", - ...columns.map((column) => column.id).filter((key) => isBaseUrl(key)), - ]; - }, [columns]); - - const onConversionTargetSelected = useCallback( - ({ - columnKey, - dataTypeId, - }: { - columnKey: BaseUrl; - dataTypeId: VersionedUrl | null; - }) => { - if (!dataTypeId) { - if (!activeConversions) { - return; - } + const newConversions: Parameters[0] = {}; + let hasKeysRemaining = false; - const newConversions: Parameters[0] = {}; - let hasKeysRemaining = false; + for (const [key, value] of typedEntries(activeConversions)) { + if (key !== columnKey) { + newConversions[key] = value.dataTypeId; + hasKeysRemaining = true; + } + } - for (const [key, value] of typedEntries(activeConversions)) { - if (key !== columnKey) { - newConversions[key] = value.dataTypeId; - hasKeysRemaining = true; + if (!hasKeysRemaining) { + setActiveConversions(null); + } else { + setActiveConversions(newConversions); } + } else { + setActiveConversions((existingConversions) => ({ + ...existingConversions, + [columnKey]: dataTypeId, + })); } - - if (!hasKeysRemaining) { - setActiveConversions(null); + }, + [activeConversions, setActiveConversions], + ); + + const customRenderers = useMemo(() => { + return [ + createRenderTextIconCell({ firstColumnLeftPadding }), + createRenderUrlCell({ firstColumnLeftPadding }), + createRenderChipCell({ firstColumnLeftPadding }), + createRenderEntitiesTableValueCell({ firstColumnLeftPadding }), + ]; + }, []); + + const setSortWithConversion = useCallback( + (newSort: GridSort) => { + const targetConversions = + conversionTargetsByColumnKey[newSort.columnKey]; + + const canonical = targetConversions?.find( + (conversion) => conversion.guessedAsCanonical, + ); + + if (canonical) { + setSort({ + ...newSort, + convertTo: extractBaseUrl(canonical.dataTypeId), + }); } else { - setActiveConversions(newConversions); + setSort(newSort); } - } else { - setActiveConversions((existingConversions) => ({ - ...existingConversions, - [columnKey]: dataTypeId, - })); - } - }, - [activeConversions, setActiveConversions], - ); - - const customRenderers = useMemo(() => { - return [ - createRenderTextIconCell({ firstColumnLeftPadding }), - createRenderUrlCell({ firstColumnLeftPadding }), - createRenderChipCell({ firstColumnLeftPadding }), - createRenderEntitiesTableValueCell({ firstColumnLeftPadding }), - ]; - }, []); - - const setSortWithConversion = useCallback( - (newSort: GridSort) => { - const targetConversions = conversionTargetsByColumnKey[newSort.columnKey]; - - const canonical = targetConversions?.find( - (conversion) => conversion.guessedAsCanonical, - ); - - if (canonical) { - setSort({ - ...newSort, - convertTo: extractBaseUrl(canonical.dataTypeId), - }); - } else { - setSort(newSort); + }, + [conversionTargetsByColumnKey, setSort], + ); + + const generateCsvFile = useCallback(() => { + const csvColumns = currentlyDisplayedColumnsRef.current; + const csvRows = currentlyDisplayedRowsRef.current; + + if (!csvColumns || !csvRows) { + return null; } - }, - [conversionTargetsByColumnKey, setSort], - ); - - const generateCsvFile = useCallback(() => { - const csvColumns = currentlyDisplayedColumnsRef.current; - const csvRows = currentlyDisplayedRowsRef.current; - - if (!csvColumns || !csvRows) { - return null; - } - - return buildCsvFile({ - columns: csvColumns, - rows: csvRows, - title: csvFileTitle, - /** - * The entities table stores actor and web ids on the row, resolving them to - * display names elsewhere in the component. Translate them here so the export - * matches what's shown in the grid rather than emitting raw ids. - */ - resolveCell: (key, row) => { - if (key === "createdById" || key === "lastEditedById") { - return actorsByAccountId[row[key]]?.displayName ?? ""; - } - if (key === "webId") { - const shortname = webNameByWebId[row.webId]; - return shortname ? `@${shortname}` : ""; - } + return buildCsvFile({ + columns: csvColumns, + rows: csvRows, + title: csvFileTitle, + /** + * The entities table stores actor and web ids on the row, resolving them to + * display names elsewhere in the component. Translate them here so the export + * matches what's shown in the grid rather than emitting raw ids. + */ + resolveCell: (key, row) => { + if (key === "createdById" || key === "lastEditedById") { + return actorsByAccountId[row[key]]?.displayName ?? ""; + } - return undefined; - }, - }); - }, [ - actorsByAccountId, - csvFileTitle, - currentlyDisplayedColumnsRef, - currentlyDisplayedRowsRef, - webNameByWebId, - ]); - - const [ - { horizontalScrollbarHeight, verticalScrollbarWidth }, - setScrollbarSizes, - ] = useState({ - horizontalScrollbarHeight: 0, - verticalScrollbarWidth: 0, - }); - - useEffect(() => { - const gridEl = document.querySelector(".dvn-scroller"); - - if (!gridEl) { - return; - } - - const scrollbarHeight = gridEl.offsetHeight - gridEl.clientHeight; - const scrollbarWidth = gridEl.offsetWidth - gridEl.clientWidth; - - setScrollbarSizes({ - horizontalScrollbarHeight: scrollbarHeight, - verticalScrollbarWidth: scrollbarWidth, - }); - }, [rows.length]); - - const loadMoreRowHeight = 60; - - return ( - <> - - - setShowSearch(false)} - onSelectedRowsChange={(updatedSelectedRows) => - setSelectedRows(updatedSelectedRows) + if (key === "webId") { + const shortname = webNameByWebId[row.webId]; + return shortname ? `@${shortname}` : ""; } - rows={rows} - selectedRows={selectedRows} + + return undefined; + }, + }); + }, [actorsByAccountId, csvFileTitle, webNameByWebId]); + + const [ + { horizontalScrollbarHeight, verticalScrollbarWidth }, + setScrollbarSizes, + ] = useState({ + horizontalScrollbarHeight: 0, + verticalScrollbarWidth: 0, + }); + + /** + * Wraps the grid so the scrollbar measurement below can find THIS table's + * scroller -- multiple EntitiesTables can be mounted at once (e.g. one in + * a slide over the entities page). + */ + const tableContainerRef = useRef(null); + + useEffect(() => { + const gridEl = + tableContainerRef.current?.querySelector(".dvn-scroller"); + + if (!gridEl) { + return; + } + + const scrollbarHeight = gridEl.offsetHeight - gridEl.clientHeight; + const scrollbarWidth = gridEl.offsetWidth - gridEl.clientWidth; + + setScrollbarSizes({ + horizontalScrollbarHeight: scrollbarHeight, + verticalScrollbarWidth: scrollbarWidth, + }); + }, [rows.length]); + + const loadMoreRowHeight = 60; + + return ( + <> + - - {hasMoreRowsAvailable && ( - ({ - alignItems: "center", - justifyContent: "center", - background: palette.common.white, - borderTop: `1px solid ${palette.gray[20]}`, - height: loadMoreRowHeight, - position: "absolute", - bottom: horizontalScrollbarHeight, - p: 1, - width: `calc(100% - ${verticalScrollbarWidth}px)`, - })} - > - - - )} - - - ); -}; + "&:hover": { + background: palette.gray[15], + "::before": { + background: "none", + }, + }, + })} + > + {entityDataLoading ? ( + <> + + Loading... + + + + ) : ( + <> + Show more entities + palette.gray[50], ml: 0.5 }} + > + {totalResultCount != null + ? `- ${formatNumber( + totalResultCount - (queryRowCount ?? rows.length), + )} remaining` + : ""} + + palette.gray[50], + }} + /> + + )} + + + )} + + + ); + }, +); diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/expansion-table-data.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/expansion-table-data.ts new file mode 100644 index 00000000000..590132fd13e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/expansion-table-data.ts @@ -0,0 +1,79 @@ +/** + * Reflects graph frontier expansions in the entities table: the entities the + * user OR-ed into the displayed set become rows appended after the query's + * own rows. + * + * Each expansion batch carries its own type maps and subgraph (the query's + * maps don't cover entities outside the filter), so rows are generated per + * record and merged with the query table data via the same machinery the + * paginated pages use. + */ +import { generateTableDataFromRows } from "./shared/generate-table-data-from-rows"; +import { mergeTableData } from "./use-entities-visualizer-data/merge-table-data"; + +import type { ExpansionRecord } from "../graph-visualizer/entity-graph/frontier-expansion-store"; +import type { + EntitiesTableData, + EntitiesTableRow, +} from "./entities-table-data"; + +interface WithExpansionRowsOptions { + readonly hideColumns: (keyof EntitiesTableRow)[] | undefined; + readonly hideArchivedColumn: boolean; +} + +/** + * Appends the expansion records' entities to `tableData` as extra rows. + * + * An expanded entity that has since become a query root (a later "Show more" + * page fetched it) is skipped: the query row wins, so nothing appears + * twice. Returns `tableData` unchanged (same reference) when the records add + * nothing, so memoized consumers see no change. + */ +export function withExpansionRows( + tableData: EntitiesTableData, + records: readonly ExpansionRecord[], + { hideColumns, hideArchivedColumn }: WithExpansionRowsOptions, +): EntitiesTableData { + if (records.length === 0) { + return tableData; + } + + const presentIds = new Set(tableData.rows.map((row) => row.entityId)); + + const additions = records.flatMap((record) => { + // Without its type maps a record's rows can't be resolved; the expansion + // query always includes them, so this is a type guard, not a real path. + if (!record.rootMap || !record.definitions) { + return []; + } + + const freshEntities = record.expandedEntities.filter( + (entity) => !presentIds.has(entity.metadata.recordId.entityId), + ); + if (freshEntities.length === 0) { + return []; + } + + for (const entity of freshEntities) { + presentIds.add(entity.metadata.recordId.entityId); + } + + return [ + generateTableDataFromRows({ + closedMultiEntityTypesRootMap: record.rootMap, + definitions: record.definitions, + entities: freshEntities.map((entity) => entity.toJSON()), + subgraph: record.subgraph, + hideColumns, + hideArchivedColumn, + }), + ]; + }); + + if (additions.length === 0) { + return tableData; + } + + return additions.reduce(mergeTableData, tableData); +} diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/grid-view.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/grid-view.tsx index d8eb47472e4..a02e6fadddb 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/grid-view.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/grid-view.tsx @@ -1,43 +1,52 @@ import { Grid } from "@mui/material"; +import { memo } from "react"; import { GridViewItem } from "./grid-view/grid-view-item"; import { GridViewItemSkeleton } from "./grid-view/grid-view-item-skeleton"; import type { EntityId } from "@blockprotocol/type-system"; import type { HashEntity } from "@local/hash-graph-sdk/entity"; -import type { FunctionComponent } from "react"; -export const GridView: FunctionComponent<{ - entities?: HashEntity[]; - onEntityClick: (entityId: EntityId) => void; -}> = ({ entities, onEntityClick }) => { - return ( - palette.common.white, - borderColor: ({ palette }) => palette.gray[30], - borderStyle: "solid", - borderWidth: 1, - borderTopWidth: 0, - borderBottomRightRadius: "8px", - borderBottomLeftRadius: "8px", - overflow: "hidden", - }} - > - {entities - ? entities.map((entity, index, all) => ( - - )) - : Array.from({ length: 4 }, (_, index) => ( - - ))} - - ); -}; +export const GridView = memo( + ({ + entities, + onEntityClick, + }: { + entities?: HashEntity[]; + onEntityClick: (entityId: EntityId) => void; + }) => { + return ( + palette.common.white, + borderColor: ({ palette }) => palette.gray[30], + borderStyle: "solid", + borderWidth: 1, + borderTopWidth: 0, + borderBottomRightRadius: "8px", + borderBottomLeftRadius: "8px", + overflow: "hidden", + }} + > + {entities + ? entities.map((entity, index, all) => ( + + )) + : Array.from({ length: 4 }, (_, index) => ( + + ))} + + ); + }, +); diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header.tsx index 6db8faa5b86..19f7b1872ea 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/header.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header.tsx @@ -4,3 +4,4 @@ export { VisualizerHeader, visualizerHeaderHeight, } from "./header/visualizer-header"; +export type { InternalWeb } from "./header/web-filter-pill"; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-config-buttons.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-config-buttons.tsx new file mode 100644 index 00000000000..8475700cc6a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-config-buttons.tsx @@ -0,0 +1,82 @@ +import { Box, Tooltip } from "@mui/material"; +import { useRef } from "react"; + +import { IconButton } from "@hashintel/design-system"; + +import { FileExportRegularIcon } from "../../../../shared/icons/file-export-regular-icon"; +import { UploadRegularIcon } from "../../../../shared/icons/upload-regular-icon"; + +import type { SxProps, Theme } from "@mui/material"; +import type { FunctionComponent } from "react"; + +type FilterConfigButtonsProps = { + /** Download the current configuration (filters plus graph additions). */ + onExport: () => void; + /** Receives the picked file's text; parsing/validation is the caller's. */ + onImport: (fileText: string) => void; +}; + +const configButtonSx: SxProps = { + p: 0.5, + color: ({ palette }) => palette.gray[50], + "&:hover": { + color: ({ palette }) => palette.gray[80], + }, + svg: { fontSize: 14 }, +}; + +/** + * Export/import of the full filter configuration as a file. The file exists + * because graph additions cannot ride the URL the way filter state does + * (entity ids are too long; see `shared/filter-config-file.ts`), so this is + * the way to share or restore an explored view. + */ +export const FilterConfigButtons: FunctionComponent< + FilterConfigButtonsProps +> = ({ onExport, onImport }) => { + const fileInputRef = useRef(null); + + return ( + + + + + + + + fileInputRef.current?.click()} + aria-label="Import filters from a file" + sx={configButtonSx} + > + + + + ) => { + const file = event.target.files?.[0]; + // Allow re-importing the same file: a file input only fires + // `change` when the selection differs from the last one. + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + if (!file) { + return; + } + void file.text().then(onImport); + }} + /> + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-ribbon.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-ribbon.tsx index 77a40868aa4..6e45cb01105 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-ribbon.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-ribbon.tsx @@ -3,6 +3,8 @@ import { useState } from "react"; import { getDefaultOperatorForKind } from "../shared/property-filters/get-operators-for-kind"; import { AddFiltersMenu } from "./add-filters-menu"; +import { FilterConfigButtons } from "./filter-config-buttons"; +import { FrontierAdditionsPill } from "./frontier-additions-pill"; import { IncludeArchivedPill } from "./include-archived-pill"; import { PropertyFilterPill } from "./property-filter-pill"; import { TypeFilterPill } from "./type-filter-pill"; @@ -22,8 +24,19 @@ type FilterRibbonProps = { availableTypesLoading: boolean; propertyFilterMetadata: FilterMetadataForProperty[]; filterState: EntitiesFilterState; + /** + * How many entities graph exploration has OR-ed into the displayed set + * beyond the query's own results. Zero hides the pill. + */ + frontierAdditionsCount: number; internalWebs: InternalWeb[]; isTypePinned: boolean; + /** Drop the graph-expansion additions (dismiss the "OR n entities" pill). */ + onClearFrontierAdditions: () => void; + /** Download the current filter configuration (filters plus graph additions). */ + onExportFilters: () => void; + /** Restore a configuration from a picked file's text. */ + onImportFilters: (fileText: string) => void; setFilterState: ( updater: (prev: EntitiesFilterState) => EntitiesFilterState, ) => void; @@ -40,8 +53,12 @@ export const FilterRibbon: FunctionComponent = ({ availableTypesLoading, propertyFilterMetadata, filterState, + frontierAdditionsCount, internalWebs, isTypePinned, + onClearFrontierAdditions, + onExportFilters, + onImportFilters, setFilterState, }) => { const [draftPropertyFilter, setDraftPropertyFilter] = @@ -145,6 +162,18 @@ export const FilterRibbon: FunctionComponent = ({ propertiesLoading={availableTypesLoading} onAddPropertyFilter={handleAddPropertyFilter} /> + {/* After the add-filter button: the AND-ed filter clause ends + there, and the OR clause joins onto the completed ribbon. */} + {frontierAdditionsCount > 0 && ( + + )} + ); }; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/frontier-additions-pill.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/frontier-additions-pill.tsx new file mode 100644 index 00000000000..8d507c2e48e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/frontier-additions-pill.tsx @@ -0,0 +1,70 @@ +import { Stack, Typography } from "@mui/material"; + +import { Chip, IconButton, XMarkRegularIcon } from "@hashintel/design-system"; +import { formatNumber } from "@local/hash-isomorphic-utils/format-number"; + +import { ChartNetworkRegularIcon } from "../../../../shared/icons/chart-network-regular-icon"; +import { activePillSx } from "./pill-styles"; + +import type { FunctionComponent } from "react"; + +type FrontierAdditionsPillProps = { + /** How many entities graph exploration has OR-ed into the displayed set. */ + count: number; + onRemove: () => void; +}; + +/** + * Shows the entities pulled in by expanding frontier nodes in the graph view. + * The filter pills before it AND together; these entities match no filter, so + * a plain-typography "OR" joins the pill onto the ribbon, reading as "filters + * OR these n entities". Removing the pill drops them again (the graph resets + * to the filtered set). + */ +export const FrontierAdditionsPill: FunctionComponent< + FrontierAdditionsPillProps +> = ({ count, onRemove }) => { + return ( + + palette.gray[60], + fontSize: 13, + fontWeight: 600, + }} + > + OR + + palette.blue[70], fontSize: 14 }} + /> + } + label={ + + {`${formatNumber(count)} ${count === 1 ? "entity" : "entities"}`} + palette.blue[70], + "&:hover": { + color: ({ palette }) => palette.blue[90], + background: "transparent", + }, + }} + > + + + + } + sx={activePillSx} + /> + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/query-count.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/query-count.tsx index be51c1c8fda..8943973361e 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/query-count.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/query-count.tsx @@ -5,13 +5,21 @@ import { formatNumber } from "@local/hash-isomorphic-utils/format-number"; import type { FunctionComponent } from "react"; -type QueryCountProps = { - count: number | null | undefined; - loading: boolean; -}; +interface QueryCountProps { + /** Total entities in the displayed set. */ + readonly count: number | null | undefined; + /** + * How many of those are loaded client-side. When it trails the total, the + * count reads "m of n entities" so partial loading is visible; equal (or + * unknown) collapses to "n entities". + */ + readonly loadedCount?: number | null; + readonly loading: boolean; +} export const QueryCount: FunctionComponent = ({ count, + loadedCount, loading, }) => { const theme = useTheme(); @@ -26,6 +34,7 @@ export const QueryCount: FunctionComponent = ({ fontSize: 13, fontWeight: 500, justifyContent: "flex-end", + whiteSpace: "nowrap", }} > {loading ? ( @@ -34,7 +43,11 @@ export const QueryCount: FunctionComponent = ({ Loading ) : count != null ? ( - `${formatNumber(count)} ${count === 1 ? "entity" : "entities"}` + `${ + loadedCount != null && loadedCount < count + ? `${formatNumber(loadedCount)} of ` + : "" + }${formatNumber(count)} ${count === 1 ? "entity" : "entities"}` ) : ( "" )} diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/visualizer-header.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/visualizer-header.tsx index 9281266e5b5..f55d69325c1 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/visualizer-header.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/visualizer-header.tsx @@ -17,7 +17,7 @@ export const VisualizerHeader: FunctionComponent = ({ palette.gray[20], borderWidth: 1, diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/apply-query-values-to-as-path.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/apply-query-values-to-as-path.ts new file mode 100644 index 00000000000..4b575f0749c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/apply-query-values-to-as-path.ts @@ -0,0 +1,110 @@ +/** + * Merging URL-owned state into a Next.js `asPath` without disturbing anyone + * else's query parameters. Each URL-synced state slice (e.g. the filter + * state) owns a fixed set of keys and rewrites only those; all other + * parameters pass through byte-for-byte, so concurrent writers converge + * instead of clobbering each other. + */ + +/** + * Percent-encodes a query value while leaving characters that are legal in a URL + * query and aid readability (`:` `/` `@` `,` `;`) intact, so type/property URLs + * and entity ids remain legible rather than turning into `%3A%2F%2F` noise. + * (`~`, which entity ids also contain, is unreserved and never encoded.) + */ +const encodeQueryValue = (value: string): string => + encodeURIComponent(value) + .replace(/%3A/g, ":") + .replace(/%2F/g, "/") + .replace(/%40/g, "@") + .replace(/%2C/g, ",") + .replace(/%3B/g, ";"); + +const parseRawPairs = (search: string): [string, string][] => + search + ? search.split("&").map((pair): [string, string] => { + const equalsIndex = pair.indexOf("="); + return equalsIndex === -1 + ? [pair, ""] + : [pair.slice(0, equalsIndex), pair.slice(equalsIndex + 1)]; + }) + : []; + +const groupRawValuesByKey = ( + pairs: [string, string][], +): Map => { + const byKey = new Map(); + for (const [key, rawValue] of pairs) { + const existing = byKey.get(key); + if (existing) { + existing.push(rawValue); + } else { + byKey.set(key, [rawValue]); + } + } + return byKey; +}; + +const arraysEqual = (a: string[], b: string[]): boolean => + a.length === b.length && a.every((value, index) => value === b[index]); + +/** + * Merges the given owned parameter values into an existing `asPath`, leaving + * any parameter outside `ownedKeys` in place. An owned key absent from + * `values` is removed from the URL. Comparison is performed against the raw + * (already-encoded) query so the result is stable across re-renders, and + * values are written with {@link encodeQueryValue} to keep URLs readable. + * Returns whether the resulting path differs so callers can avoid redundant + * navigations. + */ +export const applyQueryValuesToAsPath = ({ + asPath, + ownedKeys, + values, +}: { + asPath: string; + ownedKeys: readonly string[]; + values: Record; +}): { changed: boolean; nextAsPath: string } => { + const [path = "", search = ""] = asPath.split("?"); + const existingPairs = parseRawPairs(search); + const currentByKey = groupRawValuesByKey(existingPairs); + + const desiredByKey = new Map(); + for (const key of ownedKeys) { + const value = values[key]; + if (value === undefined) { + continue; + } + const list = Array.isArray(value) ? value : [value]; + desiredByKey.set(key, list.map(encodeQueryValue)); + } + + const changed = ownedKeys.some( + (key) => + !arraysEqual(currentByKey.get(key) ?? [], desiredByKey.get(key) ?? []), + ); + + if (!changed) { + return { changed: false, nextAsPath: asPath }; + } + + const ownedKeySet = new Set(ownedKeys); + + const rebuilt = existingPairs + .filter(([key]) => !ownedKeySet.has(key)) + .map(([key, rawValue]) => (rawValue === "" ? key : `${key}=${rawValue}`)); + + for (const key of ownedKeys) { + for (const rawValue of desiredByKey.get(key) ?? []) { + rebuilt.push(`${key}=${rawValue}`); + } + } + + const nextSearch = rebuilt.join("&"); + + return { + changed: true, + nextAsPath: nextSearch ? `${path}?${nextSearch}` : path, + }; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-config-file.test.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-config-file.test.ts new file mode 100644 index 00000000000..2b004cfd0ce --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-config-file.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + parseFilterConfigFile, + serializeFilterConfigFile, +} from "./filter-config-file"; + +import type { EntityId } from "@blockprotocol/type-system"; + +const entityA = + "11111111-1111-4111-8111-111111111111~aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" as EntityId; +const entityB = + "22222222-2222-4222-8222-222222222222~bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" as EntityId; + +describe("filter-config-file", () => { + it("round-trips filters and expanded entity ids", () => { + const config = { + filters: { + webs: "11111111-1111-4111-8111-111111111111", + archived: "true", + propertyFilter: [ + "https://hash.ai/@hash/types/property-type/age/;number;greaterThan;13", + ], + }, + expandedEntityIds: [entityA, entityB], + }; + + expect(parseFilterConfigFile(serializeFilterConfigFile(config))).toEqual( + config, + ); + }); + + it("round-trips an empty configuration", () => { + const config = { filters: {}, expandedEntityIds: [] }; + + expect(parseFilterConfigFile(serializeFilterConfigFile(config))).toEqual( + config, + ); + }); + + it("rejects malformed JSON", () => { + expect(parseFilterConfigFile("{ not json")).toBeNull(); + }); + + it("rejects JSON that is not a filter export", () => { + expect(parseFilterConfigFile(JSON.stringify({ nodes: [] }))).toBeNull(); + }); + + it("rejects a future format version", () => { + expect( + parseFilterConfigFile( + JSON.stringify({ + format: "hash-entities-filters", + version: 2, + filters: {}, + expandedEntities: [], + }), + ), + ).toBeNull(); + }); + + it("drops unknown filter keys and malformed entity ids", () => { + const parsed = parseFilterConfigFile( + JSON.stringify({ + format: "hash-entities-filters", + version: 1, + filters: { archived: "true", rogueKey: "value", types: 42 }, + expandedEntities: [entityA, "not-an-entity-id", 7], + }), + ); + + expect(parsed).toEqual({ + filters: { archived: "true" }, + expandedEntityIds: [entityA], + }); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-config-file.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-config-file.ts new file mode 100644 index 00000000000..6d35efeae3a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-config-file.ts @@ -0,0 +1,121 @@ +/** + * File-based persistence for the entities view's filter configuration: the + * filter state plus the entities graph exploration OR-ed into the set. + * + * A file, not the URL, because the expansion ids cannot ride the URL: each + * entity id is ~70 characters, so a modest exploration blows past the URL + * lengths browsers and servers reliably accept. Filter state alone stays in + * the URL (it is compact); this file carries the complete configuration for + * sharing and archiving. + * + * The filter payload reuses the URL-grade serialization + * (`serializeFilterStateToQuery` / `parseFilterStateFromQuery` in + * `filter-state-url.ts`), so a file round-trips through exactly the + * validation the URL parameters get, and the two representations cannot + * drift. The format is versioned; {@link parseFilterConfigFile} rejects + * unknown versions rather than guessing. + */ +import { isEntityId } from "@blockprotocol/type-system"; + +import { filterStateQueryKeys } from "./filter-state-url"; + +import type { EntityId } from "@blockprotocol/type-system"; + +/** Distinguishes this file from arbitrary JSON the user might pick. */ +const FILTER_CONFIG_FORMAT = "hash-entities-filters"; +const FILTER_CONFIG_VERSION = 1; + +export interface FilterConfig { + /** + * The filter state in its URL-parameter serialization (see + * `serializeFilterStateToQuery` in `filter-state-url.ts`), restricted to + * the keys the filter state owns. + */ + filters: Record; + /** The entities expanded in the graph view ("OR n entities"). */ + expandedEntityIds: EntityId[]; +} + +const isQueryValue = (value: unknown): value is string | string[] => + typeof value === "string" || + (Array.isArray(value) && + value.every((entry): entry is string => typeof entry === "string")); + +/** Serializes the configuration to the versioned file payload. */ +export const serializeFilterConfigFile = ({ + filters, + expandedEntityIds, +}: FilterConfig): string => + JSON.stringify( + { + format: FILTER_CONFIG_FORMAT, + version: FILTER_CONFIG_VERSION, + filters, + expandedEntities: expandedEntityIds, + }, + null, + 2, + ); + +/** + * Parses a filter-configuration file, returning `null` for anything that is + * not a well-formed file of a known version (malformed JSON, another format, + * a future version). Within a well-formed file, unknown filter keys and + * malformed entity ids are dropped rather than failing the whole import; + * the surviving filter values still pass through `parseFilterStateFromQuery`, + * which falls back to defaults for anything malformed at the value level. + */ +export const parseFilterConfigFile = (raw: string): FilterConfig | null => { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if ( + typeof parsed !== "object" || + parsed === null || + !("format" in parsed) || + parsed.format !== FILTER_CONFIG_FORMAT || + !("version" in parsed) || + parsed.version !== FILTER_CONFIG_VERSION + ) { + return null; + } + + const filters: Record = {}; + if ("filters" in parsed && typeof parsed.filters === "object") { + const rawFilters = parsed.filters as Record; + for (const key of filterStateQueryKeys) { + const value = rawFilters[key]; + if (isQueryValue(value)) { + filters[key] = value; + } + } + } + + const rawExpanded = + "expandedEntities" in parsed && Array.isArray(parsed.expandedEntities) + ? parsed.expandedEntities + : []; + const expandedEntityIds = rawExpanded.filter( + (id): id is EntityId => typeof id === "string" && isEntityId(id), + ); + + return { filters, expandedEntityIds }; +}; + +/** Triggers a browser download of the configuration; returns the filename. */ +export const downloadFilterConfigFile = (config: FilterConfig): string => { + const blob = new Blob([serializeFilterConfigFile(config)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `hash-filters-${new Date().toISOString().slice(0, 10)}.json`; + anchor.click(); + URL.revokeObjectURL(url); + return anchor.download; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-state-url.test.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-state-url.test.ts new file mode 100644 index 00000000000..67ff3a822eb --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-state-url.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from "vitest"; + +import { createDefaultFilterState } from "./filter-state"; +import { + applyFilterValuesToAsPath, + parseFilterStateFromQuery, + serializeFilterStateToQuery, +} from "./filter-state-url"; + +import type { EntitiesFilterState } from "./filter-state"; +import type { BaseUrl, VersionedUrl, WebId } from "@blockprotocol/type-system"; + +const webA = "11111111-1111-4111-8111-111111111111" as WebId; +const webB = "22222222-2222-4222-8222-222222222222" as WebId; +const internalWebIds = [webA, webB]; + +const personType = + "https://hash.ai/@hash/types/entity-type/person/v/1" as VersionedUrl; +const orgType = + "https://hash.ai/@hash/types/entity-type/organization/v/1" as VersionedUrl; +const unitOfMeasureBaseUrl = + "https://hash.ai/@hash/types/property-type/unit-of-measure/" as BaseUrl; + +const roundTrip = ( + state: EntitiesFilterState, + isTypePinned = false, +): EntitiesFilterState => + parseFilterStateFromQuery({ + query: serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned, + }), + internalWebIds, + isTypePinned, + }); + +describe("filter-state-url", () => { + it("serializes the default state to an empty query", () => { + expect( + serializeFilterStateToQuery({ + filterState: createDefaultFilterState(internalWebIds), + internalWebIds, + isTypePinned: false, + }), + ).toEqual({}); + }); + + it("parses an empty query to the default state", () => { + expect( + parseFilterStateFromQuery({ + query: {}, + internalWebIds, + isTypePinned: false, + }), + ).toEqual(createDefaultFilterState(internalWebIds)); + }); + + it("round-trips a single selected web", () => { + const state = createDefaultFilterState(internalWebIds); + state.web.selectedInternalWebIds = new Set([webA]); + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ webs: webA }); + expect(roundTrip(state)).toEqual(state); + }); + + it("round-trips 'any' (all internal + other webs)", () => { + const state = createDefaultFilterState(internalWebIds); + state.web.includeOtherWebs = true; + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ otherWebs: "true" }); + expect(roundTrip(state)).toEqual(state); + }); + + it("round-trips 'none' (no internal webs selected)", () => { + const state = createDefaultFilterState(internalWebIds); + state.web.selectedInternalWebIds = new Set(); + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ webs: "none" }); + expect(roundTrip(state)).toEqual(state); + }); + + it("serializes types as a comma-separated list and round-trips", () => { + const state = createDefaultFilterState(internalWebIds); + state.type.selectedTypeIds = new Set([personType, orgType]); + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ types: `${personType},${orgType}` }); + expect(roundTrip(state)).toEqual(state); + }); + + it("round-trips empty type selection", () => { + const state = createDefaultFilterState(internalWebIds); + state.type.selectedTypeIds = new Set(); + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ types: "none" }); + expect(roundTrip(state)).toEqual(state); + }); + + it("ignores the type dimension when pinned", () => { + const state = createDefaultFilterState(internalWebIds); + state.type.selectedTypeIds = new Set([personType]); + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: true, + }); + expect(values).toEqual({}); + + const parsed = roundTrip(state, true); + expect(parsed.type.selectedTypeIds).toBeNull(); + }); + + it("round-trips includeArchived", () => { + const state = createDefaultFilterState(internalWebIds); + state.includeArchived = true; + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ archived: "true" }); + expect(roundTrip(state)).toEqual(state); + }); + + it("serializes property filters compactly and re-derives the title from the slug", () => { + const state = createDefaultFilterState(internalWebIds); + state.propertyFilters = [ + { + id: "ignored-on-serialize", + baseUrl: unitOfMeasureBaseUrl, + title: "Unit of Measure", + kind: "string", + operator: "equals", + value: "2", + }, + ]; + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ + propertyFilter: [`${unitOfMeasureBaseUrl};string;equals;2`], + }); + + const parsed = roundTrip(state); + expect(parsed.propertyFilters).toHaveLength(1); + const [filter] = parsed.propertyFilters; + expect(filter).toMatchObject({ + baseUrl: unitOfMeasureBaseUrl, + kind: "string", + operator: "equals", + value: "2", + // Title is dropped from the URL and re-derived from the slug. + title: "Unit Of Measure", + }); + expect(filter!.id).not.toBe("ignored-on-serialize"); + }); + + it("preserves field separators that appear inside a property filter value", () => { + const state = createDefaultFilterState(internalWebIds); + state.propertyFilters = [ + { + id: "x", + baseUrl: unitOfMeasureBaseUrl, + title: "Unit of Measure", + kind: "string", + operator: "contains", + value: "a;b;c", + }, + ]; + + const parsed = roundTrip(state); + expect(parsed.propertyFilters[0]).toMatchObject({ value: "a;b;c" }); + }); + + it("omits the value for value-less property filter operators", () => { + const state = createDefaultFilterState(internalWebIds); + state.propertyFilters = [ + { + id: "x", + baseUrl: unitOfMeasureBaseUrl, + title: "Unit of Measure", + kind: "string", + operator: "isEmpty", + }, + ]; + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ + propertyFilter: [`${unitOfMeasureBaseUrl};string;isEmpty`], + }); + + const parsed = roundTrip(state); + expect(parsed.propertyFilters[0]).toMatchObject({ + operator: "isEmpty", + value: undefined, + }); + }); + + it("drops malformed property filters on parse", () => { + const parsed = parseFilterStateFromQuery({ + query: { + propertyFilter: [ + "not-a-url;number;equals;1", + `${unitOfMeasureBaseUrl};number;notAnOperator`, + `${unitOfMeasureBaseUrl};string;equals;5`, + ], + }, + internalWebIds, + isTypePinned: false, + }); + expect(parsed.propertyFilters).toHaveLength(1); + expect(parsed.propertyFilters[0]).toMatchObject({ value: "5" }); + }); + + it("filters out web ids that are not internal", () => { + const parsed = parseFilterStateFromQuery({ + query: { webs: `${webA},99999999-9999-4999-8999-999999999999` }, + internalWebIds, + isTypePinned: false, + }); + expect([...parsed.web.selectedInternalWebIds]).toEqual([webA]); + }); + + describe("applyFilterValuesToAsPath", () => { + it("keeps type URLs human-readable (no percent-encoded slashes/colons)", () => { + const { nextAsPath } = applyFilterValuesToAsPath({ + asPath: "/entities", + filterValues: { types: personType }, + }); + expect(nextAsPath).toBe(`/entities?types=${personType}`); + expect(nextAsPath).not.toContain("%3A"); + expect(nextAsPath).not.toContain("%2F"); + }); + + it("adds filter params while preserving existing ones", () => { + const { changed, nextAsPath } = applyFilterValuesToAsPath({ + asPath: "/entities?entityTypeIdOrBaseUrl=foo", + filterValues: { webs: webA, archived: "true" }, + }); + expect(changed).toBe(true); + expect(nextAsPath).toBe( + `/entities?entityTypeIdOrBaseUrl=foo&webs=${webA}&archived=true`, + ); + }); + + it("reports no change when the params already match", () => { + const { changed } = applyFilterValuesToAsPath({ + asPath: `/entities?archived=true`, + filterValues: { archived: "true" }, + }); + expect(changed).toBe(false); + }); + + it("removes filter params that are no longer present", () => { + const { changed, nextAsPath } = applyFilterValuesToAsPath({ + asPath: `/entities?webs=${webA}&archived=true`, + filterValues: { webs: webA }, + }); + expect(changed).toBe(true); + expect(nextAsPath).toBe(`/entities?webs=${webA}`); + }); + + it("emits one repeated parameter per property filter", () => { + const { nextAsPath } = applyFilterValuesToAsPath({ + asPath: "/entities", + filterValues: { + propertyFilter: [ + `${unitOfMeasureBaseUrl};string;equals;2`, + `${unitOfMeasureBaseUrl};string;contains;x`, + ], + }, + }); + expect(nextAsPath).toBe( + `/entities?propertyFilter=${unitOfMeasureBaseUrl};string;equals;2` + + `&propertyFilter=${unitOfMeasureBaseUrl};string;contains;x`, + ); + }); + + it("encodes spaces in values as %20 (not +) while keeping separators raw", () => { + const { nextAsPath } = applyFilterValuesToAsPath({ + asPath: "/entities", + filterValues: { + propertyFilter: [`${unitOfMeasureBaseUrl};string;equals;John Doe`], + }, + }); + expect(nextAsPath).toContain("%20"); + expect(nextAsPath).not.toContain("+"); + expect(nextAsPath).toContain(`${unitOfMeasureBaseUrl};string;equals;`); + }); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-state-url.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-state-url.ts new file mode 100644 index 00000000000..7e731cbb6f1 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-state-url.ts @@ -0,0 +1,295 @@ +import { isBaseUrl } from "@blockprotocol/type-system"; + +import { applyQueryValuesToAsPath } from "./apply-query-values-to-as-path"; +import { getOperatorDescriptor } from "./property-filters/get-operators-for-kind"; + +import type { EntitiesFilterState } from "./filter-state"; +import type { + FilterValueKind, + PropertyFilter, + PropertyFilterOperator, +} from "./property-filters/property-filter"; +import type { VersionedUrl, WebId } from "@blockprotocol/type-system"; + +/** + * The shape of `router.query` (structurally equivalent to Node's + * `ParsedUrlQuery`), declared locally to avoid importing a Node built-in into + * frontend code. + */ +type UrlQuery = Record; + +const WEBS_KEY = "webs"; +const OTHER_WEBS_KEY = "otherWebs"; +const TYPES_KEY = "types"; +const ARCHIVED_KEY = "archived"; +/** Repeated once per property filter (e.g. `?propertyFilter=…&propertyFilter=…`). */ +const PROPERTY_FILTER_KEY = "propertyFilter"; + +/** + * The query parameter keys owned by the entities filter state. Used when + * merging filter state into an existing URL so that unrelated parameters (e.g. + * `entityTypeIdOrBaseUrl`) are left untouched. + */ +export const filterStateQueryKeys = [ + WEBS_KEY, + OTHER_WEBS_KEY, + TYPES_KEY, + ARCHIVED_KEY, + PROPERTY_FILTER_KEY, +] as const; + +/** + * Sentinel used for an explicitly empty selection, distinguishing it from an + * absent parameter (which means "default", i.e. everything selected). + */ +const NONE_TOKEN = "none"; +const TRUE_TOKEN = "true"; + +/** Separator for the comma-delimited web id / type id lists. */ +const LIST_SEPARATOR = ","; +/** + * Field separator within a single serialized property filter + * (`baseUrl;kind;operator;value`). The value comes last so it may itself + * contain the separator. + */ +const FIELD_SEPARATOR = ";"; + +const filterValueKinds: FilterValueKind[] = ["number", "string", "boolean"]; + +const isFilterValueKind = (value: string): value is FilterValueKind => + filterValueKinds.includes(value as FilterValueKind); + +const getSingleValue = ( + value: string | string[] | undefined, +): string | undefined => { + const single = Array.isArray(value) ? value[0] : value; + // Treat an empty string the same as an absent parameter. + return single === undefined || single === "" ? undefined : single; +}; + +const getMultiValue = (value: string | string[] | undefined): string[] => { + if (value === undefined) { + return []; + } + return (Array.isArray(value) ? value : [value]).filter( + (entry) => entry !== "", + ); +}; + +/** + * Derives a display title from a type's base URL slug, used as a fallback + * where the actual title isn't (yet) available -- e.g. for filters restored + * from the URL, which omit the title to stay compact. + * e.g. `…/property-type/unit-of-measure/` -> "Unit Of Measure". + */ +export const titleFromBaseUrl = (baseUrl: string): string => { + const segments = baseUrl.split("/").filter((segment) => segment.length > 0); + const slug = segments[segments.length - 1] ?? baseUrl; + return slug + .split("-") + .map((word) => (word ? `${word[0]!.toUpperCase()}${word.slice(1)}` : word)) + .join(" "); +}; + +let urlPropertyFilterIdCounter = 0; + +const parsePropertyFilter = (raw: string): PropertyFilter | null => { + const [baseUrl, kind, operator, ...valueParts] = raw.split(FIELD_SEPARATOR); + + if (baseUrl === undefined || !isBaseUrl(baseUrl)) { + return null; + } + + if (kind === undefined || !isFilterValueKind(kind)) { + return null; + } + + if ( + operator === undefined || + !getOperatorDescriptor(kind, operator as PropertyFilterOperator) + ) { + return null; + } + + urlPropertyFilterIdCounter += 1; + + return { + id: `url-property-filter-${urlPropertyFilterIdCounter}`, + baseUrl, + title: titleFromBaseUrl(baseUrl), + kind, + operator: operator as PropertyFilterOperator, + value: valueParts.length ? valueParts.join(FIELD_SEPARATOR) : undefined, + }; +}; + +const serializePropertyFilter = ({ + baseUrl, + kind, + operator, + value, +}: PropertyFilter): string => { + const fields: string[] = [baseUrl, kind, operator]; + if (value !== undefined) { + fields.push(value); + } + return fields.join(FIELD_SEPARATOR); +}; + +const parseWebState = ({ + websValue, + otherWebsValue, + internalWebIds, +}: { + websValue: string | undefined; + otherWebsValue: string | undefined; + internalWebIds: WebId[]; +}): EntitiesFilterState["web"] => { + const includeOtherWebs = otherWebsValue === TRUE_TOKEN; + + let selectedInternalWebIds: Set; + + if (websValue === undefined) { + selectedInternalWebIds = new Set(internalWebIds); + } else if (websValue === NONE_TOKEN) { + selectedInternalWebIds = new Set(); + } else { + const internalWebIdSet = new Set(internalWebIds); + selectedInternalWebIds = new Set( + websValue + .split(LIST_SEPARATOR) + .filter((id): id is WebId => internalWebIdSet.has(id as WebId)), + ); + } + + return { selectedInternalWebIds, includeOtherWebs }; +}; + +const parseTypeState = ( + typesValue: string | undefined, +): EntitiesFilterState["type"] => { + if (typesValue === undefined) { + return { selectedTypeIds: null }; + } + + if (typesValue === NONE_TOKEN) { + return { selectedTypeIds: new Set() }; + } + + return { + selectedTypeIds: new Set( + typesValue + .split(LIST_SEPARATOR) + .filter((id) => id.length > 0) as VersionedUrl[], + ), + }; +}; + +/** + * Builds an {@link EntitiesFilterState} from the URL query, falling back to the + * defaults (all webs, all types, archived hidden, no property filters) for any + * absent or malformed parameter. + * + * When the type is pinned (the visualizer is scoped to a specific entity type) + * the type dimension is ignored, mirroring how the type filter pill is hidden. + */ +export const parseFilterStateFromQuery = ({ + query, + internalWebIds, + isTypePinned, +}: { + query: UrlQuery; + internalWebIds: WebId[]; + isTypePinned: boolean; +}): EntitiesFilterState => ({ + web: parseWebState({ + websValue: getSingleValue(query[WEBS_KEY]), + otherWebsValue: getSingleValue(query[OTHER_WEBS_KEY]), + internalWebIds, + }), + type: isTypePinned + ? { selectedTypeIds: null } + : parseTypeState(getSingleValue(query[TYPES_KEY])), + includeArchived: getSingleValue(query[ARCHIVED_KEY]) === TRUE_TOKEN, + propertyFilters: getMultiValue(query[PROPERTY_FILTER_KEY]) + .map(parsePropertyFilter) + .filter((filter): filter is PropertyFilter => filter !== null), +}); + +/** + * Serializes an {@link EntitiesFilterState} to a map of (decoded) query + * parameter values, omitting any dimension that is at its default value so that + * a pristine filter state produces a clean URL. Property filters are emitted as + * a repeated parameter, one entry per filter. + */ +export const serializeFilterStateToQuery = ({ + filterState, + internalWebIds, + isTypePinned, +}: { + filterState: EntitiesFilterState; + internalWebIds: WebId[]; + isTypePinned: boolean; +}): Record => { + const values: Record = {}; + + const { selectedInternalWebIds, includeOtherWebs } = filterState.web; + + const selectedValidWebIds = internalWebIds.filter((id) => + selectedInternalWebIds.has(id), + ); + const allWebsSelected = selectedValidWebIds.length === internalWebIds.length; + + if (!allWebsSelected) { + values[WEBS_KEY] = + selectedValidWebIds.length === 0 + ? NONE_TOKEN + : selectedValidWebIds.join(LIST_SEPARATOR); + } + + if (includeOtherWebs) { + values[OTHER_WEBS_KEY] = TRUE_TOKEN; + } + + if (!isTypePinned) { + const { selectedTypeIds } = filterState.type; + + if (selectedTypeIds !== null) { + values[TYPES_KEY] = + selectedTypeIds.size === 0 + ? NONE_TOKEN + : [...selectedTypeIds].join(LIST_SEPARATOR); + } + } + + if (filterState.includeArchived) { + values[ARCHIVED_KEY] = TRUE_TOKEN; + } + + if (filterState.propertyFilters.length > 0) { + values[PROPERTY_FILTER_KEY] = filterState.propertyFilters.map( + serializePropertyFilter, + ); + } + + return values; +}; + +/** + * Merges the given filter parameter values into an existing `asPath`, leaving + * any non-filter parameters in place (see {@link applyQueryValuesToAsPath}). + * Returns whether the resulting path differs so callers can avoid redundant + * navigations. + */ +export const applyFilterValuesToAsPath = ({ + asPath, + filterValues, +}: { + asPath: string; + filterValues: Record; +}): { changed: boolean; nextAsPath: string } => + applyQueryValuesToAsPath({ + asPath, + ownedKeys: filterStateQueryKeys, + values: filterValues, + }); diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-table-data/generate-table-data-from-rows.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/generate-table-data-from-rows.ts similarity index 100% rename from apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-table-data/generate-table-data-from-rows.ts rename to apps/hash-frontend/src/pages/shared/entities-visualizer/shared/generate-table-data-from-rows.ts diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-available-types.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-available-types.ts index fc097b891b4..442a6260c34 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-available-types.ts +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-available-types.ts @@ -63,18 +63,26 @@ export const useAvailableTypes = ({ return null; }, [entityTypeBaseUrl, entityTypeIds, entityTypes]); + /** + * Only the web scope and archive toggle affect which types are AVAILABLE + * (type selections and property filters narrow within them), so depend on + * those two fields rather than the whole filter state -- other filter + * changes shouldn't refetch the summary. + */ + const { web: webFilter, includeArchived } = filterState; + const filter = useMemo( () => buildEntitiesFilter({ filterState: { - web: filterState.web, + web: webFilter, type: { selectedTypeIds: null }, - includeArchived: filterState.includeArchived, + includeArchived, propertyFilters: [], }, internalWebIds: internalWebs.map(({ webId }) => webId), }), - [filterState, internalWebs], + [webFilter, includeArchived, internalWebs], ); const { data, loading } = useQuery< diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-url-synced-filter-state.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-url-synced-filter-state.ts new file mode 100644 index 00000000000..cff4bcb9c13 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-url-synced-filter-state.ts @@ -0,0 +1,68 @@ +import { useRouter } from "next/router"; +import { useEffect, useMemo, useState } from "react"; + +import { createDefaultFilterState } from "./filter-state"; +import { + applyFilterValuesToAsPath, + parseFilterStateFromQuery, + serializeFilterStateToQuery, +} from "./filter-state-url"; + +import type { EntitiesFilterState } from "./filter-state"; +import type { WebId } from "@blockprotocol/type-system"; +import type { Dispatch, SetStateAction } from "react"; + +/** + * Owns the entities visualizer filter state, optionally persisting it to (and + * hydrating it from) the URL query string. + * + * When `enabled`, the initial state is read from the current URL and any + * subsequent change is written back via a shallow `router.replace`, so that + * refreshing, bookmarking, or sharing the page preserves the active filters. + * When disabled, this behaves like a plain `useState` seeded with the defaults. + */ +export const useUrlSyncedFilterState = ({ + enabled, + internalWebs, + isTypePinned, +}: { + enabled: boolean; + internalWebs: { webId: WebId }[]; + isTypePinned: boolean; +}): [EntitiesFilterState, Dispatch>] => { + const { asPath, query, replace } = useRouter(); + + const internalWebIds = useMemo( + () => internalWebs.map(({ webId }) => webId), + [internalWebs], + ); + + const [filterState, setFilterState] = useState(() => + enabled + ? parseFilterStateFromQuery({ query, internalWebIds, isTypePinned }) + : createDefaultFilterState(internalWebIds), + ); + + useEffect(() => { + if (!enabled) { + return; + } + + const filterValues = serializeFilterStateToQuery({ + filterState, + internalWebIds, + isTypePinned, + }); + + const { changed, nextAsPath } = applyFilterValuesToAsPath({ + asPath, + filterValues, + }); + + if (changed) { + void replace(nextAsPath, undefined, { shallow: true, scroll: false }); + } + }, [enabled, filterState, internalWebIds, isTypePinned, asPath, replace]); + + return [filterState, setFilterState]; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-query-state.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-query-state.ts new file mode 100644 index 00000000000..98ca59bc080 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-query-state.ts @@ -0,0 +1,184 @@ +import { useMemo, useState } from "react"; + +import { isBaseUrl } from "@blockprotocol/type-system"; +import { typedEntries } from "@local/advanced-types/typed-entries"; + +import { serializeFilterStateToQuery } from "./shared/filter-state-url"; +import { useUrlSyncedFilterState } from "./shared/use-url-synced-filter-state"; + +import type { ColumnSort } from "../../../components/grid/utils/sorting"; +import type { VisualizerView } from "../visualizer-views"; +import type { SortableEntitiesTableColumnKey } from "./entities-table-data"; +import type { BaseUrl, VersionedUrl, WebId } from "@blockprotocol/type-system"; +import type { + EntityQuerySortingPath, + EntityQuerySortingRecord, + EntityQuerySortingToken, + NullOrdering, + Ordering, +} from "@local/hash-graph-client"; +import type { ConversionRequest } from "@local/hash-graph-sdk/entity"; + +export type EntitiesTableSort = ColumnSort & { + /** Sort on the property's value converted to this data type (e.g. sort heights in metres). */ + convertTo?: BaseUrl; +}; + +/** Per-column unit conversions, e.g. display a "Height" column in metres. */ +export type ActiveConversions = { + [columnBaseUrl: BaseUrl]: VersionedUrl; +}; + +const generateGraphSort = ( + columnKey: SortableEntitiesTableColumnKey, + direction: "asc" | "desc", + convertTo?: BaseUrl, +): EntityQuerySortingRecord => { + const nulls: NullOrdering = direction === "asc" ? "last" : "first"; + const ordering: Ordering = direction === "asc" ? "ascending" : "descending"; + + let path: EntityQuerySortingPath; + + switch (columnKey) { + case "entityLabel": + path = ["label" satisfies EntityQuerySortingToken]; + break; + case "lastEdited": + path = [ + "editionCreatedAtTransactionTime" satisfies EntityQuerySortingToken, + ]; + break; + case "created": + path = ["createdAtTransactionTime" satisfies EntityQuerySortingToken]; + break; + case "entityTypes": + path = ["typeTitle" satisfies EntityQuerySortingToken]; + break; + case "archived": + path = ["archived" satisfies EntityQuerySortingToken]; + break; + default: { + if (!isBaseUrl(columnKey)) { + throw new Error(`Unexpected sorting column key: ${columnKey}`); + } + path = ["properties" satisfies EntityQuerySortingToken, columnKey]; + + if (convertTo) { + path.push("convert", convertTo); + } + } + } + + return { + path, + nulls, + ordering, + }; +}; + +/** + * Owns every user-controlled input that shapes the entities query: filter + * state (optionally persisted to the URL), sort, per-column unit conversions, + * and the selected view. All setters returned by this hook are plain state + * setters with stable identities. + * + * Pagination is NOT owned here: cursors are continuations of responses, so + * they live with the fetched pages in {@link useEntitiesVisualizerData}, + * which restarts from page one whenever these inputs change. + */ +export const useEntitiesQueryState = ({ + entityTypeBaseUrl, + entityTypeId, + internalWebs, + isTypePinned, + persistFilterStateInUrl, +}: { + entityTypeBaseUrl?: BaseUrl; + entityTypeId?: VersionedUrl; + internalWebs: { webId: WebId }[]; + isTypePinned: boolean; + persistFilterStateInUrl: boolean; +}) => { + const [filterState, setFilterState] = useUrlSyncedFilterState({ + enabled: persistFilterStateInUrl, + internalWebs, + isTypePinned, + }); + + const [sort, setSort] = useState({ + columnKey: "entityLabel", + direction: "asc", + }); + + const [activeConversions, setActiveConversions] = + useState(null); + + /** + * The view the user explicitly picked, or `null` if they haven't -- in which + * case the displayed view is derived from the data (see useVisualizerView). + */ + const [selectedView, setSelectedView] = useState(null); + + /** + * The view as far as the server query is concerned. Table and Grid issue + * identical queries, and the data-derived default is never Graph, so only + * an explicit Graph selection changes the request. + */ + const queryView: VisualizerView = selectedView ?? "Table"; + + const graphSort = useMemo( + () => generateGraphSort(sort.columnKey, sort.direction, sort.convertTo), + [sort], + ); + + const conversionRequests = useMemo( + () => + activeConversions + ? typedEntries(activeConversions).map( + ([columnBaseUrl, dataTypeId]) => ({ + path: [columnBaseUrl], + dataTypeId, + }), + ) + : undefined, + [activeConversions], + ); + + /** + * Identity of the displayed entity SET: the type scoping plus the canonical + * (URL-grade) filter serialization. Pagination and sort/conversions don't + * change which entities are shown (only how many so far / in what order), + * so they're excluded. A change here means the entity set was REPLACED, not + * extended -- the graph visualizer uses it to purge and re-ingest rather + * than append stale data, and the root uses it to drop row selections that + * referred to the previous set. + */ + const entitySetKey = useMemo( + () => + JSON.stringify({ + entityTypeBaseUrl, + entityTypeId, + filter: serializeFilterStateToQuery({ + filterState, + internalWebIds: internalWebs.map(({ webId }) => webId), + isTypePinned, + }), + }), + [entityTypeBaseUrl, entityTypeId, filterState, internalWebs, isTypePinned], + ); + + return { + activeConversions, + conversionRequests, + entitySetKey, + filterState, + graphSort, + queryView, + selectedView, + setActiveConversions, + setFilterState, + setSelectedView, + setSort, + sort, + }; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-table-data.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-table-data.tsx deleted file mode 100644 index b83b41cf712..00000000000 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-table-data.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useCallback, useState } from "react"; - -import { isBaseUrl } from "@blockprotocol/type-system"; -import { typedEntries } from "@local/advanced-types/typed-entries"; -import { serializeSubgraph } from "@local/hash-graph-sdk/subgraph"; - -import { generateTableDataFromRows } from "./use-entities-table-data/generate-table-data-from-rows"; - -import type { - EntitiesTableColumn, - EntitiesTableData, - EntitiesTableRow, - UpdateTableDataFn, - VisibleDataTypeIdsByPropertyBaseUrl, -} from "./entities-table-data"; -import type { EntitiesVisualizerData } from "./use-entities-visualizer-data"; -import type { ClosedMultiEntityTypesRootMap } from "@local/hash-graph-sdk/ontology"; - -export const useEntitiesTableData = ({ - hideColumns, - hideArchivedColumn, -}: { - hideColumns?: (keyof EntitiesTableRow)[]; - hideArchivedColumn?: boolean; -}): { - tableData: EntitiesTableData | null; - updateTableData: UpdateTableDataFn; -} => { - const [tableData, setTableData] = useState(null); - - const updateTableData = useCallback( - ({ - appendRows, - closedMultiEntityTypesRootMap, - definitions, - entities, - subgraph, - }: Pick & { - appendRows: boolean; - closedMultiEntityTypesRootMap: ClosedMultiEntityTypesRootMap; - }) => { - if (!definitions) { - throw new Error("Definitions are required"); - } - - if (!entities) { - throw new Error("Entities are required"); - } - - if (!subgraph) { - throw new Error("Subgraph is required"); - } - - const resultFromRows = generateTableDataFromRows({ - closedMultiEntityTypesRootMap, - definitions, - entities: entities.map((entity) => entity.toJSON()), - subgraph: serializeSubgraph(subgraph), - hideColumns, - hideArchivedColumn, - }); - - setTableData((currentTableData) => { - if (appendRows && currentTableData) { - /** - * When paginating, append rows and merge the per-row metadata needed - * to render the accumulated table. Filter state and available filter - * options are derived from the whole result set, not from visible rows here. - */ - - const combinedVisibleDataTypeIdsByPropertyBaseUrl: VisibleDataTypeIdsByPropertyBaseUrl = - resultFromRows.visibleDataTypeIdsByPropertyBaseUrl; - for (const [baseUrl, dataTypeIds] of typedEntries( - currentTableData.visibleDataTypeIdsByPropertyBaseUrl, - )) { - combinedVisibleDataTypeIdsByPropertyBaseUrl[baseUrl] ??= new Set(); - combinedVisibleDataTypeIdsByPropertyBaseUrl[baseUrl] = - combinedVisibleDataTypeIdsByPropertyBaseUrl[baseUrl].union( - dataTypeIds, - ); - } - - const combinedEntityTypesWithMultipleVersionsPresent = new Set([ - ...currentTableData.entityTypesWithMultipleVersionsPresent, - ...resultFromRows.entityTypesWithMultipleVersionsPresent, - ]); - - const addedColumnIds: Set = new Set(); - const combinedColumns: EntitiesTableColumn[] = []; - - for (const column of [ - ...currentTableData.columns, - ...resultFromRows.columns, - ]) { - if (addedColumnIds.has(column.id)) { - continue; - } - - addedColumnIds.add(column.id); - - combinedColumns.push(column); - } - - return { - rows: [...currentTableData.rows, ...resultFromRows.rows], - /** - * Each page's response only carries the data types referenced by - * that page's entities, so we union the pools to keep every - * accumulated row resolvable. - */ - dataTypeDefinitions: { - ...currentTableData.dataTypeDefinitions, - ...resultFromRows.dataTypeDefinitions, - }, - columns: combinedColumns.sort((a, b) => { - /** - * The first page might not have source and target columns added (if there are no links), but a later one will. - * We want source and target columns to come before the property columns, so we sort property columns to the end. - */ - - const isAPropertyColumn = isBaseUrl(a.id); - const isBPropertyColumn = isBaseUrl(b.id); - - if (isAPropertyColumn && !isBPropertyColumn) { - return 1; - } - - if (!isAPropertyColumn && isBPropertyColumn) { - return -1; - } - - if (isAPropertyColumn && isBPropertyColumn) { - return a.title.localeCompare(b.title); - } - - return 0; - }), - entityTypesWithMultipleVersionsPresent: - combinedEntityTypesWithMultipleVersionsPresent, - visibleDataTypeIdsByPropertyBaseUrl: - combinedVisibleDataTypeIdsByPropertyBaseUrl, - }; - } - - // This is the first page, so return the result without combining. - return { - ...resultFromRows, - columns: resultFromRows.columns, - rows: resultFromRows.rows, - }; - }); - }, - [hideArchivedColumn, hideColumns], - ); - - return { - tableData, - updateTableData, - }; -}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.ts new file mode 100644 index 00000000000..96ee7833df7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.ts @@ -0,0 +1,512 @@ +import { useQuery } from "@apollo/client"; +import { useCallback, useMemo, useState } from "react"; + +import { getLatestEntityVertices, getRoots } from "@blockprotocol/graph/stdlib"; +import { + type ConversionRequest, + deserializeQueryEntitySubgraphResponse, + type HashEntity, +} from "@local/hash-graph-sdk/entity"; +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; + +import { + queryEntitySubgraphQuery, + summarizeEntitiesQuery, +} from "../../../graphql/queries/knowledge/entity.queries"; +import { useMemoCompare } from "../../../shared/use-memo-compare"; +import { buildEntitiesFilter } from "./shared/build-filter"; +import { generateTableDataFromRows } from "./shared/generate-table-data-from-rows"; +import { traversalPathsForView } from "./shared/traversal-paths"; +import { + mergeClosedMultiEntityTypesRootMaps, + mergeDefinitions, +} from "./use-entities-visualizer-data/merge-page-data"; +import { mergeTableData } from "./use-entities-visualizer-data/merge-table-data"; +import { + advancePageChain, + type ChainPage, + growPageWindow, + type PageChain, +} from "./use-entities-visualizer-data/page-chain"; + +import type { + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables, + SummarizeEntitiesQuery, + SummarizeEntitiesQueryVariables, +} from "../../../graphql/api-types.gen"; +import type { VisualizerView } from "../visualizer-views"; +import type { + EntitiesTableData, + EntitiesTableRow, +} from "./entities-table-data"; +import type { EntitiesFilterState } from "./shared/filter-state"; +import type { ApolloError } from "@apollo/client"; +import type { EntityRootType, Subgraph } from "@blockprotocol/graph"; +import type { + BaseUrl, + EntityId, + VersionedUrl, + WebId, +} from "@blockprotocol/type-system"; +import type { + EntityQueryCursor, + EntityQuerySortingRecord, + Filter, + TraversalPath, +} from "@local/hash-graph-client"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; + +type SubgraphResponse = QueryEntitySubgraphQuery["queryEntitySubgraph"]; + +/** + * One captured page of results. The response is deserialized exactly once, + * when the page is captured into the chain. + */ +interface ResultPage extends ChainPage { + readonly definitions: ClosedMultiEntityTypesDefinitions; + readonly rootEntities: HashEntity[]; + readonly rootMap: ClosedMultiEntityTypesRootMap; + readonly subgraph: Subgraph, HashEntity>; +} + +/** + * The inputs (excluding pagination) that define one query. Compared by object + * identity: a new identity means the accumulated pages describe a different + * result set and are discarded once the first page for the new inputs + * arrives. + */ +interface QueryInputsIdentity { + readonly conversions: ConversionRequest[] | undefined; + readonly filter: Filter; + readonly limit: number; + readonly sort: EntityQuerySortingRecord | undefined; + readonly traversalPaths: TraversalPath[]; +} + +export type EntitiesVisualizerData = { + /** + * Whether a request for newer results is in flight. While `true` and the + * status is "ready", the ready fields are the previous request's results, + * kept available so consumers can keep showing them (with a loading + * indicator) instead of flashing an empty state. + */ + readonly fetching: boolean; + /** From the independent summary query; available regardless of status. */ + readonly totalResultCount: number | null; +} & ( + | { readonly status: "loading" } + | { + readonly status: "error"; + readonly error: ApolloError; + readonly retry: () => void; + } + | { + readonly status: "ready"; + readonly closedMultiEntityTypesRootMap: ClosedMultiEntityTypesRootMap; + readonly definitions: ClosedMultiEntityTypesDefinitions; + /** + * All accumulated pages' results. For Graph-view queries this is every + * fetched vertex (the query roots plus the frontier link-endpoints + * the traversal pulled in); otherwise it is the roots only. + */ + readonly entities: HashEntity[]; + /** EntityIds of the query roots. `entities` not in this set are frontier nodes. */ + readonly rootEntityIds: EntityId[]; + readonly tableData: EntitiesTableData; + /** + * Fetches the next page, appending it to the accumulated results. + * Undefined when there is no next page. + */ + readonly fetchNextPage: (() => void) | undefined; + readonly hasNextPage: boolean; + /** + * Refetches from page one, e.g. after a bulk action changed the + * underlying entities. The pagination window is kept: pages the user + * had expanded to are refetched fresh rather than dropped. + */ + readonly refresh: () => void; + } +); + +interface UseEntitiesVisualizerData { + readonly conversions?: ConversionRequest[]; + readonly entityTypeBaseUrl?: BaseUrl; + readonly entityTypeIds?: VersionedUrl[]; + readonly filterState: EntitiesFilterState; + readonly hideColumns?: (keyof EntitiesTableRow)[]; + readonly internalWebs: { webId: WebId }[]; + readonly limit: number; + readonly sort?: EntityQuerySortingRecord; + readonly view: VisualizerView; +} + +/** + * Fetches the entities matching the given query inputs, accumulating "Show + * more" pages so that every view (table, grid, graph) renders the same, + * complete window of results. + * + * Pagination is owned here rather than by the caller because a cursor is + * only meaningful relative to responses: pages are stored together with the + * identity of the inputs they were fetched for ({@link QueryInputsIdentity}), + * so any input change automatically restarts from page one; there is no + * pagination state for callers to remember to reset. The previous inputs' + * pages remain on screen (with `fetching: true`) until the first page of the + * new query arrives. + * + * The pagination window (how many pages the user expanded to) is scoped to + * the entity set, not to the full input identity: when a view switch, sort, + * or conversion change forces a from-page-one refetch of the same set, the + * rebuilt chain auto-fetches page after page until it is back at the window + * (see {@link PageChain.windowKey}). A filter change is a different set and + * resets the window. + */ +export const useEntitiesVisualizerData = ({ + conversions, + entityTypeBaseUrl, + entityTypeIds, + filterState, + hideColumns, + internalWebs, + limit, + sort, + view, +}: UseEntitiesVisualizerData): EntitiesVisualizerData => { + const internalWebIds = useMemo( + () => internalWebs.map(({ webId }) => webId), + [internalWebs], + ); + + const filter = useMemo( + () => + buildEntitiesFilter({ + filterState, + internalWebIds, + pinnedEntityTypeBaseUrl: entityTypeBaseUrl, + pinnedEntityTypeIds: entityTypeIds, + }), + [filterState, internalWebIds, entityTypeBaseUrl, entityTypeIds], + ); + + /** + * A module constant per view "shape": Table and Grid share the same paths + * (and therefore the same query identity), so switching between them keeps + * the accumulated pages. + */ + const traversalPaths = traversalPathsForView(view); + + /** + * Identity of the entity set the pagination window belongs to (see + * {@link PageChain.windowKey}): the filter (which embeds any pinned type) + * plus the page size. Everything else about the query (traversal paths, + * sort, conversions) re-shapes or re-orders the same set, so the window + * survives it: a Graph-view switch or a re-sort refetches the pages the + * user had already expanded to, instead of collapsing back to page one. + */ + const windowKey = useMemo( + () => JSON.stringify({ filter, limit }), + [filter, limit], + ); + + /** + * The page chain's convergence relies on this object keeping its identity + * for unchanged inputs: every field is a memo, a module constant, or a + * primitive. An unstable field here would discard the chain every render. + */ + const queryInputs = useMemo( + () => ({ + conversions, + filter, + limit, + sort, + traversalPaths, + }), + [conversions, filter, limit, sort, traversalPaths], + ); + + const [chain, setChain] = useState | null>(null); + + /** + * The cursor to apply to the query: only a chain issued for the current + * inputs may continue paginating; otherwise we are (re)fetching page one. + */ + const requestedCursor = + chain !== null && chain.issuedFor === queryInputs + ? chain.activeCursor + : undefined; + + const variables = useMemo( + () => ({ + request: { + conversions: queryInputs.conversions, + cursor: requestedCursor, + limit: queryInputs.limit, + filter: queryInputs.filter, + traversalPaths: queryInputs.traversalPaths, + sortingPaths: queryInputs.sort ? [queryInputs.sort] : undefined, + /** + * @todo H-2633 when we use entity archival via timestamp, this will + * need varying to include archived entities. + */ + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: false, + includeEntityTypes: "resolvedWithDataTypeChildren", + includePermissions: false, + }, + }), + [queryInputs, requestedCursor], + ); + + const { data, error, loading, refetch } = useQuery< + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables + >(queryEntitySubgraphQuery, { + fetchPolicy: "cache-and-network", + variables, + }); + + const buildPage = useCallback( + ( + pageResponse: SubgraphResponse, + forCursor: EntityQueryCursor | undefined, + ): ResultPage => { + const { subgraph } = deserializeQueryEntitySubgraphResponse(pageResponse); + + const pageDefinitions = pageResponse.definitions; + + if (!pageDefinitions) { + // The query always sets `includeEntityTypes`, so this cannot happen. + throw new Error("Expected definitions in queryEntitySubgraph response"); + } + + return { + sourceResponse: pageResponse, + forCursor, + definitions: pageDefinitions, + nextCursor: pageResponse.cursor ?? null, + rootEntities: getRoots(subgraph), + rootMap: pageResponse.closedMultiEntityTypes ?? {}, + subgraph, + }; + }, + [], + ); + + /** + * Capture the latest response into the chain during render (the React + * "adjust state while rendering" pattern). `data` always corresponds to the + * current variables (Apollo clears it while a request for new variables is + * in flight), so a defined response can be attributed to + * (`queryInputs`, `requestedCursor`) without bookkeeping in a completion + * callback. Everything below derives from `displayedChain`, which keeps + * showing the previous inputs' pages until the new first page lands. + */ + const displayedChain = advancePageChain({ + buildPage, + chain, + identity: queryInputs, + requestedCursor, + response: data?.queryEntitySubgraph, + windowKey, + }); + + if (displayedChain !== chain) { + setChain(displayedChain); + } + + const pages = displayedChain?.pages ?? null; + + const chainIsForGraphView = + displayedChain !== null && + displayedChain.issuedFor.traversalPaths === traversalPathsForView("Graph"); + + const entities = useMemo(() => { + if (!pages) { + return null; + } + + if (chainIsForGraphView) { + // Frontier vertices can recur across pages; dedupe on id, later pages winning. + const entityById = new Map(); + + for (const page of pages) { + for (const vertex of getLatestEntityVertices(page.subgraph)) { + entityById.set(vertex.inner.metadata.recordId.entityId, vertex.inner); + } + } + + return [...entityById.values()]; + } + + // Roots are disjoint across pages (cursor pagination), so concatenation suffices. + return pages.flatMap((page) => page.rootEntities); + }, [pages, chainIsForGraphView]); + + const rootEntityIds = useMemo(() => { + if (!pages) { + return null; + } + + return pages.flatMap((page) => + page.rootEntities.map((entity) => entity.metadata.recordId.entityId), + ); + }, [pages]); + + const closedMultiEntityTypesRootMap = useMemo( + () => + pages + ? mergeClosedMultiEntityTypesRootMaps(pages.map((page) => page.rootMap)) + : null, + [pages], + ); + + const definitions = useMemo( + () => + pages ? mergeDefinitions(pages.map((page) => page.definitions)) : null, + [pages], + ); + + /** + * `hideColumns` is intentionally not part of the query identity (callers + * may pass inline arrays); stabilize it here so the table-data memo doesn't + * regenerate for an identical value. + */ + const stableHideColumns = useMemoCompare( + () => hideColumns, + [hideColumns], + (oldValue, newValue) => + oldValue === newValue || + (oldValue !== undefined && + newValue !== undefined && + oldValue.length === newValue.length && + oldValue.every((column, index) => column === newValue[index])), + ); + + const hideArchivedColumn = !filterState.includeArchived; + + /** + * Derived from the pages rather than imperatively appended to, so the table + * can never drift from what was fetched. Graph-view pages produce table + * data too (their roots are exactly the rows a Table query would return), + * so switching Graph -> Table shows rows instantly while the table-shaped + * query loads. + */ + const tableData = useMemo(() => { + if (!pages) { + return null; + } + + return pages + .map((page) => + generateTableDataFromRows({ + closedMultiEntityTypesRootMap: page.rootMap, + definitions: page.definitions, + entities: page.rootEntities.map((entity) => entity.toJSON()), + subgraph: page.sourceResponse.subgraph, + hideColumns: stableHideColumns, + hideArchivedColumn, + }), + ) + .reduce(mergeTableData); + }, [pages, stableHideColumns, hideArchivedColumn]); + + const nextCursor = pages?.at(-1)?.nextCursor ?? null; + + const fetchNextPage = useMemo(() => { + if (nextCursor === null) { + return undefined; + } + + return () => { + setChain((previousChain) => { + // Guard against a click racing an input change: only the chain the + // button belonged to may paginate. + if (!previousChain || previousChain.issuedFor !== queryInputs) { + return previousChain; + } + + return growPageWindow(previousChain); + }); + }; + }, [nextCursor, queryInputs]); + + const refresh = useCallback(() => { + if (requestedCursor === undefined) { + // Already on page one: the variables won't change, so force the + // network round-trip explicitly. + void refetch(); + } else { + // Clearing the cursor changes the query variables; cache-and-network + // then hits the network, and the arriving first page rebuilds the + // chain. The kept window then re-chases the later pages against the + // refreshed result set. + setChain((previousChain) => + previousChain + ? { ...previousChain, activeCursor: undefined } + : previousChain, + ); + } + }, [refetch, requestedCursor]); + + const retry = useCallback(() => { + void refetch(); + }, [refetch]); + + const { data: summaryData, previousData: previousSummaryData } = useQuery< + SummarizeEntitiesQuery, + SummarizeEntitiesQueryVariables + >(summarizeEntitiesQuery, { + variables: { + request: { + filter, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: false, + includeCount: true, + }, + }, + }); + + const totalResultCount = + (summaryData ?? previousSummaryData)?.summarizeEntities.count ?? null; + + if (error) { + return { + status: "error", + error, + retry, + fetching: loading, + totalResultCount, + }; + } + + if ( + displayedChain === null || + entities === null || + rootEntityIds === null || + tableData === null || + closedMultiEntityTypesRootMap === null || + definitions === null + ) { + return { status: "loading", fetching: loading, totalResultCount }; + } + + return { + status: "ready", + fetching: loading, + totalResultCount, + closedMultiEntityTypesRootMap, + definitions, + entities, + rootEntityIds, + tableData, + fetchNextPage, + hasNextPage: nextCursor !== null, + refresh, + }; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx deleted file mode 100644 index af55f4d430e..00000000000 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { useQuery } from "@apollo/client"; -import { useMemo } from "react"; - -import { getLatestEntityVertices, getRoots } from "@blockprotocol/graph/stdlib"; -import { - type ConversionRequest, - deserializeQueryEntitySubgraphResponse, - type HashEntity, -} from "@local/hash-graph-sdk/entity"; -import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; - -import { - queryEntitySubgraphQuery, - summarizeEntitiesQuery, -} from "../../../graphql/queries/knowledge/entity.queries"; -import { apolloClient } from "../../../lib/apollo-client"; -import { buildEntitiesFilter } from "./shared/build-filter"; -import { traversalPathsForView } from "./shared/traversal-paths"; -import { useEntitiesTableData } from "./use-entities-table-data"; - -import type { - QueryEntitySubgraphQuery, - QueryEntitySubgraphQueryVariables, - SummarizeEntitiesQuery, - SummarizeEntitiesQueryVariables, -} from "../../../graphql/api-types.gen"; -import type { VisualizerView } from "../visualizer-views"; -import type { - EntitiesTableData, - EntitiesTableRow, - UpdateTableDataFn, -} from "./entities-table-data"; -import type { EntitiesFilterState } from "./shared/filter-state"; -import type { ApolloQueryResult } from "@apollo/client"; -import type { EntityRootType, Subgraph } from "@blockprotocol/graph"; -import type { BaseUrl, VersionedUrl, WebId } from "@blockprotocol/type-system"; -import type { - EntityQueryCursor, - EntityQuerySortingRecord, -} from "@local/hash-graph-client"; - -export type EntitiesVisualizerData = Partial< - Pick< - QueryEntitySubgraphQuery["queryEntitySubgraph"], - "closedMultiEntityTypes" | "definitions" | "cursor" - > -> & { - entities?: HashEntity[]; - hadCachedContent: boolean; - loading: boolean; - /** - * Whether or not a network request is in process. - * Note that if is hasCachedContent is true, data for the given query is available before loading is complete. - * The cached content will be replaced automatically and the value updated when the network request completes. - */ - refetch: () => Promise>; - subgraph?: Subgraph>; - tableData: EntitiesTableData | null; - totalResultCount: number | null; - updateTableData: UpdateTableDataFn; -}; - -export const useEntitiesVisualizerData = (params: { - conversions?: ConversionRequest[]; - cursor?: EntityQueryCursor; - entityTypeBaseUrl?: BaseUrl; - entityTypeIds?: VersionedUrl[]; - filterState: EntitiesFilterState; - hideColumns?: (keyof EntitiesTableRow)[]; - internalWebs: { webId: WebId }[]; - limit?: number; - sort?: EntityQuerySortingRecord; - view: VisualizerView; -}): EntitiesVisualizerData => { - const { - conversions, - cursor, - entityTypeBaseUrl, - entityTypeIds, - filterState, - hideColumns, - internalWebs, - limit, - sort, - view, - } = params; - - const { tableData, updateTableData } = useEntitiesTableData({ - hideColumns, - hideArchivedColumn: !filterState.includeArchived, - }); - - const internalWebIds = useMemo( - () => internalWebs.map(({ webId }) => webId), - [internalWebs], - ); - - const filter = useMemo( - () => - buildEntitiesFilter({ - filterState, - internalWebIds, - pinnedEntityTypeBaseUrl: entityTypeBaseUrl, - pinnedEntityTypeIds: entityTypeIds, - }), - [filterState, internalWebIds, entityTypeBaseUrl, entityTypeIds], - ); - - const variables = useMemo( - () => ({ - request: { - conversions, - cursor, - limit, - filter, - traversalPaths: traversalPathsForView(view), - sortingPaths: sort ? [sort] : undefined, - /** - * @todo H-2633 when we use entity archival via timestamp, this will - * need varying to include archived entities. - */ - temporalAxes: currentTimeInstantTemporalAxes, - includeDrafts: false, - includeEntityTypes: "resolvedWithDataTypeChildren", - includePermissions: false, - }, - }), - [conversions, cursor, filter, limit, sort, view], - ); - - const { data: summaryData } = useQuery< - SummarizeEntitiesQuery, - SummarizeEntitiesQueryVariables - >(summarizeEntitiesQuery, { - variables: { - request: { - filter, - temporalAxes: currentTimeInstantTemporalAxes, - includeDrafts: false, - includeCount: true, - }, - }, - }); - - const { data, loading, refetch } = useQuery< - QueryEntitySubgraphQuery, - QueryEntitySubgraphQueryVariables - >(queryEntitySubgraphQuery, { - fetchPolicy: "cache-and-network", - onCompleted: (completedData) => { - if (view === "Graph") { - return; - } - - const newSubgraph = deserializeQueryEntitySubgraphResponse( - completedData.queryEntitySubgraph, - ).subgraph; - - const newEntities = getRoots(newSubgraph); - - updateTableData({ - appendRows: !!cursor, - closedMultiEntityTypesRootMap: - completedData.queryEntitySubgraph.closedMultiEntityTypes ?? {}, - definitions: completedData.queryEntitySubgraph.definitions, - entities: newEntities, - subgraph: newSubgraph, - }); - }, - variables, - }); - - const hadCachedContent = useMemo( - () => - !!apolloClient.readQuery({ query: queryEntitySubgraphQuery, variables }), - [variables], - ); - - const subgraph = useMemo( - () => - data?.queryEntitySubgraph - ? deserializeQueryEntitySubgraphResponse(data.queryEntitySubgraph) - .subgraph - : undefined, - [data?.queryEntitySubgraph], - ); - - const entities = useMemo( - () => - subgraph - ? view === "Graph" - ? getLatestEntityVertices(subgraph).map((vertex) => vertex.inner) - : getRoots(subgraph) - : undefined, - [subgraph, view], - ); - - return useMemo( - () => ({ - ...data?.queryEntitySubgraph, - entities, - hadCachedContent, - loading, - refetch, - subgraph, - tableData, - totalResultCount: summaryData?.summarizeEntities.count ?? null, - updateTableData, - }), - [ - data?.queryEntitySubgraph, - summaryData?.summarizeEntities, - entities, - hadCachedContent, - loading, - refetch, - subgraph, - tableData, - updateTableData, - ], - ); -}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/merge-page-data.test.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/merge-page-data.test.ts new file mode 100644 index 00000000000..a3322cf9cd5 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/merge-page-data.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { + mergeClosedMultiEntityTypesRootMaps, + mergeDefinitions, +} from "./merge-page-data"; + +import type { ClosedMultiEntityTypeMap } from "@local/hash-graph-client"; +import type { ClosedMultiEntityTypesDefinitions } from "@local/hash-graph-sdk/ontology"; + +const schemaFor = (label: string) => + ({ label }) as unknown as ClosedMultiEntityTypeMap["schema"]; + +describe("mergeClosedMultiEntityTypesRootMaps", () => { + it("returns a single map unchanged (same reference)", () => { + const map = { typeA: { schema: schemaFor("A") } }; + + expect(mergeClosedMultiEntityTypesRootMaps([map])).toBe(map); + }); + + it("merges nested branches recursively", () => { + const first = { + typeA: { + schema: schemaFor("A"), + inner: { typeB: { schema: schemaFor("A+B") } }, + }, + }; + + const second = { + typeA: { + schema: schemaFor("A"), + inner: { typeC: { schema: schemaFor("A+C") } }, + }, + typeD: { schema: schemaFor("D") }, + }; + + const merged = mergeClosedMultiEntityTypesRootMaps([first, second]); + + expect(Object.keys(merged).toSorted()).toEqual(["typeA", "typeD"]); + expect(Object.keys(merged.typeA!.inner!).toSorted()).toEqual([ + "typeB", + "typeC", + ]); + expect(merged.typeA!.inner!.typeB!.schema).toBe( + first.typeA.inner.typeB.schema, + ); + expect(merged.typeA!.inner!.typeC!.schema).toBe( + second.typeA.inner.typeC.schema, + ); + expect(merged.typeD!.schema).toBe(second.typeD.schema); + }); +}); + +describe("mergeDefinitions", () => { + it("returns a single definition set unchanged (same reference)", () => { + const definitions = { + dataTypes: {}, + entityTypes: {}, + propertyTypes: {}, + } as ClosedMultiEntityTypesDefinitions; + + expect(mergeDefinitions([definitions])).toBe(definitions); + }); + + it("unions each definition pool, later pages winning on conflicts", () => { + const dataTypeA = { name: "a" }; + const dataTypeB = { name: "b" }; + const dataTypeBNewer = { name: "b-newer" }; + const propertyType = { name: "p" }; + const entityType = { name: "e" }; + + const first = { + dataTypes: { "https://a/": dataTypeA, "https://b/": dataTypeB }, + entityTypes: {}, + propertyTypes: { "https://p/": propertyType }, + } as unknown as ClosedMultiEntityTypesDefinitions; + + const second = { + dataTypes: { "https://b/": dataTypeBNewer }, + entityTypes: { "https://e/": entityType }, + propertyTypes: {}, + } as unknown as ClosedMultiEntityTypesDefinitions; + + const merged = mergeDefinitions([first, second]); + + expect(merged.dataTypes).toEqual({ + "https://a/": dataTypeA, + "https://b/": dataTypeBNewer, + }); + expect(merged.entityTypes).toEqual({ "https://e/": entityType }); + expect(merged.propertyTypes).toEqual({ "https://p/": propertyType }); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/merge-page-data.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/merge-page-data.ts new file mode 100644 index 00000000000..3927b76c1cd --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/merge-page-data.ts @@ -0,0 +1,83 @@ +/** + * Pure helpers for combining the type metadata of multiple result pages. + * + * Each page's response only carries the closed types / definitions referenced + * by that page's entities, so displaying the union of pages requires the + * union of their metadata too. + */ +import type { ClosedMultiEntityTypeMap } from "@local/hash-graph-client"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; + +const mergeInnerMaps = ( + base: Record | undefined, + next: Record | undefined, +): Record | undefined => { + if (!base || !next) { + return base ?? next; + } + + const merged = { ...base }; + + for (const [entityTypeId, map] of Object.entries(next)) { + const existing = merged[entityTypeId]; + + if (existing) { + const inner = mergeInnerMaps(existing.inner, map.inner); + + // The key path to this node is the entity type combination it + // describes, so the two schemas are equivalent -- keep the newer one. + merged[entityTypeId] = inner + ? { schema: map.schema, inner } + : { schema: map.schema }; + } else { + merged[entityTypeId] = map; + } + } + + return merged; +}; + +/** + * Merges the nested closed multi-entity-type maps of several pages, so that + * {@link getClosedMultiEntityTypeFromMap} resolves for every entity across + * all of them. Branches are merged recursively: two pages may share a first + * type but nest different second types under it. + */ +export const mergeClosedMultiEntityTypesRootMaps = ( + maps: ClosedMultiEntityTypesRootMap[], +): ClosedMultiEntityTypesRootMap => { + if (maps.length === 1) { + return maps[0]!; + } + + return maps.reduce( + (merged, map) => mergeInnerMaps(merged, map) ?? {}, + {}, + ); +}; + +/** Merges the data/property/entity type definition pools of several pages. */ +export const mergeDefinitions = ( + definitionSets: ClosedMultiEntityTypesDefinitions[], +): ClosedMultiEntityTypesDefinitions => { + if (definitionSets.length === 1) { + return definitionSets[0]!; + } + + const merged: ClosedMultiEntityTypesDefinitions = { + dataTypes: {}, + entityTypes: {}, + propertyTypes: {}, + }; + + for (const definitions of definitionSets) { + Object.assign(merged.dataTypes, definitions.dataTypes); + Object.assign(merged.entityTypes, definitions.entityTypes); + Object.assign(merged.propertyTypes, definitions.propertyTypes); + } + + return merged; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/merge-table-data.test.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/merge-table-data.test.ts new file mode 100644 index 00000000000..c08043c21a8 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/merge-table-data.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; + +import { mergeTableData } from "./merge-table-data"; + +import type { + EntitiesTableColumn, + EntitiesTableData, + EntitiesTableRow, +} from "../entities-table-data"; +import type { BaseUrl, VersionedUrl } from "@blockprotocol/type-system"; +import type { ClosedDataTypeDefinition } from "@local/hash-graph-sdk/ontology"; + +const alphaPropertyBaseUrl = + "https://example.com/@test/types/property-type/alpha/" as BaseUrl; +const betaPropertyBaseUrl = + "https://example.com/@test/types/property-type/beta/" as BaseUrl; + +const typeV1 = + "https://example.com/@test/types/entity-type/thing/v/1" as VersionedUrl; +const typeV2 = + "https://example.com/@test/types/entity-type/thing/v/2" as VersionedUrl; + +const column = ( + id: EntitiesTableColumn["id"], + title: string, +): EntitiesTableColumn => ({ id, title, width: 100 }); + +const row = (entityLabel: string): EntitiesTableRow => + ({ entityLabel }) as EntitiesTableRow; + +const dataTypeDefinition = (name: string): ClosedDataTypeDefinition => + ({ schema: { title: name } }) as ClosedDataTypeDefinition; + +const metresDefinition = dataTypeDefinition("Metres"); +const feetDefinition = dataTypeDefinition("Feet"); +const textDefinition = dataTypeDefinition("Text"); + +describe("mergeTableData", () => { + const base: EntitiesTableData = { + columns: [ + column("entityLabel", "Entity"), + column("lastEdited", "Last Edited"), + column(betaPropertyBaseUrl, "Beta"), + ], + dataTypeDefinitions: { "https://metres/v/1": metresDefinition }, + entityTypesWithMultipleVersionsPresent: new Set([typeV1]), + rows: [row("first")], + visibleDataTypeIdsByPropertyBaseUrl: { + [betaPropertyBaseUrl]: new Set([metresDefinition]), + }, + }; + + const next: EntitiesTableData = { + columns: [ + column("entityLabel", "Entity"), + column("sourceEntity", "Source"), + column(alphaPropertyBaseUrl, "Alpha"), + ], + dataTypeDefinitions: { "https://text/v/1": textDefinition }, + entityTypesWithMultipleVersionsPresent: new Set([typeV2]), + rows: [row("second")], + visibleDataTypeIdsByPropertyBaseUrl: { + [betaPropertyBaseUrl]: new Set([feetDefinition]), + [alphaPropertyBaseUrl]: new Set([textDefinition]), + }, + }; + + const merged = mergeTableData(base, next); + + it("concatenates rows in page order", () => { + expect(merged.rows.map(({ entityLabel }) => entityLabel)).toEqual([ + "first", + "second", + ]); + }); + + it("unions columns, keeping static columns (in first-seen order) before title-sorted property columns", () => { + expect(merged.columns.map(({ id }) => id)).toEqual([ + "entityLabel", + "lastEdited", + "sourceEntity", + alphaPropertyBaseUrl, + betaPropertyBaseUrl, + ]); + }); + + it("merges the data type pools", () => { + expect(merged.dataTypeDefinitions).toEqual({ + "https://metres/v/1": metresDefinition, + "https://text/v/1": textDefinition, + }); + }); + + it("unions the multiple-versions markers", () => { + expect(merged.entityTypesWithMultipleVersionsPresent).toEqual( + new Set([typeV1, typeV2]), + ); + }); + + it("unions the visible data types per property, without mutating either input", () => { + expect( + merged.visibleDataTypeIdsByPropertyBaseUrl[betaPropertyBaseUrl], + ).toEqual(new Set([metresDefinition, feetDefinition])); + expect( + merged.visibleDataTypeIdsByPropertyBaseUrl[alphaPropertyBaseUrl], + ).toEqual(new Set([textDefinition])); + + expect( + base.visibleDataTypeIdsByPropertyBaseUrl[betaPropertyBaseUrl], + ).toEqual(new Set([metresDefinition])); + expect( + next.visibleDataTypeIdsByPropertyBaseUrl[betaPropertyBaseUrl], + ).toEqual(new Set([feetDefinition])); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/merge-table-data.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/merge-table-data.ts new file mode 100644 index 00000000000..40873c132cd --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/merge-table-data.ts @@ -0,0 +1,89 @@ +import { isBaseUrl } from "@blockprotocol/type-system"; +import { typedEntries } from "@local/advanced-types/typed-entries"; + +import type { + EntitiesTableColumn, + EntitiesTableData, + VisibleDataTypeIdsByPropertyBaseUrl, +} from "../entities-table-data"; + +/** + * Property columns come after the static columns (a later page may introduce + * Source/Target columns that an earlier page lacked), and are ordered by + * title among themselves. Static columns keep their relative order (sort is + * stable). + */ +const compareColumns = ( + columnA: EntitiesTableColumn, + columnB: EntitiesTableColumn, +): number => { + const isAPropertyColumn = isBaseUrl(columnA.id); + const isBPropertyColumn = isBaseUrl(columnB.id); + + if (isAPropertyColumn && !isBPropertyColumn) { + return 1; + } + + if (!isAPropertyColumn && isBPropertyColumn) { + return -1; + } + + if (isAPropertyColumn && isBPropertyColumn) { + return columnA.title.localeCompare(columnB.title); + } + + return 0; +}; + +/** + * Combines the table data generated from two consecutive result pages: rows + * are concatenated, while columns and the per-row rendering metadata (data + * type pools, version markers) are unioned so every accumulated row stays + * resolvable. + */ +export const mergeTableData = ( + base: EntitiesTableData, + next: EntitiesTableData, +): EntitiesTableData => { + const visibleDataTypeIdsByPropertyBaseUrl: VisibleDataTypeIdsByPropertyBaseUrl = + { ...base.visibleDataTypeIdsByPropertyBaseUrl }; + + for (const [propertyBaseUrl, dataTypes] of typedEntries( + next.visibleDataTypeIdsByPropertyBaseUrl, + )) { + const existingDataTypes = + visibleDataTypeIdsByPropertyBaseUrl[propertyBaseUrl]; + + visibleDataTypeIdsByPropertyBaseUrl[propertyBaseUrl] = existingDataTypes + ? existingDataTypes.union(dataTypes) + : dataTypes; + } + + const addedColumnIds = new Set(); + const columns: EntitiesTableColumn[] = []; + + for (const column of [...base.columns, ...next.columns]) { + if (addedColumnIds.has(column.id)) { + continue; + } + + addedColumnIds.add(column.id); + columns.push(column); + } + + columns.sort(compareColumns); + + return { + columns, + dataTypeDefinitions: { + ...base.dataTypeDefinitions, + ...next.dataTypeDefinitions, + }, + entityTypesWithMultipleVersionsPresent: + base.entityTypesWithMultipleVersionsPresent.union( + next.entityTypesWithMultipleVersionsPresent, + ), + rows: [...base.rows, ...next.rows], + visibleDataTypeIdsByPropertyBaseUrl, + }; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/page-chain.test.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/page-chain.test.ts new file mode 100644 index 00000000000..078d7c33d33 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/page-chain.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it } from "vitest"; + +import { advancePageChain, growPageWindow } from "./page-chain"; + +import type { ChainPage, PageChain } from "./page-chain"; +import type { EntityQueryCursor } from "@local/hash-graph-client"; + +type TestResponse = { page: string; nextCursor?: EntityQueryCursor }; +type TestIdentity = { filter: string }; +type TestPage = ChainPage; +type TestChain = PageChain; + +const buildPage = ( + response: TestResponse, + forCursor: EntityQueryCursor | undefined, +): TestPage => ({ + sourceResponse: response, + forCursor, + nextCursor: response.nextCursor ?? null, +}); + +const identityA: TestIdentity = { filter: "a" }; +const identityB: TestIdentity = { filter: "b" }; + +const windowKeyA = "set-a"; +const windowKeyB = "set-b"; + +const cursorOne: EntityQueryCursor = [{ position: 1 }]; +const cursorTwo: EntityQueryCursor = [{ position: 2 }]; + +const advance = (params: { + chain: TestChain | null; + identity: TestIdentity; + requestedCursor: EntityQueryCursor | undefined; + response: TestResponse | undefined; + windowKey?: string; +}) => advancePageChain({ buildPage, windowKey: windowKeyA, ...params }); + +describe("advancePageChain", () => { + it("returns the chain unchanged (same reference) when there is no response", () => { + expect( + advance({ + chain: null, + identity: identityA, + requestedCursor: undefined, + response: undefined, + }), + ).toBeNull(); + + const chain: TestChain = { + issuedFor: identityA, + windowKey: windowKeyA, + activeCursor: undefined, + targetPageCount: 1, + pages: [buildPage({ page: "1" }, undefined)], + }; + + expect( + advance({ + chain, + identity: identityA, + requestedCursor: undefined, + response: undefined, + }), + ).toBe(chain); + }); + + it("starts a chain from the first response", () => { + const response = { page: "1" }; + + const chain = advance({ + chain: null, + identity: identityA, + requestedCursor: undefined, + response, + }); + + expect(chain).toEqual({ + issuedFor: identityA, + windowKey: windowKeyA, + activeCursor: undefined, + targetPageCount: 1, + pages: [ + { sourceResponse: response, forCursor: undefined, nextCursor: null }, + ], + }); + }); + + it("replaces the chain and resets the window when the entity set has changed", () => { + const previousChain: TestChain = { + issuedFor: identityA, + windowKey: windowKeyA, + activeCursor: cursorOne, + targetPageCount: 2, + pages: [ + buildPage({ page: "a1", nextCursor: cursorOne }, undefined), + buildPage({ page: "a2" }, cursorOne), + ], + }; + + const response = { page: "b1" }; + + const chain = advance({ + chain: previousChain, + identity: identityB, + requestedCursor: undefined, + response, + windowKey: windowKeyB, + }); + + expect(chain).toEqual({ + issuedFor: identityB, + windowKey: windowKeyB, + activeCursor: undefined, + targetPageCount: 1, + pages: [ + { sourceResponse: response, forCursor: undefined, nextCursor: null }, + ], + }); + }); + + it("keeps the window and arms the refill when the same set is refetched with a new shape", () => { + // Two table pages accumulated, then the inputs change shape (e.g. graph + // traversal paths) while the entity set stays the same. + const tableChain: TestChain = { + issuedFor: identityA, + windowKey: windowKeyA, + activeCursor: cursorOne, + targetPageCount: 2, + pages: [ + buildPage({ page: "t1", nextCursor: cursorOne }, undefined), + buildPage({ page: "t2" }, cursorOne), + ], + }; + + const graphIdentity: TestIdentity = { filter: "a" }; + const graphFirstPage = { page: "g1", nextCursor: cursorTwo }; + + const rebuilt = advance({ + chain: tableChain, + identity: graphIdentity, + requestedCursor: undefined, + response: graphFirstPage, + }); + + // The window (2 pages) is inherited and the cursor for page two is + // already armed, so the caller's next request refills the window. + expect(rebuilt).toEqual({ + issuedFor: graphIdentity, + windowKey: windowKeyA, + activeCursor: cursorTwo, + targetPageCount: 2, + pages: [ + { + sourceResponse: graphFirstPage, + forCursor: undefined, + nextCursor: cursorTwo, + }, + ], + }); + + const graphSecondPage = { page: "g2" }; + + const refilled = advance({ + chain: rebuilt, + identity: graphIdentity, + requestedCursor: cursorTwo, + response: graphSecondPage, + }); + + expect(refilled?.pages).toHaveLength(2); + // Window reached: no further cursor armed. + expect(refilled?.activeCursor).toBe(cursorTwo); + expect(refilled?.pages.at(-1)?.nextCursor).toBeNull(); + }); + + it("stops refilling when the result set runs out of pages", () => { + const chain: TestChain = { + issuedFor: identityA, + windowKey: windowKeyA, + activeCursor: undefined, + targetPageCount: 3, + pages: [], + }; + + // The refreshed set only has one page (no next cursor). + const onlyPage = { page: "1" }; + + const advanced = advance({ + chain, + identity: identityA, + requestedCursor: undefined, + response: onlyPage, + }); + + expect(advanced?.pages).toHaveLength(1); + expect(advanced?.activeCursor).toBeUndefined(); + expect(advanced?.targetPageCount).toBe(3); + }); + + it("returns the same chain when the response is already captured", () => { + const response = { page: "1" }; + + const chain: TestChain = { + issuedFor: identityA, + windowKey: windowKeyA, + activeCursor: undefined, + targetPageCount: 1, + pages: [buildPage(response, undefined)], + }; + + expect( + advance({ + chain, + identity: identityA, + requestedCursor: undefined, + response, + }), + ).toBe(chain); + }); + + it("appends a page fetched with a new cursor", () => { + const firstPage = buildPage( + { page: "1", nextCursor: cursorOne }, + undefined, + ); + + const chain: TestChain = { + issuedFor: identityA, + windowKey: windowKeyA, + activeCursor: cursorOne, + targetPageCount: 2, + pages: [firstPage], + }; + + const response = { page: "2" }; + + const advanced = advance({ + chain, + identity: identityA, + requestedCursor: cursorOne, + response, + }); + + expect(advanced).toEqual({ + issuedFor: identityA, + windowKey: windowKeyA, + activeCursor: cursorOne, + targetPageCount: 2, + pages: [ + firstPage, + { sourceResponse: response, forCursor: cursorOne, nextCursor: null }, + ], + }); + }); + + it("rebuilds from a re-delivered first page and re-chases the window", () => { + const chain: TestChain = { + issuedFor: identityA, + windowKey: windowKeyA, + activeCursor: cursorOne, + targetPageCount: 2, + pages: [ + buildPage({ page: "1", nextCursor: cursorOne }, undefined), + buildPage({ page: "2" }, cursorOne), + ], + }; + + const refreshedFirstPage = { page: "1-refreshed", nextCursor: cursorTwo }; + + const advanced = advance({ + chain, + identity: identityA, + requestedCursor: undefined, + response: refreshedFirstPage, + }); + + // Later pages are dropped (they continued the old result set), and the + // kept window immediately arms the refreshed second page's cursor. + expect(advanced).toEqual({ + issuedFor: identityA, + windowKey: windowKeyA, + activeCursor: cursorTwo, + targetPageCount: 2, + pages: [ + { + sourceResponse: refreshedFirstPage, + forCursor: undefined, + nextCursor: cursorTwo, + }, + ], + }); + }); + + it("replaces a re-delivered later page and drops the pages after it", () => { + const firstPage = buildPage( + { page: "1", nextCursor: cursorOne }, + undefined, + ); + + const chain: TestChain = { + issuedFor: identityA, + windowKey: windowKeyA, + activeCursor: cursorTwo, + targetPageCount: 3, + pages: [ + firstPage, + buildPage({ page: "2", nextCursor: cursorTwo }, cursorOne), + buildPage({ page: "3" }, cursorTwo), + ], + }; + + const refreshedSecondPage = { page: "2-refreshed", nextCursor: cursorTwo }; + + const advanced = advance({ + chain, + identity: identityA, + requestedCursor: cursorOne, + response: refreshedSecondPage, + }); + + expect(advanced).toEqual({ + issuedFor: identityA, + windowKey: windowKeyA, + activeCursor: cursorTwo, + targetPageCount: 3, + pages: [ + firstPage, + { + sourceResponse: refreshedSecondPage, + forCursor: cursorOne, + nextCursor: cursorTwo, + }, + ], + }); + }); +}); + +describe("growPageWindow", () => { + it("widens the window one page beyond what is held and arms its cursor", () => { + const chain: TestChain = { + issuedFor: identityA, + windowKey: windowKeyA, + activeCursor: undefined, + targetPageCount: 1, + pages: [buildPage({ page: "1", nextCursor: cursorOne }, undefined)], + }; + + const grown = growPageWindow(chain); + + expect(grown.targetPageCount).toBe(2); + expect(grown.activeCursor).toBe(cursorOne); + }); + + it("is a no-op while a refill toward the window is already in flight", () => { + const chain: TestChain = { + issuedFor: identityA, + windowKey: windowKeyA, + activeCursor: cursorOne, + targetPageCount: 2, + pages: [buildPage({ page: "1", nextCursor: cursorOne }, undefined)], + }; + + const grown = growPageWindow(chain); + + expect(grown.targetPageCount).toBe(2); + expect(grown.activeCursor).toBe(cursorOne); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/page-chain.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/page-chain.ts new file mode 100644 index 00000000000..faf0ebd5553 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data/page-chain.ts @@ -0,0 +1,205 @@ +/** + * Pure state transitions for the visualizer's accumulated result pages. + * + * A {@link PageChain} is the ordered list of response pages fetched for one + * specific set of query inputs (the `issuedFor` identity), plus the cursor + * currently applied to the query. The chain is advanced during render via + * {@link advancePageChain} whenever a response is available, which keeps the + * accumulated pages a pure function of (previous chain, current inputs, + * current response); there is no effect or completion callback involved. + * + * The chain also carries the user's pagination window: how many pages they + * have expanded the results to ({@link PageChain.targetPageCount}). Cursors + * are continuations of responses, so any input change forces a rebuilt chain + * fetched from page one. When the change kept the same entity set, however + * (same {@link PageChain.windowKey}: a view switch changing traversal paths, + * a re-sort, a unit conversion), the rebuilt chain inherits the window and + * auto-refills to it, advancing its cursor as each page arrives. This is what + * makes every view show the same window of results: pages loaded in the + * table are re-fetched (graph-shaped) for the graph rather than silently + * collapsing back to page one. + * + * Invariants: + * - `pages[0]` was requested without a cursor (the first page); every later + * page was requested with the cursor of the page before it. + * - A response object is captured at most once (compared by identity). + * - A re-delivered first page (refresh, cache update) discards all later + * pages, since they continued a result set that no longer exists; the + * window is kept, so the refill re-chases the dropped pages. + */ +import type { EntityQueryCursor } from "@local/hash-graph-client"; + +export interface ChainPage { + /** + * The exact response object this page was built from. Compared by identity + * to recognize a response that has already been captured. + */ + readonly sourceResponse: Response; + /** The cursor this page was requested with; `undefined` for the first page. */ + readonly forCursor: EntityQueryCursor | undefined; + /** Cursor for the page after this one; `null` when this is the last page. */ + readonly nextCursor: EntityQueryCursor | null; +} + +export interface PageChain { + /** + * The query inputs these pages belong to, compared by identity. A chain + * whose `issuedFor` is not the current identity is stale: it may still be + * displayed while the first page for the new inputs loads, but its cursor + * must not be applied to the query. + */ + readonly issuedFor: Identity; + /** + * Identity of the displayed entity set plus the page size: the scope in + * which {@link targetPageCount} is meaningful. Deliberately coarser than + * `issuedFor`: inputs that only re-shape or re-order the same set + * (traversal paths, sort, conversions) are excluded, so the window + * survives them; a different key (filter change) resets the window. + */ + readonly windowKey: string; + /** The cursor applied to the in-flight or most recent request; `undefined` requests the first page. */ + readonly activeCursor: EntityQueryCursor | undefined; + /** + * The user's pagination window: 1 + the number of "Show more" clicks for + * this entity set. A chain holding fewer pages auto-refills toward it (see + * {@link advancePageChain}); a chain can hold fewer permanently only when + * the result set ran out of pages. + */ + readonly targetPageCount: number; + readonly pages: readonly Page[]; +} + +/** + * Arms the chain's cursor for the next page when it holds fewer pages than + * the window asks for and the result set has more to give. Returns the same + * chain object when there is nothing to do (window reached, no further page, + * or the next page is already the active request). + */ +const refillWindow = >( + chain: PageChain, +): PageChain => { + if (chain.pages.length >= chain.targetPageCount) { + return chain; + } + + const nextCursor = chain.pages.at(-1)?.nextCursor; + + if (nextCursor == null || chain.activeCursor === nextCursor) { + return chain; + } + + return { ...chain, activeCursor: nextCursor }; +}; + +/** + * The "Show more" transition: widen the window to one page beyond what the + * chain currently holds, and arm the cursor for it. Idempotent for a given + * chain state, so a double-click (or a click racing an in-flight refill) + * requests one page, not two. + */ +export const growPageWindow = >( + chain: PageChain, +): PageChain => + refillWindow({ + ...chain, + targetPageCount: Math.max(chain.targetPageCount, chain.pages.length + 1), + }); + +interface AdvancePageChain< + Identity, + Response, + Page extends ChainPage, +> { + /** Builds the accumulated page for a newly-captured response. Only invoked when the chain actually advances. */ + readonly buildPage: ( + response: Response, + forCursor: EntityQueryCursor | undefined, + ) => Page; + readonly chain: PageChain | null; + readonly identity: Identity; + /** The cursor the current request was issued with. */ + readonly requestedCursor: EntityQueryCursor | undefined; + readonly response: Response | undefined; + /** The current entity-set identity (see {@link PageChain.windowKey}). */ + readonly windowKey: string; +} + +/** + * Advances the chain with the latest response, returning the same chain + * object when there is nothing to do (so callers can `Object.is`-guard their + * state update). + * + * The caller guarantees that `response` (when defined) answers the request + * described by (`identity`, `requestedCursor`); with Apollo, `data` always + * corresponds to the current variables. + */ +export const advancePageChain = < + Identity, + Response, + Page extends ChainPage, +>({ + buildPage, + chain, + identity, + requestedCursor, + response, + windowKey, +}: AdvancePageChain): PageChain< + Identity, + Page +> | null => { + if (response === undefined) { + return chain; + } + + if (chain === null || chain.issuedFor !== identity) { + // First response for a new set of inputs: replace whatever was shown. + // The same entity set keeps its window (the refill below re-chases the + // discarded depth); a different set starts back at one page. + return refillWindow({ + issuedFor: identity, + windowKey, + activeCursor: requestedCursor, + targetPageCount: + chain !== null && chain.windowKey === windowKey + ? chain.targetPageCount + : 1, + pages: [buildPage(response, requestedCursor)], + }); + } + + if (chain.pages.some((page) => page.sourceResponse === response)) { + return refillWindow(chain); + } + + if (requestedCursor === undefined) { + // The first page was re-delivered (refresh or cache update). Later pages + // continued the previous result set, so they are dropped. + return refillWindow({ + ...chain, + activeCursor: undefined, + pages: [buildPage(response, undefined)], + }); + } + + const existingIndex = chain.pages.findIndex( + (page) => page.forCursor === requestedCursor, + ); + + if (existingIndex !== -1) { + // A previously-captured page was re-delivered with new content (e.g. a + // refetch): replace it and drop the pages that continued the old version. + return refillWindow({ + ...chain, + pages: [ + ...chain.pages.slice(0, existingIndex), + buildPage(response, requestedCursor), + ], + }); + } + + return refillWindow({ + ...chain, + pages: [...chain.pages, buildPage(response, requestedCursor)], + }); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-internal-webs.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-internal-webs.ts new file mode 100644 index 00000000000..c2717fc0167 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-internal-webs.ts @@ -0,0 +1,44 @@ +import { useMemoCompare } from "../../../shared/use-memo-compare"; +import { useAuthenticatedUser } from "../auth-info-context"; + +import type { InternalWeb } from "./header"; +import type { WebId } from "@blockprotocol/type-system"; + +/** + * The webs the user can filter by "membership": their own web plus each org + * they belong to. + * + * The returned array keeps a stable identity across re-renders (and across + * refetches of the authenticated user) for as long as its contents are + * unchanged, so it is safe to use in dependency arrays. + */ +export const useInternalWebs = (): InternalWeb[] => { + const { authenticatedUser } = useAuthenticatedUser(); + + return useMemoCompare( + () => { + return [ + { + webId: authenticatedUser.accountId as WebId, + name: `@${authenticatedUser.shortname}`, + }, + ...authenticatedUser.memberOf.map(({ org }) => ({ + webId: org.webId, + name: `@${org.shortname}`, + })), + ]; + }, + [authenticatedUser], + (oldValue, newValue) => { + return ( + oldValue.length === newValue.length && + oldValue.every((oldWeb) => + newValue.some( + (newWeb) => + oldWeb.webId === newWeb.webId && oldWeb.name === newWeb.name, + ), + ) + ); + }, + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-prune-stale-filters.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-prune-stale-filters.ts new file mode 100644 index 00000000000..34a05f82fcc --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-prune-stale-filters.ts @@ -0,0 +1,104 @@ +import { useEffect } from "react"; + +import type { EntitiesFilterState } from "./shared/filter-state"; +import type { FilterMetadataForProperty } from "./shared/property-filters/property-filter"; +import type { AvailableType } from "./shared/use-available-types"; +import type { Dispatch, SetStateAction } from "react"; + +interface UsePruneStaleFilters { + readonly availableEntityTypes: AvailableType[]; + readonly availableTypesLoading: boolean; + readonly filterState: EntitiesFilterState; + readonly isTypePinned: boolean; + readonly propertyFilterData: FilterMetadataForProperty[]; + readonly setFilterState: Dispatch>; +} + +/** + * Drops filter selections that no longer apply once the available types for + * the current result set are known: selected type ids that are no longer + * present, and property filters whose property is no longer filterable (e.g. + * after deselecting the type that provided it). + * + * This is deliberately an effect rather than a render-time derivation: the + * pruned state must be written back to the canonical filter state (and + * therefore the URL), not just displayed differently. + */ +export const usePruneStaleFilters = ({ + availableEntityTypes, + availableTypesLoading, + filterState: { + propertyFilters, + type: { selectedTypeIds }, + }, + isTypePinned, + propertyFilterData, + setFilterState, +}: UsePruneStaleFilters): void => { + useEffect(() => { + if (availableTypesLoading) { + return; + } + + let nextSelectedTypeIds = selectedTypeIds; + + if (!isTypePinned && selectedTypeIds) { + const availableEntityTypeIds = new Set( + availableEntityTypes.map( + ({ entityTypeId: availableEntityTypeId }) => availableEntityTypeId, + ), + ); + + const retainedSelectedTypeIds = [...selectedTypeIds].filter( + (selectedTypeId) => availableEntityTypeIds.has(selectedTypeId), + ); + + if (retainedSelectedTypeIds.length !== selectedTypeIds.size) { + nextSelectedTypeIds = + retainedSelectedTypeIds.length === 0 + ? null + : new Set(retainedSelectedTypeIds); + } + } + + let nextPropertyFilters = propertyFilters; + if (propertyFilters.length) { + const filterablePropertyKindsByBaseUrl = new Map( + propertyFilterData + .filter((property) => property.filterable) + .map((property) => [property.baseUrl, property.kind]), + ); + + nextPropertyFilters = propertyFilters.filter( + ({ baseUrl, kind }) => + filterablePropertyKindsByBaseUrl.get(baseUrl) === kind, + ); + } + + const typeFilterChanged = nextSelectedTypeIds !== selectedTypeIds; + const propertyFiltersChanged = + nextPropertyFilters.length !== propertyFilters.length; + + if (!typeFilterChanged && !propertyFiltersChanged) { + return; + } + + setFilterState((prev) => ({ + ...prev, + type: typeFilterChanged + ? { selectedTypeIds: nextSelectedTypeIds } + : prev.type, + propertyFilters: propertyFiltersChanged + ? nextPropertyFilters + : prev.propertyFilters, + })); + }, [ + availableEntityTypes, + availableTypesLoading, + propertyFilters, + selectedTypeIds, + isTypePinned, + propertyFilterData, + setFilterState, + ]); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-slide-stack-handlers.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-slide-stack-handlers.ts new file mode 100644 index 00000000000..93d81b04f23 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-slide-stack-handlers.ts @@ -0,0 +1,56 @@ +import { useCallback } from "react"; + +import { useSlideStack } from "../slide-stack"; + +import type { EntityEditorProps } from "../entity/entity-editor"; +import type { EntityId, VersionedUrl } from "@blockprotocol/type-system"; + +/** + * Stable click handlers that open the visualizer's drill-down surfaces (an + * entity, an entity type, or an aggregated edge's link table) in the slide + * stack. + */ +export const useSlideStackHandlers = (): { + handleEntityClick: ( + entityId: EntityId, + options?: Pick, + ) => void; + handleEntityTypeClick: (params: { entityTypeId: VersionedUrl }) => void; + handleOpenLinkTable: (linkEntityIds: readonly EntityId[]) => void; +} => { + const { pushToSlideStack } = useSlideStack(); + + const handleEntityClick = useCallback( + ( + entityId: EntityId, + options?: Pick, + ) => { + pushToSlideStack({ + kind: "entity", + itemId: entityId, + defaultOutgoingLinkFilters: options?.defaultOutgoingLinkFilters, + }); + }, + [pushToSlideStack], + ); + + const handleEntityTypeClick = useCallback( + ({ entityTypeId: itemId }: { entityTypeId: VersionedUrl }) => { + pushToSlideStack({ kind: "entityType", itemId }); + }, + [pushToSlideStack], + ); + + const handleOpenLinkTable = useCallback( + (linkEntityIds: readonly EntityId[]) => { + pushToSlideStack({ + kind: "linkTable", + itemId: `linkTable:${linkEntityIds[0] ?? "empty"}:${linkEntityIds.length}`, + linkEntityIds: [...linkEntityIds], + }); + }, + [pushToSlideStack], + ); + + return { handleEntityClick, handleEntityTypeClick, handleOpenLinkTable }; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-visualizer-heights.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-visualizer-heights.ts new file mode 100644 index 00000000000..4a57c961114 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-visualizer-heights.ts @@ -0,0 +1,63 @@ +import { useTheme } from "@mui/material"; +import { useEffect, useRef, useState } from "react"; + +import { HEADER_HEIGHT } from "../../../shared/layout/layout-with-header/page-header"; +import { TOP_CONTEXT_BAR_HEIGHT } from "../top-context-bar"; +import { visualizerHeaderHeight } from "./header"; + +import type { RefObject } from "react"; + +/** + * CSS height expressions for the visualizer content area, sized to fill the + * viewport below the toolbar. + * + * Attach {@link contentTopRef} to an empty element directly above the content; + * its measured viewport offset (kept up to date via a ResizeObserver on the + * document) feeds the `calc()` expressions. Until the first measurement the + * heights fall back to an estimate from the known chrome heights, so the + * initial render is roughly right rather than zero-height. + */ +export const useVisualizerHeights = (): { + contentTopRef: RefObject; + /** Full remaining viewport height, used by the Graph view. */ + availableHeight: string; + /** {@link availableHeight} clamped to 1000px, used by the Table view. */ + tableHeight: string; +} => { + const theme = useTheme(); + + const contentTopRef = useRef(null); + const [contentTop, setContentTop] = useState(null); + + useEffect(() => { + const el = contentTopRef.current; + if (!el) { + return; + } + + const measure = () => { + setContentTop(el.getBoundingClientRect().top); + }; + + measure(); + + const observer = new ResizeObserver(measure); + observer.observe(document.documentElement); + + return () => observer.disconnect(); + }, []); + + const availableHeight = `calc(100vh - ${ + contentTop != null + ? `${contentTop}px - ${theme.spacing(5)}` + : `(${ + HEADER_HEIGHT + TOP_CONTEXT_BAR_HEIGHT + 230 + visualizerHeaderHeight + }px + ${theme.spacing(5)} + ${theme.spacing(5)}` + })`; + + return { + contentTopRef, + availableHeight, + tableHeight: `min(${availableHeight}, 1000px)`, + }; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-visualizer-view.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-visualizer-view.ts new file mode 100644 index 00000000000..0a6f109e298 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-visualizer-view.ts @@ -0,0 +1,94 @@ +import { useMemo } from "react"; + +import { getClosedMultiEntityTypeFromMap } from "@local/hash-graph-sdk/entity"; + +import { useEntityTypesContextRequired } from "../../../shared/entity-types-context/hooks/use-entity-types-context-required"; +import { + computeIsDisplayingFilesOnly, + resolveVisualizerView, +} from "./use-visualizer-view/resolve-visualizer-view"; + +import type { VisualizerView } from "../visualizer-views"; +import type { + BaseUrl, + ClosedMultiEntityType, + VersionedUrl, +} from "@blockprotocol/type-system"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; + +/** + * Resolves which view (Table / Grid / Graph) to display and which views to + * offer in the toggle, based on the fetched data and the user's explicit + * selection (see {@link resolveVisualizerView} for the rules). + */ +export const useVisualizerView = ({ + closedMultiEntityTypesRootMap, + definitions, + entities, + entityTypeBaseUrl, + entityTypeId, + selectedView, +}: { + readonly closedMultiEntityTypesRootMap: + | ClosedMultiEntityTypesRootMap + | undefined; + readonly definitions: ClosedMultiEntityTypesDefinitions | undefined; + readonly entities: HashEntity[] | undefined; + readonly entityTypeBaseUrl?: BaseUrl; + readonly entityTypeId?: VersionedUrl; + /** The user's explicit choice, or `null` to derive the default from the data. */ + readonly selectedView: VisualizerView | null; +}): { + view: VisualizerView; + viewOptions: VisualizerView[]; +} => { + const { isSpecialEntityTypeLookup } = useEntityTypesContextRequired(); + + /** One closed multi-type per distinct entityTypeIds combination present in the results. */ + const closedMultiEntityTypes = useMemo(() => { + if (!entities || !definitions || !closedMultiEntityTypesRootMap) { + return []; + } + + const relevantEntityTypesMap = new Map(); + + for (const { metadata } of entities) { + const closedMultiEntityType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesRootMap, + metadata.entityTypeIds, + ); + + const key = metadata.entityTypeIds.toSorted().join(","); + + relevantEntityTypesMap.set(key, closedMultiEntityType); + } + + return Array.from(relevantEntityTypesMap.values()); + }, [entities, definitions, closedMultiEntityTypesRootMap]); + + const isDisplayingFilesOnly = useMemo( + () => + computeIsDisplayingFilesOnly({ + closedMultiEntityTypes, + entityTypeBaseUrl, + entityTypeId, + isFileType: (typeId) => + isSpecialEntityTypeLookup?.[typeId]?.isFile ?? false, + }), + [ + entityTypeBaseUrl, + entityTypeId, + closedMultiEntityTypes, + isSpecialEntityTypeLookup, + ], + ); + + return useMemo( + () => resolveVisualizerView({ isDisplayingFilesOnly, selectedView }), + [isDisplayingFilesOnly, selectedView], + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-visualizer-view/resolve-visualizer-view.test.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-visualizer-view/resolve-visualizer-view.test.ts new file mode 100644 index 00000000000..746a2eaba41 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-visualizer-view/resolve-visualizer-view.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; + +import { + computeIsDisplayingFilesOnly, + resolveVisualizerView, +} from "./resolve-visualizer-view"; + +import type { + ClosedMultiEntityType, + VersionedUrl, +} from "@blockprotocol/type-system"; + +const fileTypeId = + "https://example.com/@test/types/entity-type/photo/v/1" as VersionedUrl; +const otherTypeId = + "https://example.com/@test/types/entity-type/person/v/1" as VersionedUrl; + +const closedTypeOf = (...typeIds: VersionedUrl[]): ClosedMultiEntityType => + ({ + allOf: typeIds.map(($id) => ({ $id })), + }) as unknown as ClosedMultiEntityType; + +const isFileType = (typeId: VersionedUrl) => typeId === fileTypeId; + +describe("computeIsDisplayingFilesOnly", () => { + it("is true when the pinned entityTypeId is a known file type", () => { + expect( + computeIsDisplayingFilesOnly({ + closedMultiEntityTypes: [], + entityTypeId: systemEntityTypes.imageFile.entityTypeId, + isFileType: () => false, + }), + ).toBe(true); + }); + + it("is true when the pinned entityTypeBaseUrl is a known file type", () => { + expect( + computeIsDisplayingFilesOnly({ + closedMultiEntityTypes: [], + entityTypeBaseUrl: systemEntityTypes.pdfDocument.entityTypeBaseUrl, + isFileType: () => false, + }), + ).toBe(true); + }); + + it("is true when every fetched type combination includes a file type", () => { + expect( + computeIsDisplayingFilesOnly({ + closedMultiEntityTypes: [ + closedTypeOf(fileTypeId), + closedTypeOf(otherTypeId, fileTypeId), + ], + isFileType, + }), + ).toBe(true); + }); + + it("is false when any fetched type combination has no file type", () => { + expect( + computeIsDisplayingFilesOnly({ + closedMultiEntityTypes: [ + closedTypeOf(fileTypeId), + closedTypeOf(otherTypeId), + ], + isFileType, + }), + ).toBe(false); + }); + + it("is false with no pinned type and no fetched types", () => { + expect( + computeIsDisplayingFilesOnly({ + closedMultiEntityTypes: [], + isFileType, + }), + ).toBe(false); + }); +}); + +describe("resolveVisualizerView", () => { + it("defaults to Table, without a Grid option, for mixed results", () => { + expect( + resolveVisualizerView({ + isDisplayingFilesOnly: false, + selectedView: null, + }), + ).toEqual({ view: "Table", viewOptions: ["Table", "Graph"] }); + }); + + it("defaults to Grid for files-only results", () => { + expect( + resolveVisualizerView({ + isDisplayingFilesOnly: true, + selectedView: null, + }), + ).toEqual({ view: "Grid", viewOptions: ["Table", "Grid", "Graph"] }); + }); + + it("honors an explicit selection while it remains offered", () => { + expect( + resolveVisualizerView({ + isDisplayingFilesOnly: true, + selectedView: "Table", + }).view, + ).toBe("Table"); + + expect( + resolveVisualizerView({ + isDisplayingFilesOnly: false, + selectedView: "Graph", + }).view, + ).toBe("Graph"); + }); + + it("falls back from a Grid selection to Table when the results stop being files-only", () => { + expect( + resolveVisualizerView({ + isDisplayingFilesOnly: false, + selectedView: "Grid", + }), + ).toEqual({ view: "Table", viewOptions: ["Table", "Graph"] }); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-visualizer-view/resolve-visualizer-view.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-visualizer-view/resolve-visualizer-view.ts new file mode 100644 index 00000000000..3ea0b916a0a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-visualizer-view/resolve-visualizer-view.ts @@ -0,0 +1,97 @@ +/** + * Pure logic for which view (Table / Grid / Graph) the visualizer displays + * and which views it offers, separated from the hook for testability. + */ +import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; + +import type { VisualizerView } from "../../visualizer-views"; +import type { + BaseUrl, + ClosedMultiEntityType, + VersionedUrl, +} from "@blockprotocol/type-system"; + +/** + * @todo: avoid having to maintain this list, potentially by + * adding an `isFile` boolean to the generated ontology IDs file. + */ +const allFileEntityTypeOntologyIds = [ + systemEntityTypes.file, + systemEntityTypes.imageFile, + systemEntityTypes.documentFile, + systemEntityTypes.docxDocument, + systemEntityTypes.pdfDocument, + systemEntityTypes.presentationFile, + systemEntityTypes.pptxPresentation, +]; + +const allFileEntityTypeIds = allFileEntityTypeOntologyIds.map( + ({ entityTypeId }) => entityTypeId, +) as VersionedUrl[]; + +const allFileEntityTypeBaseUrls = allFileEntityTypeOntologyIds.map( + ({ entityTypeBaseUrl }) => entityTypeBaseUrl, +); + +/** + * Whether every displayed entity is a file, in which case the Grid + * (file-preview) view applies. + * + * To allow the Grid view to be chosen on FIRST render where possible, the + * pinned `entityTypeId` / `entityTypeBaseUrl` are checked against a static + * list of file types before any data is fetched; the fetched types are the + * fallback for everything else. + */ +export const computeIsDisplayingFilesOnly = ({ + closedMultiEntityTypes, + entityTypeBaseUrl, + entityTypeId, + isFileType, +}: { + /** One closed multi-type per distinct type combination present in the results. */ + closedMultiEntityTypes: ClosedMultiEntityType[]; + entityTypeBaseUrl?: BaseUrl; + entityTypeId?: VersionedUrl; + isFileType: (entityTypeId: VersionedUrl) => boolean; +}): boolean => + Boolean( + (entityTypeId && allFileEntityTypeIds.includes(entityTypeId)) || + (entityTypeBaseUrl && + allFileEntityTypeBaseUrls.includes(entityTypeBaseUrl)) || + (closedMultiEntityTypes.length && + closedMultiEntityTypes.every(({ allOf }) => + allOf.some(({ $id }) => isFileType($id)), + )), + ); + +/** + * The Grid view is only offered when every displayed entity is a file. When + * it is available it is also the default, so a file type's entities open in + * Grid on first render. An explicit user selection always wins while it + * remains offered; a Grid selection falls back to Table if the result set + * stops being files-only. + */ +export const resolveVisualizerView = ({ + isDisplayingFilesOnly, + selectedView, +}: { + isDisplayingFilesOnly: boolean; + /** The user's explicit choice, or `null` to derive the default from the data. */ + selectedView: VisualizerView | null; +}): { + view: VisualizerView; + viewOptions: VisualizerView[]; +} => { + const viewOptions: VisualizerView[] = isDisplayingFilesOnly + ? ["Table", "Grid", "Graph"] + : ["Table", "Graph"]; + + const view = + selectedView !== null && viewOptions.includes(selectedView) + ? selectedView + : isDisplayingFilesOnly + ? "Grid" + : "Table"; + + return { view, viewOptions }; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/visualizer-toolbar.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/visualizer-toolbar.tsx new file mode 100644 index 00000000000..a97c22d82a7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/visualizer-toolbar.tsx @@ -0,0 +1,120 @@ +import { memo } from "react"; + +import { BulkActionsDropdown } from "../../../shared/table-header/bulk-actions-dropdown"; +import { TableHeaderToggle } from "../table-header-toggle"; +import { visualizerViewIcons } from "../visualizer-views"; +import { FilterRibbon, QueryCount, VisualizerHeader } from "./header"; + +import type { VisualizerView } from "../visualizer-views"; +import type { InternalWeb } from "./header"; +import type { EntitiesFilterState } from "./shared/filter-state"; +import type { FilterMetadataForProperty } from "./shared/property-filters/property-filter"; +import type { AvailableType } from "./shared/use-available-types"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { Dispatch, SetStateAction } from "react"; + +interface VisualizerToolbarProps { + readonly availableEntityTypes: AvailableType[]; + readonly availableTypesLoading: boolean; + readonly filterState: EntitiesFilterState; + /** + * Entities graph exploration OR-ed into the set beyond the query's own + * results; shown as a dismissible pill. + */ + readonly frontierAdditionsCount: number; + readonly internalWebs: InternalWeb[]; + readonly isTypePinned: boolean; + /** + * Loaded entities in the displayed set (query roots plus graph additions), + * for the "m of n entities" count. Null while no page has landed. + */ + readonly loadedResultCount: number | null; + readonly onBulkActionCompleted: () => void; + readonly onClearFrontierAdditions: () => void; + /** Download the current filter configuration (filters plus graph additions). */ + readonly onExportFilters: () => void; + /** Restore a configuration from a picked file's text. */ + readonly onImportFilters: (fileText: string) => void; + readonly propertyFilterData: FilterMetadataForProperty[]; + /** When non-empty, the filter ribbon is replaced by bulk actions for the selection. */ + readonly selectedEntities: HashEntity[]; + readonly setFilterState: Dispatch>; + readonly setView: (view: VisualizerView) => void; + /** Size of the whole displayed set: the query's total plus graph additions. */ + readonly totalResultCount: number | null; + readonly totalResultCountLoading: boolean; + readonly view: VisualizerView; + readonly viewOptions: VisualizerView[]; +} + +/** + * The bar above the visualizer content: filter pills (or bulk actions when + * rows are selected) on the left, the result count and view toggle on the + * right. + */ +export const VisualizerToolbar: React.FC = memo( + ({ + availableEntityTypes, + availableTypesLoading, + filterState, + frontierAdditionsCount, + internalWebs, + isTypePinned, + loadedResultCount, + onBulkActionCompleted, + onClearFrontierAdditions, + onExportFilters, + onImportFilters, + propertyFilterData, + selectedEntities, + setFilterState, + setView, + totalResultCount, + totalResultCountLoading, + view, + viewOptions, + }) => ( + 0 ? ( + + ) : ( + + ) + } + right={ + <> + + ({ + icon: visualizerViewIcons[optionValue], + label: `${optionValue} view`, + value: optionValue, + }))} + /> + + } + /> + ), +); diff --git a/apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx deleted file mode 100644 index 2411a6d2587..00000000000 --- a/apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { Box, Stack, useTheme } from "@mui/material"; -import { memo, useCallback, useMemo, useState } from "react"; - -import { isEntityId, mustHaveAtLeastOne } from "@blockprotocol/type-system"; -import { ibm } from "@hashintel/design-system/palettes"; -import { - getClosedMultiEntityTypeFromMap, - getDisplayFieldsForClosedEntityType, -} from "@local/hash-graph-sdk/entity"; -import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; - -import { GraphVisualizer } from "./graph-visualizer"; - -import type { EntityEditorProps } from "./entity/entity-editor"; -import type { - DynamicNodeSizing, - GraphVisualizerProps, - GraphVizConfig, - GraphVizEdge, - GraphVizFilters, - GraphVizNode, -} from "./graph-visualizer"; -import type { - ClosedMultiEntityType, - EntityId, - EntityMetadata, - LinkData, - PropertyObject, - VersionedUrl, -} from "@blockprotocol/type-system"; -import type { ClosedMultiEntityTypesRootMap } from "@local/hash-graph-sdk/ontology"; -import type { ReactElement } from "react"; - -export type EntityForGraph = { - linkData?: LinkData; - metadata: Pick & - Partial>; - properties: PropertyObject; -}; - -const fallbackDefaultConfig = { - graphKey: "entity-graph-2024-11-19b", - edgeSizing: { - min: 2, - max: 5, - nonHighlightedVisibleSizeThreshold: 2, - scale: "Linear", - }, - nodeHighlighting: { - depth: 1, - direction: "All", - }, - nodeSizing: { - mode: "byEdgeCount", - min: 10, - max: 32, - countEdges: "All", - scale: "Linear", - }, -} as const satisfies GraphVizConfig; - -export const EntityGraphVisualizer = memo( - ({ - closedMultiEntityTypesRootMap, - defaultConfig: defaultConfigFromProps, - defaultFilters, - entities, - fullScreenMode, - isPrimaryEntity, - loadingComponent, - onEntityClick, - onEntityTypeClick, - }: { - closedMultiEntityTypesRootMap?: ClosedMultiEntityTypesRootMap; - defaultConfig?: GraphVizConfig; - defaultFilters?: GraphVizFilters; - fullScreenMode?: "document" | "element"; - entities?: T[]; - onEntityClick?: ( - entityId: EntityId, - options?: Pick, - ) => void; - onEntityTypeClick?: (entityTypeId: VersionedUrl) => void; - /** - * Whether this entity should receive a special highlight. - */ - isPrimaryEntity?: (entity: T) => boolean; - loadingComponent: ReactElement; - }) => { - const { palette } = useTheme(); - - const [loading, setLoading] = useState(true); - - const nodeColors = useMemo(() => { - return ibm.map((color) => ({ color, borderColor: palette.gray[50] })); - }, [palette]); - - const defaultConfig = defaultConfigFromProps ?? fallbackDefaultConfig; - - const { nodes, edges } = useMemo<{ - nodes: GraphVizNode[]; - edges: GraphVizEdge[]; - }>(() => { - const nodesToAddByNodeId: Record = {}; - const edgesToAdd: GraphVizEdge[] = []; - - const nonLinkEntitiesIncluded = new Set(); - const linkEntitiesToAdd: (T & { - linkData: NonNullable; - })[] = []; - - const closedMultiEntityTypesById: Record = - {}; - - const entityTypeIdToColor = new Map(); - - const linkEntityIdsSeen = new Set(); - - for (const entity of entities ?? []) { - if (entity.linkData) { - /** - * We process links afterwards, because we only want to add them if both source and target are in the graph. - */ - linkEntitiesToAdd.push( - entity as T & { - linkData: NonNullable; - }, - ); - linkEntityIdsSeen.add(entity.metadata.recordId.entityId); - continue; - } - - nonLinkEntitiesIncluded.add(entity.metadata.recordId.entityId); - - const specialHighlight = isPrimaryEntity?.(entity) ?? false; - - const sortedEntityTypeIds = mustHaveAtLeastOne( - entity.metadata.entityTypeIds.toSorted(), - ); - - const firstEntityTypeId = sortedEntityTypeIds[0]; - - /** - * @todo H-3539: take account of additional types an entity might have - */ - if (!entityTypeIdToColor.has(firstEntityTypeId)) { - entityTypeIdToColor.set( - firstEntityTypeId, - entityTypeIdToColor.size % nodeColors.length, - ); - } - - const { color, borderColor } = specialHighlight - ? { color: palette.blue[50], borderColor: palette.blue[60] } - : nodeColors[entityTypeIdToColor.get(firstEntityTypeId)!]!; - - const combinedKey = sortedEntityTypeIds.join(","); - - const entityType = - closedMultiEntityTypesById[combinedKey] ?? - getClosedMultiEntityTypeFromMap( - closedMultiEntityTypesRootMap, - entity.metadata.entityTypeIds, - ); - - const displayFields = getDisplayFieldsForClosedEntityType(entityType); - const icon = displayFields.icon; - - const nodeTypeLabel = entityType.allOf[0].title; - const entityLabel = generateEntityLabel(entityType, entity); - - closedMultiEntityTypesById[combinedKey] = entityType!; - - nodesToAddByNodeId[entity.metadata.recordId.entityId] = { - icon, - label: entityLabel, - nodeId: entity.metadata.recordId.entityId, - nodeTypeId: firstEntityTypeId, - nodeTypeLabel, - color, - borderColor, - size: defaultConfig.nodeSizing.min, - }; - } - - for (const linkEntity of linkEntitiesToAdd) { - if ( - !nonLinkEntitiesIncluded.has(linkEntity.linkData.leftEntityId) || - /** - * The target may be a link entity or a regular entity. - * If we haven't seen either as a target, we don't want to add this link. - */ - (!linkEntityIdsSeen.has(linkEntity.linkData.rightEntityId) && - !nonLinkEntitiesIncluded.has(linkEntity.linkData.rightEntityId)) - ) { - /** - * We don't have both sides of this link in the graph. - */ - continue; - } - - edgesToAdd.push({ - source: linkEntity.linkData.leftEntityId, - target: linkEntity.linkData.rightEntityId, - edgeId: linkEntity.metadata.recordId.entityId, - edgeTypeId: linkEntity.metadata.entityTypeIds[0], - size: 1, - }); - } - - return { - nodes: Object.values(nodesToAddByNodeId), - edges: edgesToAdd, - }; - }, [ - closedMultiEntityTypesRootMap, - defaultConfig.nodeSizing.min, - entities, - isPrimaryEntity, - nodeColors, - palette.blue, - ]); - - const onNodeClick = useCallback< - NonNullable["onNodeSecondClick"]> - >( - ({ nodeId }) => { - if (isEntityId(nodeId)) { - onEntityClick?.(nodeId); - } else { - onEntityTypeClick?.(nodeId as VersionedUrl); - } - }, - [onEntityClick, onEntityTypeClick], - ); - - const onEdgeClick = useCallback< - NonNullable["onEdgeClick"]> - >( - ({ edgeData }) => { - onEntityClick?.(edgeData.source as EntityId, { - defaultOutgoingLinkFilters: { - linkedTo: new Set([edgeData.target]), - linkTypes: new Set([edgeData.edgeTypeId!]), - }, - }); - }, - [onEntityClick], - ); - - const onRender = useCallback(() => setLoading(false), []); - - return ( - - {loading && ( - - {loadingComponent} - - )} - - - ); - }, -); diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor.tsx index 49c4e684d69..09236e9d7f5 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor.tsx @@ -6,6 +6,7 @@ import { getRoots } from "@blockprotocol/graph/stdlib"; import { ClaimsSection } from "./entity-editor/claims-section"; import { EntityEditorContextProvider } from "./entity-editor/entity-editor-context"; import { FilePreviewSection } from "./entity-editor/file-preview-section"; +import { GraphSection } from "./entity-editor/graph-section"; import { HistorySection } from "./entity-editor/history-section"; import { LinkSection } from "./entity-editor/link-section"; import { IncomingLinksSection } from "./entity-editor/links-section/incoming-links-section"; @@ -173,6 +174,8 @@ export const EntityEditor = (props: EntityEditorProps) => { {tab === "history" ? ( + ) : tab === "graph" ? ( + ) : ( {isLinkEntity ? : } diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/graph-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/graph-section.tsx new file mode 100644 index 00000000000..c064a6d1fa9 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/graph-section.tsx @@ -0,0 +1,175 @@ +import { useQuery } from "@apollo/client"; +import { Box, useTheme } from "@mui/material"; +import { useCallback, useMemo } from "react"; + +import { getLatestEntityVertices, getRoots } from "@blockprotocol/graph/stdlib"; +import { type EntityId, splitEntityId } from "@blockprotocol/type-system"; +import { LoadingSpinner } from "@hashintel/design-system"; +import { deserializeQueryEntitySubgraphResponse } from "@local/hash-graph-sdk/entity"; +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; + +import { queryEntitySubgraphQuery } from "../../../../graphql/queries/knowledge/entity.queries"; +import { useOwnedFrontierStore } from "../../graph-visualizer/entity-graph/use-frontier-expansion"; +import { EntityGraphVisualizer } from "../../graph-visualizer/entity-graph/visualizer"; +import { useSlideStack, useSlideStackOcclusion } from "../../slide-stack"; + +import type { + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables, +} from "../../../../graphql/api-types.gen"; +import type { TraversalPath } from "@local/hash-graph-client"; + +/** + * Resolve the links into and out of the seed entity so its immediate neighbours come back as + * FRONTIER nodes (greyed-out until the user expands them). Mirrors the Graph view's traversal in + * the entities visualizer: both link directions, one hop. + */ +const graphViewTraversalPaths: TraversalPath[] = [ + { + edges: [ + { kind: "has-left-entity", direction: "incoming" }, + { kind: "has-right-entity", direction: "outgoing" }, + ], + }, + { + edges: [ + { kind: "has-right-entity", direction: "incoming" }, + { kind: "has-left-entity", direction: "outgoing" }, + ], + }, +]; + +/** + * Embeds the graph visualizer (v2) seeded with a single entity -- the one being viewed. The seed is + * the only query ROOT; its one-hop link endpoints come back as FRONTIER nodes the user can expand + * from to explore outwards. + */ +export const GraphSection = ({ entityId }: { entityId: EntityId }) => { + const theme = useTheme(); + const { pushToSlideStack } = useSlideStack(); + // Pause the simulation while this editor's slide is covered by a later + // slide (or, on a full page, while any slide is open over it). + const occluded = useSlideStackOcclusion(); + // Expansions extend this seed entity's neighbourhood; a different seed is + // a different graph, so the store resets with the entity id. + const frontierStore = useOwnedFrontierStore(entityId); + + const [webId, entityUuid] = splitEntityId(entityId); + + const { data, loading } = useQuery< + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables + >(queryEntitySubgraphQuery, { + fetchPolicy: "cache-and-network", + variables: { + request: { + filter: { + all: [ + { equal: [{ path: ["uuid"] }, { parameter: entityUuid }] }, + { equal: [{ path: ["webId"] }, { parameter: webId }] }, + ], + }, + traversalPaths: graphViewTraversalPaths, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: false, + includeEntityTypes: "resolvedWithDataTypeChildren", + includePermissions: false, + }, + }, + }); + + const subgraph = useMemo( + () => + data?.queryEntitySubgraph + ? deserializeQueryEntitySubgraphResponse(data.queryEntitySubgraph) + .subgraph + : undefined, + [data?.queryEntitySubgraph], + ); + + const entities = useMemo( + () => + subgraph + ? getLatestEntityVertices(subgraph).map((vertex) => vertex.inner) + : undefined, + [subgraph], + ); + + /** + * The seed is the only root; every other fetched entity (its link endpoints) is a frontier node. + * Deriving the root id from the subgraph (rather than reusing the page's `entityId`) keeps it + * matched to the fetched live entity regardless of any draft id on the page. + */ + const rootEntityIds = useMemo( + () => + subgraph + ? getRoots(subgraph).map((entity) => entity.metadata.recordId.entityId) + : undefined, + [subgraph], + ); + + const handleEntityClick = useCallback( + (clickedEntityId: EntityId) => { + pushToSlideStack({ kind: "entity", itemId: clickedEntityId }); + }, + [pushToSlideStack], + ); + + const handleOpenLinkTable = useCallback( + (linkEntityIds: readonly EntityId[]) => { + pushToSlideStack({ + kind: "linkTable", + itemId: `linkTable:${linkEntityIds[0] ?? "empty"}:${ + linkEntityIds.length + }`, + linkEntityIds: [...linkEntityIds], + }); + }, + [pushToSlideStack], + ); + + return ( + ({ + position: "relative", + height: "min(calc(100vh - 320px), 900px)", + minHeight: 500, + border: 1, + borderColor: palette.gray[20], + borderRadius: "10px", + overflow: "hidden", + background: palette.common.white, + })} + > + {loading && !subgraph ? ( + + + + ) : ( + + } + onEntityClick={handleEntityClick} + onOpenLinkTable={handleOpenLinkTable} + /> + )} + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entity/shared/entity-editor-tabs.tsx b/apps/hash-frontend/src/pages/shared/entity/shared/entity-editor-tabs.tsx index 1ba9f9b3fb5..eadb884d5a6 100644 --- a/apps/hash-frontend/src/pages/shared/entity/shared/entity-editor-tabs.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/shared/entity-editor-tabs.tsx @@ -7,7 +7,7 @@ import { Tabs } from "../../../../shared/ui/tabs"; import type { PropsWithChildren } from "react"; -type EntityEditorTab = "overview" | "history"; +type EntityEditorTab = "overview" | "history" | "graph"; const defaultTab: EntityEditorTab = "overview"; @@ -125,6 +125,20 @@ export const EntityEditorTabs = ({ label="History" active={tab === "history"} /> + { + event.preventDefault(); + setTab("graph"); + } + : undefined + } + label="Graph" + active={tab === "graph"} + /> ); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer.tsx deleted file mode 100644 index acf2c66c0f0..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import "@react-sigma/core/lib/react-sigma.min.css"; -import dynamic from "next/dynamic"; -import { memo } from "react"; - -import type { GraphContainerProps } from "./graph-visualizer/graph-container"; -import type { - DynamicNodeSizing, - GraphVizConfig, - StaticNodeSizing, -} from "./graph-visualizer/graph-container/shared/config-control"; - -export type { DynamicNodeSizing, GraphVizConfig, StaticNodeSizing }; -export type { GraphVizFilters } from "./graph-visualizer/graph-container/shared/filter-control"; -export type { - GraphVizEdge, - GraphVizNode, -} from "./graph-visualizer/graph-container/shared/types"; - -export type GraphVisualizerProps< - NodeSizing extends DynamicNodeSizing | StaticNodeSizing, -> = GraphContainerProps; - -export const GraphVisualizer = memo( - ( - props: GraphVisualizerProps, - ) => { - if (typeof window !== "undefined") { - /** - * WebGL APIs aren't available in the server, so we need to dynamically load any module which uses Sigma/graphology. - */ - const GraphContainer = dynamic( - import("./graph-visualizer/graph-container").then( - (module) => module.GraphContainer, - ), - { ssr: false }, - ); - - return ; - } - - return null; - }, -); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/brand.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/brand.ts new file mode 100644 index 00000000000..335e0d251c5 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/brand.ts @@ -0,0 +1,93 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * The `Brand` module adds compile-time names to ordinary TypeScript values so + * structurally identical values cannot be mixed accidentally. A branded value + * has the same runtime representation as its unbranded value; the extra + * information lives in the type system unless you choose a validating + * constructor. + * + * Vendored from Effect-TS effect-smol (MIT). + */ + +const TypeId = "~effect/Brand"; + +/** + * Compile-time brand tag carried on a value so structurally identical primitives + * cannot be mixed at the type level. + * + * **When to use** + * + * Use to define a branded type such as `number & Brand<"Positive">` when + * TypeScript should keep structurally identical values separate without + * changing their runtime value. + * + * @see {@link Branded} for applying a brand key to a base type + * @see {@link Constructor} for validating or constructing branded values + * + * @category models + * @since 2.0.0 + */ +export interface Brand { + readonly [TypeId]: { + readonly [K in Keys]: Keys; + }; +} + +/** + * Namespace containing type-level helpers for working with branded types and + * brand constructors. + * + * @since 2.0.0 + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export declare namespace Brand { + /** + * Strips every brand key from a branded type, leaving only the underlying + * value type. + * + * @category utility types + * @since 2.0.0 + */ + export type Unbranded> = B extends (infer U) & Brands + ? U + : B; + + /** + * Lists the string brand keys attached to a branded type. + * + * @category utility types + * @since 4.0.0 + */ + export type Keys> = keyof B[typeof TypeId]; + + type UnionToIntersection = ( + T extends any ? (x: T) => any : never + ) extends (x: infer R) => any + ? R + : never; + + /** + * Intersects the per-key brand tags of a multiply-branded type into one + * brand object. + * + * @category utility types + * @since 2.0.0 + */ + export type Brands> = UnionToIntersection< + { [K in Keys]: K extends string ? Brand : never }[Keys] + >; +} + +/** + * Pairs a base value type with a single compile-time brand key. + * + * @category utility types + * @since 2.0.0 + */ +export type Branded = A & Brand; + +export const Branded = + >() => + (value: Brand.Unbranded): B => + // Identity at runtime; the brand exists only in the type system. + value as B; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/components/frontier-controls.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/components/frontier-controls.tsx new file mode 100644 index 00000000000..7642e15807c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/components/frontier-controls.tsx @@ -0,0 +1,171 @@ +import { Box, Tooltip, Typography, useTheme } from "@mui/material"; + +import { + LoadingSpinner, + MagnifyingGlassPlusLightIcon, +} from "@hashintel/design-system"; + +import { GrayToBlueIconButton } from "../../gray-to-blue-icon-button"; + +interface FrontierControlsProps { + readonly frontierCount: number; + readonly isFetching: boolean; + readonly fetchedCount: number; + readonly totalToFetch: number; + readonly error?: string; + /** What a frontier node is, for button/tooltip copy ("entity", "type"). */ + readonly noun: string; + readonly onFetchCompleteFrontier: () => void; +} + +export const FrontierControls = ({ + frontierCount, + isFetching, + fetchedCount, + totalToFetch, + error, + noun, + onFetchCompleteFrontier, +}: FrontierControlsProps) => { + const theme = useTheme(); + + if (frontierCount === 0 && !isFetching && !error) { + return null; + } + + const remainingCount = Math.max(0, totalToFetch - fetchedCount); + const progress = + isFetching && totalToFetch > 0 + ? `${remainingCount.toLocaleString()} ${ + remainingCount === 1 ? "frontier" : "frontiers" + } in flight · ${fetchedCount.toLocaleString()} of ${totalToFetch.toLocaleString()} loaded` + : undefined; + const compactInFlightText = + isFetching && totalToFetch > 0 + ? remainingCount > 0 + ? `Fetching ${remainingCount.toLocaleString()} ${ + remainingCount === 1 ? "frontier" : "frontiers" + }` + : "Finishing frontier fetch" + : undefined; + const badgeCount = isFetching ? 0 : frontierCount; + const disabled = frontierCount === 0 || isFetching; + const nounPlural = noun === "entity" ? "entities" : `${noun}s`; + const tooltipTitle = + error ?? + progress ?? + (frontierCount > 0 + ? `Fetch ${frontierCount.toLocaleString()} frontier ${ + frontierCount === 1 ? noun : nounPlural + }` + : "The visible frontier is fully fetched."); + + return ( + + {compactInFlightText ? ( + ({ + display: "flex", + alignItems: "center", + gap: 0.75, + height: 26, + px: 0.85, + borderRadius: "4px", + bgcolor: palette.gray[5], + border: `1px solid ${palette.gray[30]}`, + pointerEvents: "none", + color: palette.gray[70], + whiteSpace: "nowrap", + })} + > + + ({ + fontSize: 13, + color: palette.gray[70], + fontWeight: 500, + lineHeight: 1, + })} + > + {compactInFlightText} + + + ) : null} + + + ({ + background: "red.10", + border: `1px solid ${palette.red[30]}`, + color: "red.80", + }) + : undefined, + }} + > + {isFetching ? ( + + ) : ( + + )} + + {badgeCount > 0 ? ( + ({ + position: "absolute", + top: -5, + right: -5, + minWidth: 16, + height: 16, + px: 0.4, + borderRadius: 8, + border: `1px solid ${palette.white}`, + bgcolor: error ? "red.70" : "blue.70", + color: palette.white, + fontSize: 9, + fontWeight: 700, + lineHeight: "14px", + textAlign: "center", + pointerEvents: "none", + })} + > + {badgeCount > 99 ? "99+" : badgeCount.toLocaleString()} + + ) : null} + + + {error ? ( + + {error} + + ) : null} + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/components/graph-controls.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/components/graph-controls.tsx new file mode 100644 index 00000000000..517007137a0 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/components/graph-controls.tsx @@ -0,0 +1,51 @@ +import { Tooltip } from "@mui/material"; + +import { + MagnifyingGlassMinusLightIcon, + MagnifyingGlassPlusLightIcon, +} from "@hashintel/design-system"; + +import { ArrowsToLineRegularIcon } from "../../../../shared/icons/arrows-to-line-regular-icon"; +import { GrayToBlueIconButton } from "../../gray-to-blue-icon-button"; + +interface GraphControlsProps { + readonly onZoomIn: () => void; + readonly onZoomOut: () => void; + readonly onFitView: () => void; +} + +export const GraphControls = ({ + onZoomIn, + onZoomOut, + onFitView, +}: GraphControlsProps) => ( + <> + + + + + + + + + + + + + + + + +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/components/graph-overlay-panel.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/components/graph-overlay-panel.tsx new file mode 100644 index 00000000000..5fbd6d39eed --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/components/graph-overlay-panel.tsx @@ -0,0 +1,38 @@ +import { Box } from "@mui/material"; + +import type { ReactNode } from "react"; + +interface GraphOverlayPanelProps { + readonly children: ReactNode; + readonly placement?: "top-left" | "top-right" | "bottom-left"; + readonly interactive?: boolean; +} + +const placementSx = { + "top-left": { top: 8, left: 8 }, + "top-right": { top: 8, right: 13 }, + "bottom-left": { bottom: 8, left: 8 }, +} as const; + +export const GraphOverlayPanel = ({ + children, + placement = "top-left", + interactive = true, +}: GraphOverlayPanelProps) => ( + ({ + position: "absolute", + zIndex: 7, + maxWidth: 320, + p: 1.25, + borderRadius: 2, + border: `1px solid ${palette.gray[30]}`, + bgcolor: palette.white, + boxShadow: boxShadows.sm, + pointerEvents: interactive ? "auto" : "none", + ...placementSx[placement], + })} + > + {children} + +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/components/graph-status-overlay.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/components/graph-status-overlay.tsx new file mode 100644 index 00000000000..9951a20acef --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/components/graph-status-overlay.tsx @@ -0,0 +1,46 @@ +import { Stack, Typography } from "@mui/material"; + +import { LoadingSpinner } from "@hashintel/design-system"; + +import { GraphOverlayPanel } from "./graph-overlay-panel"; + +interface GraphStatusOverlayProps { + readonly title: string; + readonly description?: string; + readonly variant?: "loading" | "empty" | "error"; +} + +export const GraphStatusOverlay = ({ + title, + description, + variant = "loading", +}: GraphStatusOverlayProps) => ( + + + {variant === "loading" ? : null} + + + variant === "error" ? palette.red[80] : palette.gray[90], + }} + > + {title} + + {description ? ( + + {description} + + ) : null} + + + +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/components/node-label-overlay.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/components/node-label-overlay.tsx new file mode 100644 index 00000000000..ad768f9026d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/components/node-label-overlay.tsx @@ -0,0 +1,67 @@ +import { alpha, Box } from "@mui/material"; +import { memo } from "react"; + +import type { NodeLabel } from "../render/scene/scene"; + +interface NodeLabelOverlayProps { + readonly labels: readonly NodeLabel[]; +} + +/** + * One hub label's pill, memoized on its text so a per-frame position change (the wrapper's + * transform) never re-lays out the text. Positioned absolutely at the wrapper's origin (which the + * Scene places to the RIGHT of the dot) and vertically centred on the dot via translateY(-50%), + * left-aligned -- so it reads "Name" beside the dot with the text start as the stable anchor. + */ +const HubLabel = memo(({ text }: { readonly text: string }) => ( + ({ + position: "absolute", + transform: "translateY(-50%)", + maxWidth: 180, + px: 0.75, + py: 0.2, + borderRadius: "5px", + bgcolor: alpha(palette.common.white, 0.88), + border: `1px solid ${palette.gray[20]}`, + boxShadow: boxShadows.sm, + typography: "microText", + fontWeight: 700, + letterSpacing: "-0.01em", + color: palette.gray[90], + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + })} + > + {text} + +)); + +/** + * The always-on hub labels, overlaid as HTML over the canvas (in the hash-frontend design + * language rather than GPU text). The Scene re-emits `labels` -- only the on-screen hubs, with + * their current container-pixel positions -- each frame, so they track the camera + settling + * layout. Each label rides a cheap GPU transform; the pill itself is memoized on its text. + * Click-through (pointer-events disabled) so it never eats a hover / pan on the canvas. + */ +export const NodeLabelOverlay = ({ labels }: NodeLabelOverlayProps) => ( + <> + {labels.map((label) => ( +
+ +
+ ))} + +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/components/scene-overlay-store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/components/scene-overlay-store.ts new file mode 100644 index 00000000000..f062a60a076 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/components/scene-overlay-store.ts @@ -0,0 +1,139 @@ +/** + * Render-free bus for the Scene's per-frame overlay reports (hover cards, the pinned + * selection card, the hub labels): the Scene re-emits current on-screen positions EVERY + * FRAME while the camera moves or the layout settles, so holding them as bridge state + * would re-render the whole bridge per frame. Instead each report lands in a slice here + * and only the leaf overlay subscribed to that slice re-renders (`useOverlaySlice`). + */ +import { useSyncExternalStore } from "react"; + +import type { + ClusterHover, + FlatEdgeHover, + HighwayHover, + NodeHover, + NodeLabel, + NodeSelection, +} from "../render/scene/scene"; + +/** + * Grace period the frontier-cluster card stays open after the cursor leaves its bubble, so + * the cursor can reach the card's Load button (the standard interactive-tooltip handoff). + */ +const CLUSTER_CARD_CLOSE_GRACE_MS = 140; + +/** One overlay value, subscribable by exactly the component that renders it. */ +export class OverlaySlice { + #value: Value; + readonly #listeners = new Set<() => void>(); + + constructor(initialValue: Value) { + this.#value = initialValue; + } + + readonly getValue = (): Value => this.#value; + + readonly subscribe = (listener: () => void): (() => void) => { + this.#listeners.add(listener); + + return () => { + this.#listeners.delete(listener); + }; + }; + + readonly setValue = (next: Value): void => { + if (Object.is(next, this.#value)) { + return; + } + + this.#value = next; + + for (const listener of this.#listeners) { + listener(); + } + }; +} + +/** Subscribe to one slice; re-renders only the calling component when it changes. */ +export const useOverlaySlice = (slice: OverlaySlice): Value => + useSyncExternalStore(slice.subscribe, slice.getValue, slice.getValue); + +const noLabels: readonly [] = []; + +export class SceneOverlayStore { + readonly nodeHover = new OverlaySlice | null>(null); + readonly edgeHover = new OverlaySlice | null>(null); + readonly highwayHover = new OverlaySlice(null); + readonly selection = new OverlaySlice | null>(null); + readonly nodeLabels = new OverlaySlice[]>( + noLabels, + ); + + /** + * The hovered wholly-frontier cluster, shown as an interactive load card. Unlike the other + * hover cards this one has a button, so it stays open while the cursor is over the bubble + * OR the card: a bubble-leave starts a short grace timer that the card's own enter cancels. + */ + readonly clusterHover = new OverlaySlice(null); + + #clusterCardHovered = false; + #clusterCloseTimer: ReturnType | null = null; + + /** Scene-facing: a cluster-bubble hover report, or null when the cursor leaves the bubble. */ + readonly handleClusterHover = (hover: ClusterHover | null): void => { + if (hover !== null) { + this.#cancelClusterCloseTimer(); + this.clusterHover.setValue(hover); + return; + } + + // The cursor left the bubble; keep the card briefly so it can reach the button. The + // card's own mouse-enter cancels this; its mouse-leave closes immediately. + if (this.#clusterCardHovered || this.#clusterCloseTimer !== null) { + return; + } + + this.#clusterCloseTimer = setTimeout(() => { + this.#clusterCloseTimer = null; + if (!this.#clusterCardHovered) { + this.clusterHover.setValue(null); + } + }, CLUSTER_CARD_CLOSE_GRACE_MS); + }; + + readonly handleClusterCardEnter = (): void => { + this.#clusterCardHovered = true; + this.#cancelClusterCloseTimer(); + }; + + readonly handleClusterCardLeave = (): void => { + this.#clusterCardHovered = false; + this.clusterHover.setValue(null); + }; + + /** Close the cluster card now (its Load action fired). */ + readonly dismissClusterCard = (): void => { + this.#clusterCardHovered = false; + this.#cancelClusterCloseTimer(); + this.clusterHover.setValue(null); + }; + + /** Clear all transient overlay state (scene torn down / worker recreated). */ + reset(): void { + this.#clusterCardHovered = false; + this.#cancelClusterCloseTimer(); + this.nodeHover.setValue(null); + this.edgeHover.setValue(null); + this.highwayHover.setValue(null); + this.clusterHover.setValue(null); + this.selection.setValue(null); + this.nodeLabels.setValue(noLabels); + } + + #cancelClusterCloseTimer(): void { + if (this.#clusterCloseTimer !== null) { + clearTimeout(this.#clusterCloseTimer); + this.#clusterCloseTimer = null; + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/config.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/config.ts new file mode 100644 index 00000000000..c1784dead03 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/config.ts @@ -0,0 +1,616 @@ +/** + * Composition root for visualizer tuning. Each engine's tuning interface and + * defaults live with (or beside) the engine module; this file composes them + * into {@link VizConfig} / {@link defaultVizConfig} and re-exports the types + * so main-thread consumers (dev harness, visualizer wiring) have one import + * surface. Cross-module policy groups with no single owning engine + * ({@link LayoutStabilityConfig}, {@link IngestTuningConfig}) are defined + * here, importing module-owned defaults where they exist. + */ +import { + MAX_COALESCE_DELAY_MS, + MAX_COALESCED_BATCHES, +} from "./worker/core/commit-coalescer"; +import { + FLAT_SEED_DISK_SCALE, + FLAT_SEED_NEIGHBOUR_OFFSET, +} from "./worker/core/flat-seed"; +import { + GROWTH_RELAYOUT_TOLERANCE_FRAC, + OVERLAP_REBUILD_TOLERANCE_FRAC, +} from "./worker/entity-graph/hierarchical/layout-reuse"; +import { defaultEntityStyleConfig } from "./worker/entity-style"; +import { defaultClusterSizingConfig } from "./worker/hierarchy/cluster-sizing-config"; +import { defaultClusterForceConfig } from "./worker/layout/cluster-layout-config"; +import { defaultEntityForceConfig } from "./worker/layout/entity-layout-config"; +import { defaultFlatForceConfig } from "./worker/layout/flat-layout-config"; +import { defaultMajorizationConfig } from "./worker/layout/majorization-config"; +import { defaultTopLevelPolishConfig } from "./worker/layout/top-level-layout"; +import { defaultUntangleConfig } from "./worker/layout/untangle"; + +import type { EntityStyleConfig } from "./worker/entity-style"; +import type { ClusterSizingConfig } from "./worker/hierarchy/cluster-sizing-config"; +import type { ClusterForceConfig } from "./worker/layout/cluster-layout-config"; +import type { EntityForceConfig } from "./worker/layout/entity-layout-config"; +import type { FlatForceConfig } from "./worker/layout/flat-layout-config"; +import type { MajorizationConfig } from "./worker/layout/majorization-config"; +import type { TopLevelPolishConfig } from "./worker/layout/top-level-layout"; +import type { UntangleConfig } from "./worker/layout/untangle"; + +export type { + ClusterForceConfig, + ClusterSizingConfig, + EntityForceConfig, + EntityStyleConfig, + FlatForceConfig, + MajorizationConfig, + TopLevelPolishConfig, + UntangleConfig, +}; + +/** Layout reuse, seeding, and re-layout stability thresholds. */ +export interface LayoutStabilityConfig { + /** + * How far one bubble may penetrate another (as a fraction of the smaller + * radius) before a settled layout is rebuilt. + * + * @defaultValue 0.05. A small dead-band so freshly-solved padding doesn't + * trigger an immediate rebuild. + */ + readonly overlapRebuildTolerance: number; + /** + * How much a child may grow (as a fraction of its build-time radius) before + * the top-level layout is voluntarily re-warmed. + * + * @defaultValue 0.15 (~+32% members, since radius grows as sqrt(count)). + */ + readonly growthRelayoutTolerance: number; + /** Seed offset (world units) for a streamed node placed beside a placed neighbour. @defaultValue 24. */ + readonly flatSeedNeighbourOffset: number; + /** Phyllotaxis disk scale (world units) for cold-start / orphan flat nodes. @defaultValue 28. */ + readonly flatSeedDiskScale: number; + /** + * After community-force ingests go quiet for this long (ms), run one trailing + * Louvain so BubbleSets reflect the settled graph. + * + * @defaultValue 100. + */ + readonly flatLouvainLingerMs: number; +} + +/** Streaming-ingest commit coalescing and tick diagnostics. */ +export interface IngestTuningConfig { + /** + * Flush once this many ingest batches are pending, even mid-burst. + * + * @defaultValue 8. Bounds how much a sustained stream batches into one + * commit (progressive rendering). + */ + readonly maxCoalescedBatches: number; + /** + * Flush when the oldest pending batch is older than this (ms, checked at + * enqueue time). + * + * @defaultValue 100. + */ + readonly maxCoalesceDelayMs: number; + /** Debug-mode threshold (ms) above which a simulation tick logs a warning. @defaultValue 10. */ + readonly slowTickWarningMs: number; +} + +export interface VizConfig { + /** + * Node count below which the worker (re-)enters the flat-force tier from + * community-force, laying out every entity as an individually simulated dot. + * + * @defaultValue 200. Paired with {@link VizConfig.flatLayoutExitNodes} as a + * hysteresis band (checked by {@link validateConfig}): raising it keeps larger, + * shrinking graphs in the cheaper flat layout longer. + */ + readonly flatLayoutMaxNodes: number; + /** + * Node count above which the worker leaves the flat-force tier, moving to + * community-force (or straight to hierarchical-lod if also above + * {@link VizConfig.communityColorExitNodes}). + * + * @defaultValue 250. Must stay above {@link VizConfig.flatLayoutMaxNodes}; + * widening the gap between the two reduces mode flip-flopping as the entity + * count hovers near the boundary. + */ + readonly flatLayoutExitNodes: number; + /** + * Node count below which the worker returns from hierarchical-lod to the + * community-force tier. + * + * @defaultValue 4000. Paired with {@link VizConfig.communityColorExitNodes} as a + * hysteresis band; raising it keeps a shrinking graph in the cheaper + * community-force tier longer. + */ + readonly communityColorMaxNodes: number; + /** + * Node count above which the worker enters the hierarchical-lod tier from + * either flat-force or community-force. + * + * @defaultValue 5000. Must stay above {@link VizConfig.communityColorMaxNodes} + * and at or above {@link VizConfig.flatLayoutExitNodes} (no gap between tiers); + * raising it delays the switch to hierarchical rollups on growing graphs. + */ + readonly communityColorExitNodes: number; + + /** + * Minimum member count for a type-set group to render as its own standalone + * cluster instead of being folded into a rollup. + * + * @defaultValue 25. Lowering it surfaces more small, distinct clusters at the + * cost of visual clutter; raising it rolls more small groups together. + */ + readonly minStandaloneTypeSet: number; + /** + * Minimum Jaccard similarity between two type-set groups' inheritance closures + * for them to merge into one cluster. + * + * @defaultValue 0.25. Lowering it merges more loosely-related type sets, + * reducing cluster count at the cost of grouping less similar entities together. + */ + readonly mergeJaccardMin: number; + /** + * Lower Jaccard threshold that still allows a merge when one type set's direct + * types are a subset of the other's. + * + * @defaultValue 0.15. Lower than {@link VizConfig.mergeJaccardMin} because a + * direct-subset relationship is already strong evidence the groups belong + * together; lowering it further merges more subset relationships at the cost of + * grouping entities with more divergent ancestor types. + */ + readonly mergeSubsetJaccardMin: number; + /** + * Maximum direct children a cluster-tree parent keeps before bucketing the + * remainder under bounded rollup children. + * + * @defaultValue 64. Raising it flattens the tree (fewer intermediate rollups) at + * the cost of more siblings to lay out and pick among at each level. + */ + readonly maxChildrenPerParent: number; + + /** + * Entity count above which a cluster becomes a candidate for community-based + * subclustering. + * + * @defaultValue 500. Currently unused: the worker keys subdivision decisions off + * {@link VizConfig.entityRevealMax} instead; reserved for decoupling the two + * thresholds. + */ + readonly subclusterAboveCount: number; + /** + * Entity count above which a cluster must be expanded (children revealed or + * subdivided) rather than rendered as a single rolled-up node. + * + * @defaultValue 500. Must stay at or below {@link VizConfig.forceMaxNodes} (see + * {@link validateConfig}); lowering it reveals structure earlier at smaller + * scales at the cost of more on-screen elements. + */ + readonly entityRevealMax: number; + /** + * Upper bound on the entity count the worker will run through force-directed + * layout in one pass. + * + * @defaultValue 2000. Raising it lets larger clusters use force layout directly + * at the cost of longer settle times. + */ + readonly forceMaxNodes: number; + /** + * Entity count above which community detection for a cluster falls back to + * coarse link-signature bucketing instead of full community detection. + * + * @defaultValue 50,000. Raising it runs true community detection on larger + * clusters at the cost of worker CPU time; lowering it favours the cheaper + * bucketing sooner. + */ + readonly communityWorkerNodeCap: number; + /** + * Communities smaller than this are pooled into a single catch-all group + * instead of standing alone. + * + * @defaultValue 20. Raising it reduces the number of tiny, low-signal + * communities shown at the cost of merging more entities into the catch-all. + */ + readonly communityMinSize: number; + /** + * Communities larger than this are split into equal-sized chunks so no single + * community dominates the layout. + * + * @defaultValue 500. Lowering it produces more, smaller communities at the cost + * of splitting communities a domain expert would otherwise consider one group. + */ + readonly communityMaxSize: number; + + /** + * Fraction of the viewport's minimum dimension a cluster's on-screen radius + * must exceed, while centred in view, to open and reveal its children. + * + * @defaultValue 0.35. Raising it delays revealing children until the user is + * more zoomed in, reducing on-screen clutter at the cost of more zooming to + * reach detail. + */ + readonly openChildrenFraction: number; + /** + * Fraction of the viewport's minimum dimension below which an already-open + * cluster's children collapse back into it. + * + * @defaultValue 0.25. Lower than {@link VizConfig.openChildrenFraction} to give + * the open/close decision hysteresis; narrowing the gap makes children flicker + * open and closed more readily as the user zooms. + */ + readonly closeChildrenFraction: number; + /** + * Fraction of the viewport's minimum dimension a cluster's on-screen radius + * must exceed, while centred in view, to reveal individual entities. + * + * @defaultValue 0.45. Raising it delays entity-level detail until deeper zoom, + * favouring performance on large clusters over early detail. + */ + readonly openEntitiesFraction: number; + /** + * Fraction of the viewport's minimum dimension below which revealed entities + * collapse back into their parent cluster. + * + * @defaultValue 0.3. Lower than {@link VizConfig.openEntitiesFraction} for the + * same open/close hysteresis as {@link VizConfig.closeChildrenFraction}. + */ + readonly closeEntitiesFraction: number; + + /** + * Target dimensionality for the embedding projection used to subdivide very + * large homogeneous clusters. + * + * @defaultValue 128. Currently unused; the embedding-based subdivision path + * this was designed for is not yet wired up in the worker. + */ + readonly embeddingProjectionDims: number; + /** + * Maximum number of k-means clusters embedding subdivision will target for one + * parent cluster. + * + * @defaultValue 32. Raising it allows finer subdivision of very large clusters + * at the cost of more child nodes to lay out. + */ + readonly embeddingMaxK: number; + /** + * Target fraction of {@link VizConfig.entityRevealMax} each embedding-subdivided + * leaf should hold, used to size k in k-means. + * + * @defaultValue 0.75. Raising it aims for fuller (fewer, larger) leaves closer to + * the reveal threshold; lowering it produces more, smaller leaves with headroom + * before they need further subdivision. + */ + readonly embeddingTargetLeafFillRatio: number; + /** + * Entity count above which embedding computation is expected to move off the + * main thread. + * + * @defaultValue 25,000. Currently unused; reserved for when embedding + * computation gains an off-thread path. + */ + readonly embeddingClientNodeCap: number; + /** + * Minimum cluster concentration (tightness of the embedding) required before + * embedding-based subdivision is attempted. + * + * @defaultValue 0.3. Currently unused, pending the embedding subdivision path + * described under {@link VizConfig.embeddingProjectionDims}. + */ + readonly embeddingMinConcentration: number; + + /** + * Minimum on-screen spacing, in pixels, aimed for between adjacent bubble ports + * on a cluster's rim. + * + * @defaultValue 12. Currently unused; port spacing is instead derived from + * {@link VizConfig.maxPortsPerCluster}. + */ + readonly minPortSpacingPx: number; + /** + * Maximum number of distinct bubble ports a cluster shows before neighbours are + * merged by angular sector. + * + * @defaultValue 24. Raising it keeps more neighbour connections visually + * distinct at the cost of denser, more cluttered cluster rims. + */ + readonly maxPortsPerCluster: number; + /** + * World-space padding applied when placing a port relative to its cluster's + * rim. + * + * @defaultValue 0. Raising it pulls edge endpoints further from the cluster + * boundary, trading a cleaner rim silhouette for less precise + * edge-to-boundary contact. + */ + readonly portPaddingWorld: number; + /** + * Multiplier controlling how far a curved edge's control point bows away from + * the straight line between its ports, scaled by segment length. + * + * @defaultValue 0.4. Raising it produces more pronounced curves, which helps + * separate parallel edges at the cost of longer, more circuitous-looking edge + * paths. + */ + readonly portTension: number; + + /** + * Budget cap on the number of clusters left open (showing children) at once; + * the LOD pass only opens a cluster's children if doing so keeps the running + * total at or under this cap. + * + * @defaultValue 4000. Raising it shows more structure open at once at the cost + * of GPU draw time. + */ + readonly maxRenderedClusters: number; + /** + * Budget cap on individual entities revealed (rather than rolled up into their + * parent cluster) at once; a leaf cluster only reveals entities if doing so + * keeps the running total at or under this cap. + * + * @defaultValue 5000. Raising it reveals more entity-level detail at once at + * the cost of GPU draw time and hit-testing overhead. + */ + readonly maxRenderedEntities: number; + /** + * Maximum number of aggregated edges the renderer draws in one frame; edges + * beyond the cap are truncated from the frame. + * + * @defaultValue 10,000. Raising it shows more connections at once at the cost + * of GPU draw time and visual clutter. + */ + readonly maxRenderedEdges: number; + /** + * Maximum distinct link types shown as separate parallel edges between the same + * pair of endpoints before they collapse into one multi-type rollup edge. + * + * @defaultValue 5. Raising it keeps more relationship types visually distinct + * between the same pair of nodes at the cost of a busier bundle of parallel + * curves. + */ + readonly maxParallelEdgeTypes: number; + + /** + * On-screen pixel spacing between adjacent parallel edges connecting the same + * pair of endpoints. + * + * @defaultValue 7. Raising it makes parallel edges easier to distinguish at the + * cost of a wider overall bundle. + */ + readonly parallelEdgeSpacingPx: number; + /** + * Curvature multiplier applied to parallel and self-loop edges. + * + * @defaultValue 1.6. Raising it bows edges further from a straight line, which + * helps separate overlapping edges at the cost of longer visual paths. + */ + readonly parallelEdgeCurvature: number; + /** + * Number of line segments used to approximate a curved edge. + * + * @defaultValue 16. Raising it produces smoother curves at the cost of more + * vertices to upload and draw per edge. + */ + readonly curveSegments: number; + + /** Stress-majorization engine tuning (community-force flat tier). */ + readonly majorization: MajorizationConfig; + /** WebCola flat layout tuning (small-N flat-force tier). */ + readonly flatForce: FlatForceConfig; + /** Top-level overview polish (crossing/detour optimiser) tuning. */ + readonly topLevelPolish: TopLevelPolishConfig; + /** Sub-cluster untangle polish tuning. */ + readonly untangle: UntangleConfig; + /** WebCola cluster (bubble) layout tuning. */ + readonly clusterForce: ClusterForceConfig; + /** d3-force entity (dot) layout tuning. */ + readonly entityForce: EntityForceConfig; + /** Cluster-bubble and entity-dot sizing. */ + readonly clusterSizing: ClusterSizingConfig; + /** Entity dot/edge colour and size style. */ + readonly entityStyle: EntityStyleConfig; + /** Layout reuse / seeding / re-layout stability thresholds. */ + readonly stability: LayoutStabilityConfig; + /** Streaming-ingest coalescing and diagnostics. */ + readonly ingest: IngestTuningConfig; + + /** Enable noisy worker diagnostics. Intended for local profiling/debug only. */ + readonly debug: boolean; +} + +export const defaultVizConfig: VizConfig = { + flatLayoutMaxNodes: 200, + flatLayoutExitNodes: 250, + communityColorMaxNodes: 4000, + communityColorExitNodes: 5000, + + minStandaloneTypeSet: 25, + mergeJaccardMin: 0.25, + mergeSubsetJaccardMin: 0.15, + maxChildrenPerParent: 64, + + subclusterAboveCount: 500, + entityRevealMax: 500, + forceMaxNodes: 2_000, + communityWorkerNodeCap: 50_000, + communityMinSize: 20, + communityMaxSize: 500, + + openChildrenFraction: 0.35, + closeChildrenFraction: 0.25, + openEntitiesFraction: 0.45, + closeEntitiesFraction: 0.3, + + embeddingProjectionDims: 128, + embeddingMaxK: 32, + embeddingTargetLeafFillRatio: 0.75, + embeddingClientNodeCap: 25_000, + embeddingMinConcentration: 0.3, + + minPortSpacingPx: 12, + maxPortsPerCluster: 24, + portPaddingWorld: 0, + portTension: 0.4, + + maxRenderedClusters: 4_000, + maxRenderedEntities: 5_000, + maxRenderedEdges: 10_000, + maxParallelEdgeTypes: 5, + + parallelEdgeSpacingPx: 7, + parallelEdgeCurvature: 1.6, + curveSegments: 16, + + majorization: defaultMajorizationConfig, + flatForce: defaultFlatForceConfig, + topLevelPolish: defaultTopLevelPolishConfig, + untangle: defaultUntangleConfig, + clusterForce: defaultClusterForceConfig, + entityForce: defaultEntityForceConfig, + clusterSizing: defaultClusterSizingConfig, + entityStyle: defaultEntityStyleConfig, + + stability: { + overlapRebuildTolerance: OVERLAP_REBUILD_TOLERANCE_FRAC, + growthRelayoutTolerance: GROWTH_RELAYOUT_TOLERANCE_FRAC, + flatSeedNeighbourOffset: FLAT_SEED_NEIGHBOUR_OFFSET, + flatSeedDiskScale: FLAT_SEED_DISK_SCALE, + flatLouvainLingerMs: 100, + }, + + ingest: { + maxCoalescedBatches: MAX_COALESCED_BATCHES, + maxCoalesceDelayMs: MAX_COALESCE_DELAY_MS, + slowTickWarningMs: 10, + }, + + debug: false, +}; + +/** + * Copy a config one group deep: a fresh root object with fresh nested group + * objects (groups are flat value objects, so one level suffices). + * + * {@link EntityGraphWorker} clones the config it is constructed with so that + * {@link assignVizConfigInPlace} can mutate the worker's copy without the + * caller (which may have passed the shared {@link defaultVizConfig}, or a + * shallow spread of it) observing the mutation. + */ +export function cloneVizConfig(config: VizConfig): VizConfig { + const result: Record = {}; + for (const [key, value] of Object.entries(config)) { + result[key] = + value !== null && typeof value === "object" ? { ...value } : value; + } + return result as unknown as VizConfig; +} + +/** + * Overwrite `target`'s values with `next`'s, preserving the identity of the + * root object and of every nested group object. + * + * The worker shares one {@link VizConfig} reference (and references to its + * nested groups, e.g. `config.topLevelPolish` held by the settle polisher) + * across its collaborators, so a live config update must mutate in place + * rather than replace objects. A full `VizConfig` always carries every + * field, so nothing in `target` survives unoverwritten. + */ +export function assignVizConfigInPlace( + target: VizConfig, + next: VizConfig, +): void { + const targetRecord = target as unknown as Record; + for (const [key, value] of Object.entries(next)) { + const current = targetRecord[key]; + if ( + value !== null && + typeof value === "object" && + current !== null && + typeof current === "object" + ) { + Object.assign(current, value); + } else { + targetRecord[key] = value; + } + } +} + +/** + * Validates the hysteresis pairs (`flatLayoutMaxNodes`/`flatLayoutExitNodes`, + * `communityColorMaxNodes`/`communityColorExitNodes`, + * `closeChildrenFraction`/`openChildrenFraction`, + * `closeEntitiesFraction`/`openEntitiesFraction`), that `flatLayoutExitNodes` + * leaves no gap before `communityColorMaxNodes`, the `entityRevealMax <= + * forceMaxNodes` cap, and the `communityMinSize < communityMaxSize` bound. Called + * on worker init so a bad config fails loudly at startup rather than producing + * subtly wrong hysteresis or mode transitions later. + * + * @throws {Error} When any checked pair is unordered, the mode-transition gap is + * violated, or the reveal/force cap is exceeded; the message names the failing + * field. + */ +export function validateConfig(config: VizConfig): void { + function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`Invalid VizConfig: ${message}`); + } + } + + assert( + config.entityRevealMax <= config.forceMaxNodes, + `entityRevealMax (${config.entityRevealMax}) must be <= forceMaxNodes (${config.forceMaxNodes})`, + ); + assert( + config.flatLayoutMaxNodes < config.flatLayoutExitNodes, + "flatLayoutMaxNodes must be < flatLayoutExitNodes (hysteresis)", + ); + assert( + config.communityColorMaxNodes < config.communityColorExitNodes, + "communityColorMaxNodes must be < communityColorExitNodes (hysteresis)", + ); + assert( + config.flatLayoutExitNodes <= config.communityColorMaxNodes, + "flatLayoutExitNodes must be <= communityColorMaxNodes (no mode gap)", + ); + assert( + config.closeChildrenFraction < config.openChildrenFraction, + "closeChildrenFraction must be < openChildrenFraction (hysteresis)", + ); + assert( + config.closeEntitiesFraction < config.openEntitiesFraction, + "closeEntitiesFraction must be < openEntitiesFraction (hysteresis)", + ); + assert( + config.communityMinSize < config.communityMaxSize, + "communityMinSize must be < communityMaxSize", + ); + assert( + config.topLevelPolish.idealGapFraction >= + config.topLevelPolish.overlapPadFraction, + "topLevelPolish.idealGapFraction must be >= topLevelPolish.overlapPadFraction (stress and non-overlap would fight)", + ); + assert( + config.flatForce.overlapMinIterations <= + config.flatForce.overlapMaxIterations, + "flatForce.overlapMinIterations must be <= flatForce.overlapMaxIterations", + ); + assert( + config.entityStyle.hubLinkFadeStartDegree < + config.entityStyle.hubLinkFadeEndDegree, + "entityStyle.hubLinkFadeStartDegree must be < entityStyle.hubLinkFadeEndDegree", + ); + assert( + config.entityStyle.minLightness <= config.entityStyle.maxLightness, + "entityStyle.minLightness must be <= entityStyle.maxLightness", + ); + assert( + config.majorization.idealEdgeLength > 0, + "majorization.idealEdgeLength must be > 0", + ); + assert( + config.clusterSizing.radiusPerSqrtCount > 0, + "clusterSizing.radiusPerSqrtCount must be > 0", + ); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/build-entities.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/build-entities.ts new file mode 100644 index 00000000000..297079abc69 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/build-entities.ts @@ -0,0 +1,241 @@ +/** + * Build the synthetic {@link HashEntity} set: normal entities (one per kind, with a populated label + * property so `generateEntityLabel` resolves a name) plus link entities connecting them with a + * hub-skewed degree distribution, so a few high-degree hubs emerge and the hub-label feature is + * exercised. + * + * Entities are constructed by mirroring the Graph-API plain-object shape (recordId.entityId in + * `webId~entityUuid` form, non-empty entityTypeIds, provenance, temporalVersioning, per-property + * `metadata.dataTypeId`) and wrapping it in `new HashEntity(...)`. + */ +import { HashEntity } from "@local/hash-graph-sdk/entity"; + +import { mulberry32 } from "../math/random"; + +import type { FixtureEntityKind } from "./build-type-maps"; +import type { + BaseUrl, + EntityId, + VersionedUrl, +} from "@blockprotocol/type-system"; +import type { Entity as GraphApiEntity } from "@local/hash-graph-client"; + +const WEB_ID = "00000000-0000-0000-0000-000000000001"; + +const TEXT_DATA_TYPE_ID = + "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1"; + +const LINK_LABEL_BASE_URL = + "https://hash.ai/@dev/types/property-type/relationship-label/" as BaseUrl; + +const FIXED_TIMESTAMP = "2024-01-01T00:00:00Z"; +const ACTOR_ID = "4ed14962-7132-4453-8fc5-39b5c2131d45"; + +/** Inputs for {@link buildEntities}. */ +export interface BuildEntitiesParams { + readonly entityCount: number; + readonly kinds: readonly FixtureEntityKind[]; + readonly linkTypeId: VersionedUrl; + /** Average links emitted per node; total links is roughly `entityCount * linkDensity`. */ + readonly linkDensity: number; + /** Count of high-degree hub nodes that many other nodes link into. */ + readonly hubCount: number; + readonly seed: number; +} + +/** The built entity set plus the ordered ids, so the harness can stream and pick roots in order. */ +export interface EntityFixture { + readonly entities: HashEntity[]; + readonly nodeEntityIds: readonly EntityId[]; +} + +/** A 16-hex-digit segment of a deterministic, fixture-shaped UUID. */ +function hexSegment(rng: () => number, length: number): string { + let out = ""; + for (let index = 0; index < length; index++) { + out += Math.floor(rng() * 16).toString(16); + } + return out; +} + +function makeUuid(rng: () => number): string { + return [ + hexSegment(rng, 8), + hexSegment(rng, 4), + hexSegment(rng, 4), + hexSegment(rng, 4), + hexSegment(rng, 12), + ].join("-"); +} + +function makeEntityId(rng: () => number): EntityId { + // Fixture ids use the Graph API webId~uuid shape expected by EntityId. + return `${WEB_ID}~${makeUuid(rng)}` as EntityId; +} + +/** Provenance block shared by every synthetic entity. */ +function provenance(): GraphApiEntity["metadata"]["provenance"] { + return { + createdById: ACTOR_ID, + createdAtDecisionTime: FIXED_TIMESTAMP, + createdAtTransactionTime: FIXED_TIMESTAMP, + firstNonDraftCreatedAtDecisionTime: FIXED_TIMESTAMP, + firstNonDraftCreatedAtTransactionTime: FIXED_TIMESTAMP, + edition: { + createdById: ACTOR_ID, + actorType: "machine", + origin: { type: "api" }, + }, + }; +} + +/** Temporal versioning block (decision + transaction time, both unbounded) shared by every entity. */ +function temporalVersioning(): GraphApiEntity["metadata"]["temporalVersioning"] { + const interval = { + start: { kind: "inclusive" as const, limit: FIXED_TIMESTAMP }, + end: { kind: "unbounded" as const }, + }; + return { decisionTime: interval, transactionTime: interval }; +} + +/** + * A single text property's flat value plus the matching nested metadata entry (carrying the + * dataTypeId), so the entity exposes both `entity.properties[baseUrl]` (read by the label path) and + * the per-property `metadata`. + */ +function textProperty(baseUrl: BaseUrl, value: string) { + return { + flat: { [baseUrl]: value }, + metadata: { + [baseUrl]: { + metadata: { dataTypeId: TEXT_DATA_TYPE_ID }, + }, + }, + }; +} + +function buildNode(params: { + readonly entityId: EntityId; + readonly editionId: string; + readonly kind: FixtureEntityKind; + readonly label: string; +}): GraphApiEntity { + const name = textProperty(params.kind.labelPropertyBaseUrl, params.label); + const description = textProperty( + params.kind.secondaryPropertyBaseUrl, + `A synthetic ${params.kind.name.toLowerCase()} for the dev harness.`, + ); + + return { + metadata: { + archived: false, + entityTypeIds: [params.kind.typeId], + provenance: provenance(), + temporalVersioning: temporalVersioning(), + recordId: { editionId: params.editionId, entityId: params.entityId }, + properties: { value: { ...name.metadata, ...description.metadata } }, + }, + properties: { ...name.flat, ...description.flat }, + }; +} + +function buildLink(params: { + readonly entityId: EntityId; + readonly editionId: string; + readonly linkTypeId: VersionedUrl; + readonly leftEntityId: EntityId; + readonly rightEntityId: EntityId; + readonly label: string; +}): GraphApiEntity { + const labelProperty = textProperty(LINK_LABEL_BASE_URL, params.label); + + return { + metadata: { + archived: false, + entityTypeIds: [params.linkTypeId], + provenance: provenance(), + temporalVersioning: temporalVersioning(), + recordId: { editionId: params.editionId, entityId: params.entityId }, + properties: { value: { ...labelProperty.metadata } }, + }, + properties: { ...labelProperty.flat }, + linkData: { + leftEntityId: params.leftEntityId, + rightEntityId: params.rightEntityId, + }, + }; +} + +/** + * Pick a link target index with a hub bias: most targets are drawn from the first `hubCount` nodes, + * the rest uniformly. This concentrates incoming links on a few hubs so the 95th-percentile hub + * detection promotes them. + */ +function pickTarget( + rng: () => number, + nodeCount: number, + hubCount: number, +): number { + const effectiveHubs = Math.min(hubCount, nodeCount); + if (effectiveHubs > 0 && rng() < 0.7) { + return Math.floor(rng() * effectiveHubs); + } + return Math.floor(rng() * nodeCount); +} + +export function buildEntities(params: BuildEntitiesParams): EntityFixture { + const rng = mulberry32(params.seed); + const nodeCount = Math.max(1, params.entityCount); + + const nodes: GraphApiEntity[] = []; + const nodeIds: EntityId[] = []; + const perKindCounter = new Map(); + + for (let index = 0; index < nodeCount; index++) { + // kinds is non-empty (buildTypeMaps clamps kindCount >= 1). + const kind = params.kinds[index % params.kinds.length]!; + const ordinal = (perKindCounter.get(kind.typeId) ?? 0) + 1; + perKindCounter.set(kind.typeId, ordinal); + + const entityId = makeEntityId(rng); + nodeIds.push(entityId); + nodes.push( + buildNode({ + entityId, + editionId: makeUuid(rng), + kind, + label: `${kind.name} ${ordinal}`, + }), + ); + } + + const links: GraphApiEntity[] = []; + const targetLinkCount = Math.round( + nodeCount * Math.max(0, params.linkDensity), + ); + + for (let index = 0; index < targetLinkCount; index++) { + const sourceIndex = Math.floor(rng() * nodeCount); + let targetIndex = pickTarget(rng, nodeCount, params.hubCount); + if (targetIndex === sourceIndex) { + targetIndex = (targetIndex + 1) % nodeCount; + } + + links.push( + buildLink({ + entityId: makeEntityId(rng), + editionId: makeUuid(rng), + linkTypeId: params.linkTypeId, + // sourceIndex and targetIndex are drawn from [0, nodeCount) and nodeIds has + // length nodeCount. + leftEntityId: nodeIds[sourceIndex]!, + rightEntityId: nodeIds[targetIndex]!, + label: "related to", + }), + ); + } + + const entities = [...nodes, ...links].map((plain) => new HashEntity(plain)); + + return { entities, nodeEntityIds: nodeIds }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/build-type-maps.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/build-type-maps.ts new file mode 100644 index 00000000000..afe957f3672 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/build-type-maps.ts @@ -0,0 +1,255 @@ +/** + * Build the synthetic ontology the visualizer needs to label, icon, and card every fixture entity: + * one entity type per "kind" plus a single link type, the matching property types and data type, and + * the nested {@link ClosedMultiEntityTypesRootMap} keyed by sorted type URL that + * {@link getClosedMultiEntityTypeFromMap} traverses. + * + * Each kind is single-type, so its root-map entry is `{ [typeId]: { schema, inner: undefined } }`. + */ +import type { + BaseUrl, + ClosedDataType, + ClosedEntityTypeMetadata, + ClosedMultiEntityType, + EntityTypeDisplayMetadata, + PartialEntityType, + PropertyType, + VersionedUrl, +} from "@blockprotocol/type-system"; +import type { + ClosedDataTypeDefinition, + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; + +const WEB_SHORTNAME = "dev"; + +/** + * Display data for one synthetic entity kind. `name` is the kind's title; `icon` is its emoji, + * surfaced as the dot's type icon and the hover card's icon. + */ +export interface FixtureEntityKind { + readonly typeId: VersionedUrl; + readonly name: string; + readonly icon: string; + readonly labelPropertyBaseUrl: BaseUrl; + readonly labelPropertyTypeId: VersionedUrl; + readonly secondaryPropertyBaseUrl: BaseUrl; + readonly secondaryPropertyTypeId: VersionedUrl; +} + +/** + * The full type fixture: the per-kind display data the entity builder reads to populate properties, + * plus the link type id and the two map structures the visualizer consumes. + */ +export interface TypeFixture { + readonly kinds: readonly FixtureEntityKind[]; + readonly linkTypeId: VersionedUrl; + readonly closedMultiEntityTypesRootMap: ClosedMultiEntityTypesRootMap; + readonly definitions: ClosedMultiEntityTypesDefinitions; +} + +const TEXT_DATA_TYPE_ID = + "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1" as VersionedUrl; + +const LINK_LABEL_BASE_URL = + "https://hash.ai/@dev/types/property-type/relationship-label/" as BaseUrl; + +const LINK_LABEL_PROPERTY_TYPE_ID = + "https://hash.ai/@dev/types/property-type/relationship-label/v/1" as VersionedUrl; + +const KIND_TITLES: readonly { + readonly title: string; + readonly icon: string; +}[] = [ + { title: "Person", icon: "\u{1F464}" }, + { title: "Company", icon: "\u{1F3E2}" }, + { title: "Project", icon: "\u{1F4C1}" }, + { title: "Document", icon: "\u{1F4C4}" }, + { title: "Event", icon: "\u{1F4C5}" }, + { title: "Location", icon: "\u{1F4CD}" }, + { title: "Product", icon: "\u{1F4E6}" }, + { title: "Team", icon: "\u{1F465}" }, +]; + +function slug(title: string): string { + return title.toLowerCase().replace(/[^a-z0-9]+/g, "-"); +} + +function entityTypeId(title: string): VersionedUrl { + return `https://hash.ai/@${WEB_SHORTNAME}/types/entity-type/${slug(title)}/v/1` as VersionedUrl; +} + +function propertyBaseUrl(typeTitle: string, propertyName: string): BaseUrl { + return `https://hash.ai/@${WEB_SHORTNAME}/types/property-type/${slug(typeTitle)}-${slug(propertyName)}/` as BaseUrl; +} + +function propertyTypeId(typeTitle: string, propertyName: string): VersionedUrl { + return `${propertyBaseUrl(typeTitle, propertyName)}v/1` as VersionedUrl; +} + +/** Builds a Block Protocol property type that constrains values to the shared text data type. */ +function makePropertyType($id: VersionedUrl, title: string): PropertyType { + return { + $schema: + "https://blockprotocol.org/types/modules/graph/0.3/schema/property-type", + kind: "propertyType", + $id, + title, + description: `Synthetic ${title} property for the dev harness.`, + oneOf: [{ $ref: TEXT_DATA_TYPE_ID }], + }; +} + +/** A minimal closed text data type (one string value constraint). */ +function makeTextDataType(): ClosedDataType { + return { + $id: TEXT_DATA_TYPE_ID, + title: "Text", + description: "An ordered sequence of characters.", + allOf: [{ type: "string" }], + abstract: false, + }; +} + +/** Build one closed-multi-entity-type wrapping a single closed entity type's display metadata. */ +function makeClosedMultiEntityType(params: { + readonly typeId: VersionedUrl; + readonly title: string; + readonly icon: string; + readonly labelPropertyBaseUrl: BaseUrl; + readonly properties: ClosedMultiEntityType["properties"]; + readonly isLink: boolean; +}): ClosedMultiEntityType { + const display: EntityTypeDisplayMetadata = { + $id: params.typeId, + depth: 0, + icon: params.icon, + labelProperty: params.labelPropertyBaseUrl, + }; + + const metadata: ClosedEntityTypeMetadata = { + $id: params.typeId, + title: params.title, + description: `Synthetic ${params.title} type for the dev harness.`, + allOf: [display], + ...(params.isLink ? { inverse: { title: `Inverse ${params.title}` } } : {}), + }; + + return { + properties: params.properties, + allOf: [metadata], + }; +} + +/** + * Construct the type fixture for `kindCount` entity kinds (clamped to the built-in title list) plus + * one shared link type. Returns the kinds (for the entity builder) and both visualizer map + * structures. + */ +export function buildTypeMaps(kindCount: number): TypeFixture { + const clamped = Math.max(1, Math.min(kindCount, KIND_TITLES.length)); + + const dataTypes: Record = { + [TEXT_DATA_TYPE_ID]: { schema: makeTextDataType(), parents: [] }, + }; + const propertyTypes: Record = {}; + const entityTypes: Record = {}; + const closedMultiEntityTypesRootMap: ClosedMultiEntityTypesRootMap = {}; + + const kinds: FixtureEntityKind[] = []; + + for (let index = 0; index < clamped; index++) { + // index < clamped <= KIND_TITLES.length. + const { title, icon } = KIND_TITLES[index]!; + const typeId = entityTypeId(title); + + const labelPropertyBaseUrl = propertyBaseUrl(title, "name"); + const labelPropertyTypeId = propertyTypeId(title, "name"); + const secondaryPropertyBaseUrl = propertyBaseUrl(title, "description"); + const secondaryPropertyTypeId = propertyTypeId(title, "description"); + + propertyTypes[labelPropertyTypeId] = makePropertyType( + labelPropertyTypeId, + `${title} Name`, + ); + propertyTypes[secondaryPropertyTypeId] = makePropertyType( + secondaryPropertyTypeId, + `${title} Description`, + ); + + const properties: ClosedMultiEntityType["properties"] = { + [labelPropertyBaseUrl]: { $ref: labelPropertyTypeId }, + [secondaryPropertyBaseUrl]: { $ref: secondaryPropertyTypeId }, + }; + + entityTypes[typeId] = { + $id: typeId, + title, + description: `Synthetic ${title} type for the dev harness.`, + labelProperty: labelPropertyBaseUrl, + icon, + }; + + closedMultiEntityTypesRootMap[typeId] = { + schema: makeClosedMultiEntityType({ + typeId, + title, + icon, + labelPropertyBaseUrl, + properties, + isLink: false, + }), + }; + + kinds.push({ + typeId, + name: title, + icon, + labelPropertyBaseUrl, + labelPropertyTypeId, + secondaryPropertyBaseUrl, + secondaryPropertyTypeId, + }); + } + + const linkTitle = "Related To"; + const linkTypeId = entityTypeId(linkTitle); + const linkIcon = "\u{1F517}"; + + propertyTypes[LINK_LABEL_PROPERTY_TYPE_ID] = makePropertyType( + LINK_LABEL_PROPERTY_TYPE_ID, + "Relationship Label", + ); + + const linkProperties: ClosedMultiEntityType["properties"] = { + [LINK_LABEL_BASE_URL]: { $ref: LINK_LABEL_PROPERTY_TYPE_ID }, + }; + + entityTypes[linkTypeId] = { + $id: linkTypeId, + title: linkTitle, + description: "Synthetic link type for the dev harness.", + labelProperty: LINK_LABEL_BASE_URL, + icon: linkIcon, + inverse: { title: "Related From" }, + }; + + closedMultiEntityTypesRootMap[linkTypeId] = { + schema: makeClosedMultiEntityType({ + typeId: linkTypeId, + title: linkTitle, + icon: linkIcon, + labelPropertyBaseUrl: LINK_LABEL_BASE_URL, + properties: linkProperties, + isLink: true, + }), + }; + + return { + kinds, + linkTypeId, + closedMultiEntityTypesRootMap, + definitions: { dataTypes, propertyTypes, entityTypes }, + }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/config-panel.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/config-panel.tsx new file mode 100644 index 00000000000..a5de57ccd03 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/config-panel.tsx @@ -0,0 +1,631 @@ +/** + * Dev-harness panel exposing every {@link VizConfig} value as an adjustable + * knob, so layout/tuning changes can be tried live without editing code. + * + * The knob list is derived from {@link defaultVizConfig} itself (every numeric + * field gets a slider + exact-entry input, booleans get a switch), so new + * config fields show up here automatically: named sections below only control + * grouping/order, and any field they miss lands in a trailing "Other" section. + * + * Edits are validated with {@link validateConfig} before they are applied; + * an invalid draft stays local (worker keeps the last valid config) with the + * validation error shown until a knob change makes it valid again. Applied + * configs reach the live worker (UPDATE_CONFIG), which re-lays out in place, + * keeping the ingested graph and the camera. "Copy JSON" exports the full + * applied config for pasting into code or a bug report. + */ +import { Box, Slider, Stack, Switch, Typography } from "@mui/material"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; + +import { TextField } from "@hashintel/design-system"; + +import { Button } from "../../../../shared/ui"; +import { defaultVizConfig, validateConfig } from "../config"; + +import type { VizConfig } from "../config"; + +/** `["fieldName"]` for top-level fields, `["groupName", "fieldName"]` for grouped ones. */ +type FieldPath = readonly [string] | readonly [string, string]; + +interface SectionSpec { + readonly title: string; + /** Top-level flat field names, in display order. */ + readonly fields?: readonly string[]; + /** A nested config-group key whose own fields are all displayed. */ + readonly group?: string; +} + +/** + * Display grouping for the flat (ungrouped) VizConfig fields plus the nested + * config groups. Purely presentational; completeness is guaranteed by the + * "Other" fallback computed below. + */ +const SECTIONS: readonly SectionSpec[] = [ + { + title: "Mode tiers", + fields: [ + "flatLayoutMaxNodes", + "flatLayoutExitNodes", + "communityColorMaxNodes", + "communityColorExitNodes", + ], + }, + { + title: "Cluster grouping", + fields: [ + "minStandaloneTypeSet", + "mergeJaccardMin", + "mergeSubsetJaccardMin", + "maxChildrenPerParent", + ], + }, + { + title: "Subdivision & reveal", + fields: [ + "subclusterAboveCount", + "entityRevealMax", + "forceMaxNodes", + "communityWorkerNodeCap", + "communityMinSize", + "communityMaxSize", + ], + }, + { + title: "LOD open/close", + fields: [ + "openChildrenFraction", + "closeChildrenFraction", + "openEntitiesFraction", + "closeEntitiesFraction", + ], + }, + { + title: "Embedding", + fields: [ + "embeddingProjectionDims", + "embeddingMaxK", + "embeddingTargetLeafFillRatio", + "embeddingClientNodeCap", + "embeddingMinConcentration", + ], + }, + { + title: "Ports", + fields: [ + "minPortSpacingPx", + "maxPortsPerCluster", + "portPaddingWorld", + "portTension", + ], + }, + { + title: "Render budgets", + fields: [ + "maxRenderedClusters", + "maxRenderedEntities", + "maxRenderedEdges", + "maxParallelEdgeTypes", + ], + }, + { + title: "Edge geometry", + fields: ["parallelEdgeSpacingPx", "parallelEdgeCurvature", "curveSegments"], + }, + { title: "Majorization (community force)", group: "majorization" }, + { title: "Flat force (WebCola)", group: "flatForce" }, + { title: "Top-level polish", group: "topLevelPolish" }, + { title: "Untangle", group: "untangle" }, + { title: "Cluster force (WebCola)", group: "clusterForce" }, + { title: "Entity force (d3)", group: "entityForce" }, + { title: "Cluster sizing", group: "clusterSizing" }, + { title: "Entity style", group: "entityStyle" }, + { title: "Stability", group: "stability" }, + { title: "Ingest", group: "ingest" }, + { title: "Diagnostics", fields: ["debug"] }, +]; + +/** + * Any defaultVizConfig field the sections above don't mention: scalars join a + * trailing "Other" section and unlisted groups get their own section, so a + * newly added config field always has a knob without touching this file. + */ +const FALLBACK_SECTIONS: readonly SectionSpec[] = (() => { + const listedFlat = new Set( + SECTIONS.flatMap((section) => section.fields ?? []), + ); + const listedGroups = new Set( + SECTIONS.flatMap((section) => (section.group ? [section.group] : [])), + ); + const orphanFlat: string[] = []; + const orphanGroups: SectionSpec[] = []; + for (const [key, value] of Object.entries(defaultVizConfig)) { + if (value !== null && typeof value === "object") { + if (!listedGroups.has(key)) { + orphanGroups.push({ title: key, group: key }); + } + } else if (!listedFlat.has(key)) { + orphanFlat.push(key); + } + } + return [ + ...orphanGroups, + ...(orphanFlat.length > 0 ? [{ title: "Other", fields: orphanFlat }] : []), + ]; +})(); + +const ALL_SECTIONS: readonly SectionSpec[] = [ + ...SECTIONS, + ...FALLBACK_SECTIONS, +]; + +const asRecord = (config: VizConfig): Record => + config as unknown as Record; + +function getAtPath(config: VizConfig, path: FieldPath): unknown { + const record = asRecord(config); + if (path.length === 1) { + return record[path[0]]; + } + return (record[path[0]] as Record | undefined)?.[path[1]]; +} + +function setAtPath( + config: VizConfig, + path: FieldPath, + value: unknown, +): VizConfig { + if (path.length === 1) { + return { ...config, [path[0]]: value }; + } + const group = asRecord(config)[path[0]] as Record; + return { ...config, [path[0]]: { ...group, [path[1]]: value } }; +} + +/** Round to 1/2/5 × a power of ten, the usual "nice" slider increments. */ +function niceStep(raw: number): number { + const power = 10 ** Math.floor(Math.log10(raw)); + const mantissa = raw / power; + return (mantissa >= 5 ? 5 : mantissa >= 2 ? 2 : 1) * power; +} + +interface SliderRange { + readonly min: number; + readonly max: number; + readonly step: number; +} + +/** + * Infer a usable slider range from a field's default value: fractions sweep + * toward [0, 1], larger magnitudes sweep [0, 4x default] (mirrored when + * negative), byte alphas get [0, 255]. Zero defaults get no slider (no + * scale to infer); the exact-entry input still accepts any value, including + * beyond the slider's inferred bounds. + */ +function sliderRangeFor( + fieldName: string, + defaultValue: number, +): SliderRange | undefined { + if (defaultValue === 0) { + return undefined; + } + const magnitude = Math.abs(defaultValue); + if ( + fieldName.toLowerCase().endsWith("alpha") && + magnitude > 1 && + Number.isInteger(defaultValue) + ) { + return { min: 0, max: 255, step: 1 }; + } + const max = + magnitude <= 0.25 ? magnitude * 4 : magnitude <= 1 ? 1 : magnitude * 4; + const step = + Number.isInteger(defaultValue) && magnitude >= 1 + ? Math.max(1, niceStep(max / 200)) + : niceStep(max / 200); + return defaultValue < 0 ? { min: -max, max: 0, step } : { min: 0, max, step }; +} + +interface NumberKnobProps { + readonly label: string; + readonly value: number; + readonly defaultValue: number; + readonly onCommit: (value: number) => void; +} + +/** + * One numeric knob: a slider (when a range is inferable from the default) + * plus an exact-entry input. Slider drags preview locally and commit on + * release, so the live worker is not re-tuned (and re-laid-out) on every + * drag tick; the input commits on blur or Enter. A reset button appears when + * the value differs from the default. + */ +const NumberKnob = memo( + ({ label, value, defaultValue, onCommit }: NumberKnobProps) => { + const range = sliderRangeFor(label, defaultValue); + /** In-flight slider value while dragging; undefined when idle. */ + const [dragValue, setDragValue] = useState(); + const [text, setText] = useState(String(value)); + useEffect(() => { + setText(String(value)); + }, [value]); + + const commitText = () => { + const parsed = Number(text); + if (Number.isFinite(parsed) && parsed !== value) { + onCommit(parsed); + } else { + setText(String(value)); + } + }; + + const shown = dragValue ?? value; + const isModified = value !== defaultValue; + + return ( + + + + {label} + + {isModified ? ( + + ) : null} + setText(event.target.value)} + onBlur={commitText} + onKeyDown={(event) => { + if (event.key === "Enter") { + commitText(); + (event.target as HTMLInputElement).blur(); + } + }} + inputProps={{ + inputMode: "decimal", + style: { + fontSize: 11, + padding: "2px 6px", + width: 64, + textAlign: "right", + }, + }} + /> + + {range ? ( + { + setDragValue(Array.isArray(next) ? next[0]! : next); + }} + onChangeCommitted={(_event, next) => { + setDragValue(undefined); + const committed = Array.isArray(next) ? next[0]! : next; + if (committed !== value) { + onCommit(committed); + } + }} + /> + ) : null} + + ); + }, +); + +interface FieldRowProps { + readonly path: FieldPath; + readonly config: VizConfig; + readonly onFieldChange: (path: FieldPath, value: unknown) => void; +} + +const FieldRow = memo(({ path, config, onFieldChange }: FieldRowProps) => { + const fieldName = path[path.length - 1]!; + const value = getAtPath(config, path); + const defaultValue = getAtPath(defaultVizConfig, path); + + if (typeof defaultValue === "boolean" || typeof value === "boolean") { + const checked = Boolean(value); + return ( + + + {fieldName} + + onFieldChange(path, event.target.checked)} + /> + + ); + } + + if (typeof defaultValue !== "number") { + return null; + } + + return ( + onFieldChange(path, next)} + /> + ); +}); + +/** Field paths a section displays, resolved against defaultVizConfig's shape. */ +function sectionPaths(section: SectionSpec): FieldPath[] { + if (section.group) { + const group = asRecord(defaultVizConfig)[section.group]; + if (group === null || typeof group !== "object") { + return []; + } + return Object.keys(group).map((field) => [section.group!, field] as const); + } + return (section.fields ?? []).map((field) => [field] as const); +} + +function countModified(config: VizConfig, paths: readonly FieldPath[]): number { + let modified = 0; + for (const path of paths) { + if (getAtPath(config, path) !== getAtPath(defaultVizConfig, path)) { + modified += 1; + } + } + return modified; +} + +interface VizConfigPanelProps { + /** The currently applied (last valid) config. */ + readonly value: VizConfig; + /** Called with a validated config whenever a knob commit produces one. */ + readonly onApply: (next: VizConfig) => void; +} + +export const VizConfigPanel = memo( + ({ value, onApply }: VizConfigPanelProps) => { + const [isCollapsed, setIsCollapsed] = useState(true); + const [expandedSections, setExpandedSections] = useState< + ReadonlySet + >(new Set()); + const [filter, setFilter] = useState(""); + /** Draft that failed validation; kept local so the worker stays on `value`. */ + const [invalidDraft, setInvalidDraft] = useState(); + const [validationError, setValidationError] = useState(); + const [copyFeedback, setCopyFeedback] = useState(); + + const config = invalidDraft ?? value; + + const handleFieldChange = useCallback( + (path: FieldPath, fieldValue: unknown) => { + // Build on the visible config so consecutive edits (e.g. fixing the + // second half of a hysteresis pair) accumulate rather than reset. + const next = setAtPath(invalidDraft ?? value, path, fieldValue); + try { + validateConfig(next); + } catch (error) { + setInvalidDraft(next); + setValidationError( + error instanceof Error ? error.message : String(error), + ); + return; + } + setInvalidDraft(undefined); + setValidationError(undefined); + onApply(next); + }, + [invalidDraft, value, onApply], + ); + + const handleCopyJson = useCallback(() => { + const json = JSON.stringify(config, null, 2); + // eslint-disable-next-line no-console -- dev harness affordance (console-copyable fallback) + console.log("VizConfig JSON:", json); + void navigator.clipboard + .writeText(json) + .then(() => setCopyFeedback("Copied to clipboard")) + .catch(() => setCopyFeedback("Clipboard blocked; JSON in console")); + window.setTimeout(() => setCopyFeedback(undefined), 2000); + }, [config]); + + const handleReset = useCallback(() => { + setInvalidDraft(undefined); + setValidationError(undefined); + onApply(defaultVizConfig); + }, [onApply]); + + const toggleSection = useCallback((title: string) => { + setExpandedSections((current) => { + const next = new Set(current); + if (next.has(title)) { + next.delete(title); + } else { + next.add(title); + } + return next; + }); + }, []); + + const totalModified = useMemo( + () => + countModified( + config, + ALL_SECTIONS.flatMap((section) => sectionPaths(section)), + ), + [config], + ); + + const filterQuery = filter.trim().toLowerCase(); + + return ( + ({ + position: "absolute", + top: 56, + right: 16, + zIndex: 10, + width: isCollapsed ? "auto" : 320, + maxHeight: "calc(100% - 72px)", + overflowY: "auto", + p: isCollapsed ? 1 : 2, + borderRadius: 2, + bgcolor: palette.common.white, + border: `1px solid ${palette.gray[20]}`, + boxShadow: boxShadows.md, + })} + > + + {isCollapsed ? null : ( + + + Layout config + + + {totalModified === 0 + ? "all defaults" + : `${totalModified} value${totalModified === 1 ? "" : "s"} modified`} + + + )} + + + + {isCollapsed ? null : ( + + + + + + {copyFeedback ? ( + + {copyFeedback} + + ) : null} + {validationError ? ( + + {validationError} — fix to apply; the worker keeps the last + valid config. + + ) : null} + setFilter(event.target.value)} + inputProps={{ + style: { fontSize: 11.5, padding: "4px 8px" }, + }} + /> + + {ALL_SECTIONS.map((section) => { + const paths = sectionPaths(section); + const visiblePaths = filterQuery + ? paths.filter( + (path) => + path[path.length - 1]!.toLowerCase().includes( + filterQuery, + ) || section.title.toLowerCase().includes(filterQuery), + ) + : paths; + if (visiblePaths.length === 0) { + return null; + } + const isExpanded = + filterQuery.length > 0 || expandedSections.has(section.title); + const modifiedCount = countModified(config, paths); + return ( + + toggleSection(section.title)} + sx={{ cursor: "pointer", userSelect: "none", py: 0.25 }} + > + + {section.title} + {modifiedCount > 0 ? ( + + ({modifiedCount}) + + ) : null} + + + {isExpanded ? "−" : "+"} + + + {isExpanded ? ( + + {visiblePaths.map((path) => ( + + ))} + + ) : null} + + ); + })} + + )} + + ); + }, +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/controls-panel.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/controls-panel.tsx new file mode 100644 index 00000000000..5cc00d9348c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/controls-panel.tsx @@ -0,0 +1,297 @@ +/** + * Floating panel of fixture and streaming knobs for the dev harness: sliders for the + * fixture knobs, the streaming toggle and its chunk controls, and the Regenerate + * button. + */ +import { Box, Slider, Stack, Switch, Typography } from "@mui/material"; +import { memo, useState } from "react"; + +import { Button } from "../../../../shared/ui"; +import { TOGGLEABLE_LAYER_KINDS } from "../render/scene/layer-kinds"; + +import type { LayerKind } from "../render/scene/layer-kinds"; + +/** + * The full knob set the harness drives the generator with. Layout/worker + * tuning lives in the separate config panel + * ({@link "./config-panel"}), not here. + */ +export interface HarnessKnobs { + readonly entityCount: number; + readonly entityTypeCount: number; + readonly linkDensity: number; + readonly rootFraction: number; + readonly hubCount: number; + readonly stream: boolean; + readonly chunkSize: number; + readonly intervalMs: number; +} + +interface ControlsPanelProps { + readonly knobs: HarnessKnobs; + readonly onChange: (knobs: HarnessKnobs) => void; + readonly onRegenerate: () => void; + /** + * Optional callback: download the live layout as replayable JSON. Omitted + * until a worker handle exists. + */ + readonly onCaptureFixture?: () => void; + /** + * render-bench debug hook: capture deck stats + layer-push timings for a + * fixed window under a scripted zoom sweep (report JSON in the console). + */ + readonly onRunRenderBench?: () => void; + /** + * As above but without the scripted camera: benches the CURRENT viewport + * as framed by the user, isolating fill-rate cost at one zoom. + */ + readonly onRunRenderBenchPinned?: () => void; + /** Layer kinds currently hidden from every render pass (GPU-cost bisection). */ + readonly hiddenLayerKinds?: readonly LayerKind[]; + /** Toggle one layer kind's visibility. */ + readonly onToggleLayerKind?: (kind: LayerKind) => void; + /** Summary line of the last render bench (or its in-progress notice). */ + readonly renderBenchStatus?: string; + /** Entities handed to the visualizer so far, shown against the total while streaming. */ + readonly streamedCount: number; + readonly totalCount: number; + readonly seed: number; +} + +interface KnobSliderProps { + readonly label: string; + readonly value: number; + readonly min: number; + readonly max: number; + readonly step: number; + readonly disabled?: boolean; + readonly onChange: (value: number) => void; +} + +const KnobSlider = memo( + ({ label, value, min, max, step, disabled, onChange }: KnobSliderProps) => ( + + + + {label} + + {value} + + { + // MUI Slider onChange passes number for a single-thumb slider. + onChange(Array.isArray(next) ? next[0]! : next); + }} + /> + + ), +); + +export const ControlsPanel = memo( + ({ + knobs, + onChange, + onRegenerate, + onCaptureFixture, + onRunRenderBench, + onRunRenderBenchPinned, + hiddenLayerKinds, + onToggleLayerKind, + renderBenchStatus, + streamedCount, + totalCount, + seed, + }: ControlsPanelProps) => { + const [isCollapsed, setIsCollapsed] = useState(false); + const set = (patch: Partial) => { + onChange({ ...knobs, ...patch }); + }; + + return ( + ({ + position: "absolute", + top: 16, + left: 16, + zIndex: 10, + width: isCollapsed ? 232 : 280, + maxHeight: "calc(100% - 32px)", + overflowY: "auto", + p: isCollapsed ? 1.25 : 2, + borderRadius: 2, + bgcolor: palette.common.white, + border: `1px solid ${palette.gray[20]}`, + boxShadow: boxShadows.md, + })} + > + + + + Graph visualizer dev harness + + + Seed {seed} - {streamedCount} / {totalCount} visible + + + + + + {isCollapsed ? null : ( + + set({ entityCount })} + /> + set({ entityTypeCount })} + /> + set({ linkDensity })} + /> + set({ rootFraction })} + /> + set({ hubCount })} + /> + + + + Stream incrementally + + set({ stream: event.target.checked })} + /> + + + {knobs.stream ? ( + <> + set({ chunkSize })} + /> + set({ intervalMs })} + /> + + Streamed {streamedCount} / {totalCount} + + + ) : null} + + + {onCaptureFixture ? ( + + ) : null} + {onRunRenderBench ? ( + + ) : null} + {onRunRenderBenchPinned ? ( + + ) : null} + {onToggleLayerKind ? ( + + + Hide layers (GPU-cost bisection) + + + {TOGGLEABLE_LAYER_KINDS.map((kind) => { + const hidden = hiddenLayerKinds?.includes(kind) ?? false; + return ( + + ); + })} + + + ) : null} + {renderBenchStatus ? ( + + {renderBenchStatus} + + ) : null} + + )} + + ); + }, +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/generate-fixture.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/generate-fixture.test.ts new file mode 100644 index 00000000000..d2c0ff22e15 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/generate-fixture.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from "vitest"; + +import { + getClosedMultiEntityTypeFromMap, + getDisplayFieldsForClosedEntityType, +} from "@local/hash-graph-sdk/entity"; +import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; + +import { generateGraphFixture } from "./generate-fixture"; + +const params = { + entityCount: 40, + entityTypeCount: 4, + linkDensity: 1.5, + rootFraction: 0.6, + hubCount: 3, + seed: 12345, +} as const; + +test("the produced root map resolves every entity via getClosedMultiEntityTypeFromMap", () => { + const { entities, closedMultiEntityTypesRootMap } = + generateGraphFixture(params); + + for (const entity of entities) { + expect(() => + getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesRootMap, + entity.metadata.entityTypeIds, + ), + ).not.toThrow(); + } +}); + +test("a generated entity yields a non-empty label and an emoji icon", () => { + const { entities, closedMultiEntityTypesRootMap } = + generateGraphFixture(params); + + const node = entities.find((entity) => entity.linkData === undefined); + expect(node).toBeDefined(); + + const closedType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesRootMap, + node!.metadata.entityTypeIds, + ); + + const label = generateEntityLabel(closedType, node!); + expect(label.length).toBeGreaterThan(0); + // The label resolves via the type's labelProperty, not the entity-type-title fallback. + expect(label).not.toMatch(/^Entity/); + + const { icon, labelProperty } = + getDisplayFieldsForClosedEntityType(closedType); + expect(typeof icon).toBe("string"); + expect(icon?.length ?? 0).toBeGreaterThan(0); + expect(labelProperty).toBeDefined(); +}); + +test("regenerate with the same seed reproduces the same graph", () => { + const first = generateGraphFixture(params); + const second = generateGraphFixture(params); + + expect(first.entities.map((entity) => entity.entityId)).toEqual( + second.entities.map((entity) => entity.entityId), + ); + expect(first.rootEntityIds).toEqual(second.rootEntityIds); +}); + +test("hubs emerge: a few nodes carry far more incident links than the median", () => { + const { entities } = generateGraphFixture({ ...params, hubCount: 3 }); + + const degree = new Map(); + for (const entity of entities) { + const { linkData } = entity; + if (!linkData) { + continue; + } + degree.set( + linkData.leftEntityId, + (degree.get(linkData.leftEntityId) ?? 0) + 1, + ); + degree.set( + linkData.rightEntityId, + (degree.get(linkData.rightEntityId) ?? 0) + 1, + ); + } + + const degrees = [...degree.values()].sort((a, b) => b - a); + expect(degrees.length).toBeGreaterThan(0); + const maxDegree = degrees[0]!; + const median = degrees[Math.floor(degrees.length / 2)] ?? 0; + expect(maxDegree).toBeGreaterThan(median); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/generate-fixture.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/generate-fixture.ts new file mode 100644 index 00000000000..bc9e2ae8a5b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/generate-fixture.ts @@ -0,0 +1,70 @@ +/** + * Deterministic synthetic-graph generator for the dev harness: from a small set of knobs it builds a + * valid {@link HashEntity} set plus the ontology maps the visualizer needs, so a developer can + * iterate on the visualizer without real entities. + * + * Reproducible per seed (a seeded PRNG drives every choice), so Regenerate with the same seed yields + * the same graph. + */ +import { buildEntities } from "./build-entities"; +import { buildTypeMaps } from "./build-type-maps"; + +import type { EntityId } from "@blockprotocol/type-system"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; + +/** Knobs that shape the generated graph. All counts are clamped to sane minimums. */ +export interface GenerateFixtureParams { + /** Number of normal (non-link) entities. */ + readonly entityCount: number; + /** Number of distinct entity types, clamped to the built-in kind list. */ + readonly entityTypeCount: number; + /** Average links per node; total link entities is roughly `entityCount * linkDensity`. */ + readonly linkDensity: number; + /** Fraction (0-1) of nodes treated as query roots; the rest render as frontier nodes. */ + readonly rootFraction: number; + /** Count of high-degree hub nodes that many others link into. */ + readonly hubCount: number; + /** Seed for the PRNG; the same seed reproduces the same graph. */ + readonly seed: number; +} + +/** The complete set of props the visualizer consumes, ready to spread onto it. */ +export interface GraphFixture { + readonly entities: HashEntity[]; + readonly rootEntityIds: EntityId[]; + readonly closedMultiEntityTypesRootMap: ClosedMultiEntityTypesRootMap; + readonly definitions: ClosedMultiEntityTypesDefinitions; +} + +export function generateGraphFixture( + params: GenerateFixtureParams, +): GraphFixture { + const { kinds, linkTypeId, closedMultiEntityTypesRootMap, definitions } = + buildTypeMaps(params.entityTypeCount); + + const { entities, nodeEntityIds } = buildEntities({ + entityCount: params.entityCount, + kinds, + linkTypeId, + linkDensity: params.linkDensity, + hubCount: params.hubCount, + seed: params.seed, + }); + + // Roots are the first `rootFraction` of the node ids; ordering is stable per seed, so streaming + // can grow the root set alongside the entity stream. Hubs come first, so they are roots early. + const clampedFraction = Math.min(1, Math.max(0, params.rootFraction)); + const rootCount = Math.round(nodeEntityIds.length * clampedFraction); + const rootEntityIds = nodeEntityIds.slice(0, rootCount); + + return { + entities, + rootEntityIds, + closedMultiEntityTypesRootMap, + definitions, + }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/harness.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/harness.tsx new file mode 100644 index 00000000000..09b09faf65f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/dev-harness/harness.tsx @@ -0,0 +1,436 @@ +/** + * Dev harness for {@link EntityGraphVisualizer}: renders the visualizer against a synthetic + * fixture driven by UI knobs, so the visualizer can be iterated on without navigating to the + * entities page and creating real entities. + * + * Two feed modes: + * - all-at-once: every fixture entity is handed to the visualizer immediately. + * - streaming: entities are revealed in chunks over time (and `rootEntityIds` grows alongside), to + * reproduce the incremental/absorb path (warm re-settling, hub-labels during load). + */ +import { Box } from "@mui/material"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { LoadingSpinner } from "@hashintel/design-system"; + +import { defaultVizConfig } from "../config"; +import { useOwnedFrontierStore } from "../entity-graph/use-frontier-expansion"; +import { EntityGraphVisualizer } from "../entity-graph/visualizer"; +import { downloadLayoutFixture } from "../layout-fixture-capture"; +import { VizConfigPanel } from "./config-panel"; +import { ControlsPanel } from "./controls-panel"; +import { generateGraphFixture } from "./generate-fixture"; + +import type { VizConfig } from "../config"; +import type { EntityIndex } from "../ids"; +import type { EntityWorkerHandle } from "../render/entity-worker-connection"; +import type { LayerKind } from "../render/scene/layer-kinds"; +import type { Scene } from "../render/scene/scene"; +import type { HarnessKnobs } from "./controls-panel"; +import type { GraphFixture } from "./generate-fixture"; +import type { EntityId } from "@blockprotocol/type-system"; + +/** Render-bench capture length. Long enough for ~10 deck metric samples. */ +const RENDER_BENCH_DURATION_MS = 10_000; +/** Cadence of the scripted camera sweep during the bench. */ +const RENDER_BENCH_SWEEP_INTERVAL_MS = 90; +/** Per-step zoom amplitude of the sweep sinusoid. */ +const RENDER_BENCH_SWEEP_ZOOM_STEP = 0.12; +/** + * Wait for the fit-to-content transition (240 ms) before a sweep capture + * starts, so the fit animation itself is not measured. + */ +const RENDER_BENCH_FIT_SETTLE_MS = 400; + +const DEFAULT_KNOBS: HarnessKnobs = { + entityCount: 200, + entityTypeCount: 4, + linkDensity: 1.2, + rootFraction: 0.7, + hubCount: 4, + stream: false, + chunkSize: 10, + intervalMs: 150, +}; + +/** How much of the fixture is currently revealed to the visualizer. */ +interface FeedState { + /** Count of node entities revealed; roots grow with this count when streaming. */ + readonly revealedNodes: number; + /** Count of link entities revealed. */ + readonly revealedLinks: number; +} + +export const DevHarness = () => { + const [knobs, setKnobs] = useState(DEFAULT_KNOBS); + const [seed, setSeed] = useState(1); + + // The full worker config, driven by the config panel's knobs. Applied + // changes reach the live worker via UPDATE_CONFIG (it re-tunes and + // re-lays out in place), so the visualizer keeps its camera and ingested + // graph -- no remount. + const [layoutConfig, setLayoutConfig] = useState(defaultVizConfig); + + // The fixture is regenerated only when a fixture-shaping knob (or seed) changes; the streaming + // knobs (chunk size, interval) drive the feed, not the fixture. + const fixture = useMemo( + () => + generateGraphFixture({ + entityCount: knobs.entityCount, + entityTypeCount: knobs.entityTypeCount, + linkDensity: knobs.linkDensity, + rootFraction: knobs.rootFraction, + hubCount: knobs.hubCount, + seed, + }), + [ + knobs.entityCount, + knobs.entityTypeCount, + knobs.linkDensity, + knobs.rootFraction, + knobs.hubCount, + seed, + ], + ); + + // Separate nodes from links so streaming can reveal nodes first, then their links, and grow the + // root set against the revealed nodes (links are never roots). + const { nodes, links, nodeIdOrder } = useMemo(() => { + const nodeList = fixture.entities.filter( + (entity) => entity.linkData === undefined, + ); + const linkList = fixture.entities.filter( + (entity) => entity.linkData !== undefined, + ); + return { + nodes: nodeList, + links: linkList, + nodeIdOrder: nodeList.map((entity) => entity.metadata.recordId.entityId), + }; + }, [fixture]); + + const [feed, setFeed] = useState({ + revealedNodes: 0, + revealedLinks: 0, + }); + + // Reset the feed whenever the fixture or feed mode changes: all-at-once reveals everything, + // streaming starts from zero and grows via the interval below. + useEffect(() => { + if (knobs.stream) { + setFeed({ revealedNodes: 0, revealedLinks: 0 }); + } else { + setFeed({ revealedNodes: nodes.length, revealedLinks: links.length }); + } + }, [knobs.stream, nodes.length, links.length]); + + // Keep the latest chunk size in a ref so the interval reads it without re-subscribing each change. + const chunkSizeRef = useRef(knobs.chunkSize); + chunkSizeRef.current = knobs.chunkSize; + + // Streaming reveal: every `intervalMs`, reveal another chunk of nodes (then links once nodes are + // exhausted), until the whole fixture is shown. Re-armed on fixture / interval / mode change. + useEffect(() => { + if (!knobs.stream) { + return; + } + const handle = setInterval(() => { + setFeed((previous) => { + const chunk = chunkSizeRef.current; + if (previous.revealedNodes < nodes.length) { + return { + ...previous, + revealedNodes: Math.min( + nodes.length, + previous.revealedNodes + chunk, + ), + }; + } + if (previous.revealedLinks < links.length) { + return { + ...previous, + revealedLinks: Math.min( + links.length, + previous.revealedLinks + chunk, + ), + }; + } + return previous; + }); + }, knobs.intervalMs); + return () => { + clearInterval(handle); + }; + }, [knobs.stream, knobs.intervalMs, nodes.length, links.length]); + + // The entities + roots actually handed to the visualizer this render, sliced to the revealed + // counts. The visualizer's ingest is additive (it only sends the delta), so growing these arrays + // reproduces the incremental absorb path. + const { visibleEntities, visibleRootIds } = useMemo(() => { + const revealedNodes = nodes.slice(0, feed.revealedNodes); + const revealedLinks = links.slice(0, feed.revealedLinks); + const revealedNodeIds = new Set(nodeIdOrder.slice(0, feed.revealedNodes)); + const roots = fixture.rootEntityIds.filter((id) => revealedNodeIds.has(id)); + const safeRoots = + roots.length > 0 + ? roots + : revealedNodes + .slice(0, 1) + .map((entity) => entity.metadata.recordId.entityId); + return { + visibleEntities: [...revealedNodes, ...revealedLinks], + visibleRootIds: safeRoots, + }; + }, [nodes, links, nodeIdOrder, feed, fixture.rootEntityIds]); + + const handleRegenerate = useCallback(() => { + setSeed((previous) => previous + 1); + }, []); + + // Debug hook: exposes the live worker handle so Capture can download a replayable + // layout fixture (positions, radii, edges, Louvain communities). + const workerHandleRef = useRef(undefined); + const handleWorkerHandle = useCallback( + (handle: EntityWorkerHandle | undefined) => { + workerHandleRef.current = handle; + }, + [], + ); + const handleCaptureFixture = useCallback(() => { + void workerHandleRef.current?.captureLayoutFixture().then((captured) => { + if (!captured) { + // eslint-disable-next-line no-console -- dev harness affordance + console.warn( + "capture-layout-fixture: no flat-tier layout live (hierarchical mode or empty graph)", + ); + return; + } + downloadLayoutFixture(captured); + }); + }, []); + + // render-bench hook: the visualizer surfaces its Scene here so the bench button can + // capture deck stats + layer-push timings under a scripted zoom sweep. The report JSON + // lands in the console; the summary line shows fps | frame p95/hitches | attrs ms/s | + // rebuild p95. Key fields: frames.p95Ms/hitchCount (rAF cadence -- felt smoothness), + // rebuild.p95Ms (main-thread layer rebuild), deck.updateAttributesTime (Deck's + // deferred attribute regen), deck.fps/cpuTimePerFrame. Compare runs only at matching + // zoom envelopes; settled vs under-load sweeps measure different things. + const sceneRef = useRef | null>( + null, + ); + // GPU-cost bisection state: kinds currently hidden from every render pass. + // Kept in a ref too so a scene remount re-applies the selection. + const [hiddenLayerKinds, setHiddenLayerKinds] = useState< + readonly LayerKind[] + >([]); + const hiddenLayerKindsRef = useRef(hiddenLayerKinds); + const handleSceneReady = useCallback( + (scene: Scene | null) => { + sceneRef.current = scene; + scene?.setHiddenLayerKinds(hiddenLayerKindsRef.current); + }, + [], + ); + const handleToggleLayerKind = useCallback((kind: LayerKind) => { + setHiddenLayerKinds((current) => { + const next = current.includes(kind) + ? current.filter((hidden) => hidden !== kind) + : [...current, kind]; + hiddenLayerKindsRef.current = next; + sceneRef.current?.setHiddenLayerKinds(next); + return next; + }); + }, []); + const [renderBenchStatus, setRenderBenchStatus] = useState(); + const benchTimersRef = useRef<{ + fit?: number; + sweep?: number; + stop?: number; + }>({}); + useEffect( + () => () => { + window.clearTimeout(benchTimersRef.current.fit); + window.clearInterval(benchTimersRef.current.sweep); + window.clearTimeout(benchTimersRef.current.stop); + }, + [], + ); + const runRenderBench = useCallback((sweep: boolean) => { + const scene = sceneRef.current; + if (!scene || scene.renderCapturing) { + return; + } + // Reports are compared across bisection runs, so each one is labelled + // with the layer kinds hidden while it was captured. + const hiddenLabel = + hiddenLayerKindsRef.current.length === 0 + ? "all layers" + : `hidden: ${[...hiddenLayerKindsRef.current].sort().join("+")}`; + setRenderBenchStatus( + `capturing ${RENDER_BENCH_DURATION_MS / 1000}s (${ + sweep ? "zoom sweep" : "pinned camera" + }, ${hiddenLabel})...`, + ); + const beginCapture = () => { + const liveScene = sceneRef.current; + if (!liveScene || liveScene.renderCapturing) { + return; + } + liveScene.startRenderCapture(); + const benchStart = performance.now(); + if (sweep) { + benchTimersRef.current.sweep = window.setInterval(() => { + // Slow zoom oscillation: crosses LOD/label buckets both ways, exercising + // layer rebuilds the way interactive use does, but reproducibly. + const phase = (performance.now() - benchStart) / 700; + sceneRef.current?.zoomBy( + Math.sin(phase) * RENDER_BENCH_SWEEP_ZOOM_STEP, + ); + }, RENDER_BENCH_SWEEP_INTERVAL_MS); + } + benchTimersRef.current.stop = window.setTimeout(() => { + window.clearInterval(benchTimersRef.current.sweep); + const stopScene = sceneRef.current; + if (!stopScene) { + setRenderBenchStatus("scene remounted mid-capture; rerun"); + return; + } + const report = stopScene.stopRenderCapture(); + if (sweep) { + // A pinned capture keeps the camera the user framed; only the sweep + // needs to restore the view it disturbed. + stopScene.fitToContent(); + } + // eslint-disable-next-line no-console -- dev harness affordance (console-copyable) + console.log( + `render-bench report (${ + sweep ? "sweep" : "pinned" + }, ${hiddenLabel}):`, + JSON.stringify(report, null, 2), + ); + const gpuSummary = report.gpu.available + ? `gpu p95 ${report.gpu.p95Ms.toFixed( + 1, + )}ms max ${report.gpu.maxMs.toFixed(0)}ms (${report.gpu.samples}x)` + : "gpu n/a"; + setRenderBenchStatus( + `${hiddenLabel} | fps ${report.deck.fps.toFixed( + 0, + )} | frame p95 ${report.frames.p95Ms.toFixed(1)}ms, ` + + `${report.frames.hitchCount} hitch${ + report.frames.hitchCount === 1 ? "" : "es" + } (max ${report.frames.maxMs.toFixed(0)}ms) | ` + + `${gpuSummary} | ` + + `attrs ${report.deck.updateAttributesTime.toFixed(1)}ms/s | ` + + `rebuild p95 ${report.rebuild.p95Ms.toFixed(2)}ms (${ + report.rebuild.count + }x) | ` + + `zoom ${report.camera.initialZoom.toFixed( + 1, + )} [${report.camera.minZoom.toFixed( + 1, + )}..${report.camera.maxZoom.toFixed(1)}]`, + ); + }, RENDER_BENCH_DURATION_MS); + }; + if (sweep) { + // Every sweep starts from the same data-derived anchor (after its + // 240 ms transition settles): the largest bubble, framed to fill the + // view. That keeps the zoom envelope deterministic -- bisection runs + // (hide one kind, re-run) compare like with like -- AND parks the + // sweep on the fill-heaviest region, so the zoom-in crest dives into + // the big bubble instead of whatever happened to sit at the viewport + // centre. (Fit-to-content alone was deterministic but zoomed into + // the graph's midpoint, missing an off-centre worst case.) A pinned + // capture keeps the camera exactly as the user framed it. + scene.frameLargestBubble(); + benchTimersRef.current.fit = window.setTimeout( + beginCapture, + RENDER_BENCH_FIT_SETTLE_MS, + ); + } else { + beginCapture(); + } + }, []); + const handleRunRenderBench = useCallback( + () => runRenderBench(true), + [runRenderBench], + ); + // Pinned variant: no scripted camera. Zoom/frame the worst case first, then + // capture -- isolates fill-rate cost at that exact viewport from the + // rebuild/LOD churn a sweep adds (see PERFORMANCE.md section 7.1). + const handleRunRenderBenchPinned = useCallback( + () => runRenderBench(false), + [runRenderBench], + ); + + // Frontier expand fetches against the live API; with synthetic ids that fetch no-ops (the dev + // entities do not exist server-side), so clicking a frontier node simply logs and does nothing + // further -- that is expected here. + const handleEntityClick = useCallback((entityId: EntityId) => { + // eslint-disable-next-line no-console -- dev harness affordance + console.log("dev-harness onEntityClick", entityId); + }, []); + + const handleOpenLinkTable = useCallback( + (linkEntityIds: readonly EntityId[]) => { + // eslint-disable-next-line no-console -- dev harness affordance + console.log("dev-harness onOpenLinkTable", linkEntityIds); + }, + [], + ); + + // Remount the visualizer (fresh worker, cleared graph) whenever the fixture identity or feed + // mode changes. Ingest is additive, so without a remount a regenerated fixture would pile its + // entities onto the previous graph instead of replacing it -- so the key must include every + // fixture-shaping knob, not just seed/stream (changing any of these sliders regenerates the + // fixture too). Config changes are NOT in the key: they apply to the live worker. + const visualizerKey = [ + seed, + knobs.entityCount, + knobs.entityTypeCount, + knobs.linkDensity, + knobs.rootFraction, + knobs.hubCount, + knobs.stream, + ].join(":"); + + // Synthetic ids never resolve server-side, so expansions no-op; the store + // just satisfies the visualizer's ownership contract, reset per fixture. + const frontierStore = useOwnedFrontierStore(visualizerKey); + + return ( + + + + } + onEntityClick={handleEntityClick} + onOpenLinkTable={handleOpenLinkTable} + onWorkerHandle={handleWorkerHandle} + onSceneReady={handleSceneReady} + /> + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/dim-color.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/dim-color.ts new file mode 100644 index 00000000000..fae171da154 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/dim-color.ts @@ -0,0 +1,20 @@ +/** + * The single focus-dim formula, shared by the worker (entity dots + edge beziers, dimmed in + * the SABs / bezier buffers) and the main thread (cluster bubbles, dimmed in the layer) so + * everything recedes by exactly the same amount when a highlight is active. Desaturates toward + * a neutral grey and drops alpha, so a non-highlighted element fades into the background. + */ +import type { Color } from "./frames"; + +const DIM_GRAY = 150; +const DIM_MIX = 0.8; +const DIM_ALPHA = 0.2; + +export function dimColor(color: Color): Color { + return [ + Math.round(color[0] + (DIM_GRAY - color[0]) * DIM_MIX), + Math.round(color[1] + (DIM_GRAY - color[1]) * DIM_MIX), + Math.round(color[2] + (DIM_GRAY - color[2]) * DIM_MIX), + Math.round(color[3] * DIM_ALPHA), + ]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/frontier-cluster-card.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/frontier-cluster-card.tsx new file mode 100644 index 00000000000..d71232f2779 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/frontier-cluster-card.tsx @@ -0,0 +1,163 @@ +import { Box, Divider, Typography } from "@mui/material"; +import { keyframes } from "@mui/system"; +import { memo } from "react"; + +import { Button } from "../../../../shared/ui/button"; +import { graphColors } from "../visual-style"; + +import type { Position } from "../geometry"; + +interface FrontierClusterCardProps extends Position { + /** Number of unexpanded (frontier) entities the cluster holds. */ + readonly count: number; + + /** Bubble on-screen radius (px); the card sits just outside the right edge. */ + readonly radiusPx: number; + /** A frontier fetch is already in flight, so the action is busy. */ + readonly isFetching: boolean; + readonly onLoad: () => void; + readonly onMouseEnter: () => void; + readonly onMouseLeave: () => void; +} + +const rise = keyframes` + from { opacity: 0; transform: translateY(3px); } + to { opacity: 1; transform: translateY(0); } +`; + +const [frontierR, frontierG, frontierB] = graphColors.frontier; + +type FrontierClusterBodyProps = Pick< + FrontierClusterCardProps, + "count" | "isFetching" | "onLoad" +>; + +/** + * The card's visual body, memoized on its content (not the bubble position), so the per-frame + * re-position of the wrapper never re-lays out this MUI tree. Echoes the grey frontier bubble with a + * matching swatch and follows the same icon + title + stat discipline as the other graph cards. + */ +const FrontierClusterBodyComponent = ({ + count, + isFetching, + onLoad, +}: FrontierClusterBodyProps) => ( + ({ + minWidth: 188, + maxWidth: 260, + bgcolor: palette.common.white, + border: `1px solid ${palette.gray[20]}`, + borderRadius: "8px", + boxShadow: boxShadows.md, + overflow: "hidden", + animation: `${rise} 130ms cubic-bezier(0.22, 1, 0.36, 1)`, + "@media (prefers-reduced-motion: reduce)": { animation: "none" }, + })} + > + + {/* A swatch in the frontier grey, tying the card to the greyed-out bubble it acts on. */} + ({ + flexShrink: 0, + width: 26, + height: 26, + borderRadius: "50%", + bgcolor: `rgb(${frontierR}, ${frontierG}, ${frontierB})`, + border: `1px solid ${palette.gray[30]}`, + })} + /> + + palette.gray[90], + }} + > + Unexpanded cluster + + palette.gray[70], + }} + > + palette.gray[90] }} + > + {count.toLocaleString()} + {" "} + {count === 1 ? "entity" : "entities"} not loaded + + + + + palette.gray[20] }} /> + + + +); + +const FrontierClusterBody = memo(FrontierClusterBodyComponent); + +/** + * Action card for a wholly-frontier cluster bubble: its unexpanded entity count and a button that + * loads them. Unlike the click-through hover cards, this one is interactive (the Load button), so + * the bridge keeps it open while the cursor is over the bubble OR the card. The wrapper positions + * the card just outside the bubble's right edge via a GPU transform and re-positions every frame so + * it tracks the bubble; {@link FrontierClusterBody} is memoized so that move never re-lays it out. + */ +export const FrontierClusterCard = ({ + count, + x, + y, + radiusPx, + isFetching, + onLoad, + onMouseEnter, + onMouseLeave, +}: FrontierClusterCardProps) => ( +
+ +
+); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/frontier-expansion-store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/frontier-expansion-store.ts new file mode 100644 index 00000000000..e7c4be50245 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/frontier-expansion-store.ts @@ -0,0 +1,383 @@ +/** + * Frontier-expansion state, held outside React and read through + * `useSyncExternalStore` snapshots (see `use-frontier-expansion.ts`). + * + * Expansions are async fetch pipelines (id batches -> subgraph fetch -> worker + * ingest) whose bookkeeping (what is expanded, what is in flight, what each + * expansion revealed) must be readable both by React renders and by the + * Scene's imperative resolvers between renders. A store gives both readers one + * authority: React subscribes to immutable snapshots; imperative code reads + * the latest snapshot directly. + * + * The store outlives any one worker: it is owned by the surface that owns the + * entity query (the entities page, an entity slide) and keyed to the query's + * identity, while workers come and go beneath it (view switches unmount the + * visualizer; a `sourceKey` change recreates the worker). The worker is a + * sink the store {@link FrontierExpansionStore.attach}es to: every committed + * expansion is kept as an {@link ExpansionRecord}, and attaching a fresh + * worker replays the records into it, so expansions survive leaving and + * re-entering the graph view and can be shown outside it (the "OR n + * entities" filter pill, expansion rows in the table). + * + * An expansion still in flight when its owner scope dies keeps fetching until + * {@link FrontierExpansionStore.deactivate} stops it; a deactivated store + * never mutates state again, so an orphaned generation cannot leak into the + * fresh one that replaced it. + */ + +import { useSyncExternalStore } from "react"; + +import { fetchFrontierExpansion } from "../fetch-frontier-expansion"; +import { + freshFrontierIds, + frontierExpansionBatches, +} from "../interactivity/frontier-expansion"; +import { + extractPropertySchemas, + extractTypeSchemas, + toIngestEntities, +} from "./ingest-mapping"; + +import type { EntityWorkerHandle } from "../render/entity-worker-connection"; +import type { + IngestEntity, + PropertySchemaEntry, + TypeSchemaEntry, +} from "../worker/protocol"; +import type { EntityId } from "@blockprotocol/type-system"; +import type { + HashEntity, + SerializedSubgraph, +} from "@local/hash-graph-sdk/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; + +/** + * A hovered/selected entity's data plus the type maps its card needs. For a freshly-expanded node + * (not in the prop `entities`) this is the expansion it arrived in, since the prop maps don't cover + * it; for a prop entity it is just the prop maps. + */ +export interface EntityCardContext { + readonly entity: HashEntity; + readonly rootMap: ClosedMultiEntityTypesRootMap | undefined; + readonly definitions: ClosedMultiEntityTypesDefinitions | undefined; +} + +/** + * One committed expansion batch: everything needed to (1) replay it into a + * recreated worker byte-for-byte (the exact schema/ingest payloads originally + * sent) and (2) present the expanded entities outside the graph (table rows, + * resolved against the type maps and subgraph the batch arrived with). + */ +export interface ExpansionRecord { + /** The frontier ids this batch expanded (they became roots in the worker). */ + readonly expandedIds: readonly EntityId[]; + /** + * The expanded entities themselves (the fetched entities matching + * {@link expandedIds}), which are the rows an expansion adds to the table. + * The rest of the fetched neighbourhood (links, endpoint nodes) is the next + * frontier, not an addition. + */ + readonly expandedEntities: readonly HashEntity[]; + readonly rootMap: ClosedMultiEntityTypesRootMap | undefined; + readonly definitions: ClosedMultiEntityTypesDefinitions | undefined; + /** The batch response's subgraph, as table-row generation expects it. */ + readonly subgraph: SerializedSubgraph; + readonly typeSchemas: readonly TypeSchemaEntry[]; + readonly propertySchemas: readonly PropertySchemaEntry[]; + /** The exact ingest payload sent to the worker (and re-sent on replay). */ + readonly ingestEntities: readonly IngestEntity[]; +} + +export interface FrontierProgress { + readonly done: number; + readonly total: number; + readonly fetching: boolean; +} + +export interface FrontierSnapshot { + /** + * Frontier nodes the user has already expanded. Tracked locally because the worker learns + * their root-ness via ingest, but the bridge's prop-derived root set never does. + */ + readonly expandedRoots: ReadonlySet; + readonly inFlight: ReadonlySet; + /** + * Freshly-fetched expansion nodes and links (absent from the prop `entities`) plus the type + * maps their card resolves against. The root map is a nested per-type-chain structure, so each + * node keeps its source map rather than deep-merging maps from every expansion. + */ + readonly expandedById: ReadonlyMap; + /** Committed expansion batches, in commit order (the replay + table-row log). */ + readonly records: readonly ExpansionRecord[]; + readonly progress: FrontierProgress; + /** + * User-facing message from the most recent expansion failure, cleared when the next + * expansion starts. Partial progress survives the failure: batches fetched before it + * remain expanded, and the unfetched ids return to the frontier for retry (see + * {@link FrontierExpansionStore.expand}). + */ + readonly error: string | undefined; +} + +const emptyFrontierSnapshot: FrontierSnapshot = { + expandedRoots: new Set(), + inFlight: new Set(), + expandedById: new Map(), + records: [], + progress: { done: 0, total: 0, fetching: false }, + error: undefined, +}; + +export class FrontierExpansionStore { + /** + * The worker connection currently receiving expansions, if any. The store + * works without one: expansions keep committing to the record log, and the + * next {@link attach} replays them. Never read across an await without + * re-reading (a detach/attach can happen mid-expansion). + */ + #handle: EntityWorkerHandle | undefined; + + readonly #listeners = new Set<() => void>(); + + #snapshot = emptyFrontierSnapshot; + #active = true; + /** In-flight {@link expand} calls; `progress.fetching` stays true until the last one settles. */ + #runningExpansions = 0; + + readonly subscribe = (listener: () => void): (() => void) => { + this.#listeners.add(listener); + + return () => { + this.#listeners.delete(listener); + }; + }; + + /** + * The current snapshot. Immutable: every mutation publishes a fresh snapshot (with fresh + * collections for the parts that changed), so it is safe both as a `useSyncExternalStore` + * source and as a memo dependency. + */ + readonly getSnapshot = (): FrontierSnapshot => this.#snapshot; + + /** + * Point the store at a (new) worker connection and replay every committed + * expansion into it. The worker's ingest is additive and idempotent per + * entity (a re-sent entity re-asserts root-ness, never duplicates), so + * replay after the base re-ingest converges to the same graph the previous + * worker held. + */ + attach(handle: EntityWorkerHandle): void { + this.#handle = handle; + + for (const record of this.#snapshot.records) { + handle.registerTypes(record.typeSchemas, record.propertySchemas); + handle.ingestBatch(record.ingestEntities); + } + } + + /** Stop feeding the current worker (it is being torn down); state is kept. */ + detach(): void { + this.#handle = undefined; + } + + /** Re-arm after {@link deactivate} (an effect setup/cleanup pair toggles these). */ + activate(): void { + this.#active = true; + } + + /** + * Stop in-flight expansions from fetching further batches and mutating + * state. Called when the owning scope goes away and leaves this instance + * orphaned: the store's reset key changed (new filter set, cleared + * additions) or its owner unmounted. + */ + deactivate(): void { + this.#active = false; + } + + /** + * Expand frontier nodes: fetch their neighbourhoods in batches, commit each + * batch to the record log, and hand it to the attached worker (if any), + * whose additive ingest is the merge. Each expanded node flips to a root + * and un-greys; its endpoints become the next frontier. Ids already + * expanded or in flight are skipped, so each id expands at most once and + * repeat calls are no-ops. + * + * Batches commit independently, and a fetch failure mid-run rolls nothing back: the + * worker cannot retract batches it already merged. Ids ingested before the failure + * stay expanded, while the unfetched ids leave `inFlight` without joining + * `expandedRoots`, so they count as frontier again and a repeat call fetches only that + * remainder. The failure message is published as {@link FrontierSnapshot.error} and + * clears when the next expansion starts. Failures never reject the returned promise + * (they surface only through the snapshot), so callers can fire-and-forget. + */ + readonly expand = async (entityIds: readonly EntityId[]): Promise => { + if (!this.#isActive()) { + return; + } + + const fresh = freshFrontierIds( + entityIds, + this.#snapshot.expandedRoots, + this.#snapshot.inFlight, + ); + if (fresh.length === 0) { + return; + } + + this.#runningExpansions += 1; + this.#publish({ + inFlight: new Set([...this.#snapshot.inFlight, ...fresh]), + error: undefined, + progress: { done: 0, total: fresh.length, fetching: true }, + }); + + let done = 0; + try { + for (const batch of frontierExpansionBatches(fresh)) { + const expansion = await fetchFrontierExpansion(batch); + if (!this.#isActive()) { + return; + } + + if (!expansion) { + throw new Error("Frontier expansion returned no data."); + } + + const batchIdSet = new Set(batch); + const record: ExpansionRecord = { + expandedIds: batch, + expandedEntities: expansion.entities.filter((entity) => + batchIdSet.has(entity.metadata.recordId.entityId), + ), + rootMap: expansion.closedMultiEntityTypes, + definitions: expansion.definitions, + subgraph: expansion.subgraph, + typeSchemas: extractTypeSchemas( + expansion.entities, + expansion.closedMultiEntityTypes, + expansion.definitions, + ), + propertySchemas: extractPropertySchemas(expansion.definitions), + ingestEntities: toIngestEntities(expansion.entities, batchIdSet), + }; + + // Feed the live worker, if one is attached; otherwise the record + // alone carries the batch until the next attach replays it. + this.#handle?.registerTypes(record.typeSchemas, record.propertySchemas); + this.#handle?.ingestBatch(record.ingestEntities); + + const expandedRoots = new Set(this.#snapshot.expandedRoots); + for (const entityId of batch) { + expandedRoots.add(entityId); + } + + // Keep the fetched entities and the maps their card resolves against, for hover/selection + // on nodes this expansion revealed (they are not in the prop `entities`). + const expandedById = new Map(this.#snapshot.expandedById); + for (const entity of expansion.entities) { + expandedById.set(entity.metadata.recordId.entityId, { + entity, + rootMap: expansion.closedMultiEntityTypes, + definitions: expansion.definitions, + }); + } + + done += batch.length; + this.#publish({ + expandedRoots, + expandedById, + records: [...this.#snapshot.records, record], + progress: { done, total: fresh.length, fetching: true }, + }); + } + } catch (fetchError) { + if (this.#isActive()) { + this.#publish({ + error: + fetchError instanceof Error + ? fetchError.message + : "Could not fetch the frontier.", + }); + } + } finally { + this.#runningExpansions -= 1; + if (this.#isActive()) { + const inFlight = new Set(this.#snapshot.inFlight); + for (const entityId of fresh) { + inFlight.delete(entityId); + } + + this.#publish({ + inFlight, + progress: { + ...this.#snapshot.progress, + fetching: this.#runningExpansions > 0, + }, + }); + } + } + }; + + /** + * As a method (not a field read) so the lint's control-flow narrowing doesn't flag the + * re-checks after each await: `deactivate()` flips the field concurrently mid-expansion. + */ + #isActive(): boolean { + return this.#active; + } + + #publish(partial: Partial): void { + this.#snapshot = { ...this.#snapshot, ...partial }; + + for (const listener of this.#listeners) { + listener(); + } + } +} + +export const useFrontierExpansionStore = (store: FrontierExpansionStore) => + useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); + +/** + * The not-yet-expanded frontier across both entity sources (the props and prior expansions): + * every non-link entity that is neither a query root nor already expanded nor in flight. + */ +export function collectFrontierEntityIds( + entities: readonly HashEntity[] | undefined, + rootIdSet: ReadonlySet | undefined, + frontierState: Pick< + FrontierSnapshot, + "expandedRoots" | "inFlight" | "expandedById" + >, +): EntityId[] { + if (!rootIdSet) { + return []; + } + + const frontier = new Set(); + const addIfFrontier = (entity: HashEntity): void => { + const entityId = entity.metadata.recordId.entityId; + if ( + !entity.linkData && + !rootIdSet.has(entityId) && + !frontierState.expandedRoots.has(entityId) && + !frontierState.inFlight.has(entityId) + ) { + frontier.add(entityId); + } + }; + + for (const entity of entities ?? []) { + addIfFrontier(entity); + } + + for (const context of frontierState.expandedById.values()) { + addIfFrontier(context.entity); + } + + return [...frontier]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/frontier-legend.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/frontier-legend.tsx new file mode 100644 index 00000000000..f43d120f95b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/frontier-legend.tsx @@ -0,0 +1,51 @@ +import { Box, Stack, Typography } from "@mui/material"; + +import { graphColors } from "../visual-style"; + +const rgba = (color: readonly [number, number, number, number]) => + `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3] / 255})`; + +export const FrontierLegend = () => ( + + + + + + Grey nodes are frontier entities + + + + ({ + width: 14, + height: 2, + borderRadius: 999, + bgcolor: palette.gray[50], + })} + /> + + Thick lanes bundle links + + + + +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/guidance-card.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/guidance-card.tsx new file mode 100644 index 00000000000..d3dedaa53c9 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/guidance-card.tsx @@ -0,0 +1,46 @@ +import { Stack, Typography } from "@mui/material"; + +import { Button } from "../../../../shared/ui/button"; +import { GraphOverlayPanel } from "../components/graph-overlay-panel"; + +interface GraphGuidanceCardProps { + readonly onDismiss: () => void; +} + +export const GraphGuidanceCard = ({ onDismiss }: GraphGuidanceCardProps) => ( + + + + + Explore relationships + + + Drag to pan, scroll to zoom, hover for details, and select a node to + focus its neighbours. + + + + + Grey nodes are frontier entities outside the current query. + + + Bundled links can be opened as a table. + + + + + +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/highway-summary-card.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/highway-summary-card.tsx new file mode 100644 index 00000000000..f6a128e5878 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/highway-summary-card.tsx @@ -0,0 +1,234 @@ +import { Box, Divider, Typography } from "@mui/material"; +import { keyframes } from "@mui/system"; +import { memo, useMemo } from "react"; + +import { EntityOrTypeIcon } from "@hashintel/design-system"; +import { + getClosedMultiEntityTypeFromMap, + getDisplayFieldsForClosedEntityType, +} from "@local/hash-graph-sdk/entity"; + +import type { Position } from "../geometry"; +import type { VersionedUrl } from "@blockprotocol/type-system"; +import type { ClosedMultiEntityTypesRootMap } from "@local/hash-graph-sdk/ontology"; + +interface HighwaySummaryCardProps extends Position { + /** The lane's single link type (a lane is single-type); null for a multi-type rollup. */ + readonly typeId: VersionedUrl | null; + /** Fallback display label, used for a rollup (no single type to resolve from the schema). */ + readonly typeLabel: string; + /** Total number of links the highway aggregates. */ + readonly count: number; + /** Net direction of the bundled links, relative to the highway's source -> target. */ + readonly direction: "forward" | "reverse" | "both"; + /** The closed type schema the main thread already holds -- resolves the icon + title. */ + readonly closedMultiEntityTypesRootMap: + | ClosedMultiEntityTypesRootMap + | undefined; +} + +const rise = keyframes` + from { opacity: 0; transform: translateY(3px); } + to { opacity: 1; transform: translateY(0); } +`; + +/** A short human phrase for a bundle's net direction. */ +function directionLabel( + direction: HighwaySummaryCardProps["direction"], +): string { + switch (direction) { + case "both": + return "Bidirectional"; + case "forward": + return "Outgoing"; + case "reverse": + return "Incoming"; + } +} + +type HighwaySummaryBodyProps = Omit; + +/** + * The card's visual body, memoized on its content (not the cursor position), so a mouse-move that + * re-positions the wrapper every frame never re-lays out this MUI tree. Resolves the lane link + * type's icon and direction-aware title from the closed type schema via + * {@link getDisplayFieldsForClosedEntityType}. + */ +const HighwaySummaryBodyComponent = ({ + typeId, + typeLabel, + count, + direction, + closedMultiEntityTypesRootMap, +}: HighwaySummaryBodyProps) => { + const typeFields = useMemo(() => { + if (!typeId || !closedMultiEntityTypesRootMap) { + return null; + } + try { + const closedType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesRootMap, + [typeId], + ); + const { icon, isLink } = getDisplayFieldsForClosedEntityType(closedType); + const leaf = closedType.allOf[0]; + // A link type reads differently per direction: the forward title ("Has Member") vs the + // inverse title ("Member Of"). A lane is single-direction, so pick the matching one. + return { + icon, + isLink, + title: leaf.title, + inverseTitle: leaf.inverse?.title, + }; + } catch { + return null; + } + }, [typeId, closedMultiEntityTypesRootMap]); + + const title = typeFields + ? direction === "reverse" + ? (typeFields.inverseTitle ?? typeFields.title) + : typeFields.title + : typeLabel || "Links"; + + return ( + ({ + minWidth: 188, + maxWidth: 280, + bgcolor: palette.common.white, + border: `1px solid ${palette.gray[20]}`, + borderRadius: "8px", + boxShadow: boxShadows.md, + overflow: "hidden", + animation: `${rise} 130ms cubic-bezier(0.22, 1, 0.36, 1)`, + "@media (prefers-reduced-motion: reduce)": { animation: "none" }, + })} + > + {/* Identity: the link type's icon anchors the type + direction, mirroring the entity card. */} + + ({ + flexShrink: 0, + width: 32, + height: 32, + borderRadius: "7px", + bgcolor: palette.gray[10], + border: `1px solid ${palette.gray[20]}`, + display: "flex", + alignItems: "center", + justifyContent: "center", + })} + > + palette.gray[70]} + /> + + + palette.gray[90], + wordBreak: "break-word", + }} + > + {title} + + palette.gray[70], + }} + > + {directionLabel(direction)} + + + + + palette.gray[20] }} /> + + {/* Stat: how many links this ribbon aggregates -- the number a click expands into a table. */} + + palette.gray[70] }} + > + palette.gray[90] }} + > + {count.toLocaleString()} + {" "} + {count === 1 ? "link" : "links"} bundled + + + + palette.gray[20] }} /> + + + + Click to view links + + + + ); +}; + +const HighwaySummaryBody = memo(HighwaySummaryBodyComponent); + +/** + * Hover summary for an aggregated highway: the link type(s) it bundles, the net direction, and how + * many links (a click opens the full table). The wrapper positions the card via a GPU transform -- + * cheap per cursor-move frame -- while {@link HighwaySummaryBody} renders the contents, memoized so + * the per-frame move never re-lays them out. Click-through so it never eats a hover. + */ +export const HighwaySummaryCard = ({ + typeId, + typeLabel, + count, + direction, + closedMultiEntityTypesRootMap, + x, + y, +}: HighwaySummaryCardProps) => ( +
+ +
+); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/hover-card.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/hover-card.tsx new file mode 100644 index 00000000000..1a953e01bc5 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/hover-card.tsx @@ -0,0 +1,362 @@ +import { Box, Divider, Stack, Typography } from "@mui/material"; +import { keyframes } from "@mui/system"; +import { memo, useMemo } from "react"; + +import { EntityOrTypeIcon } from "@hashintel/design-system"; +import { + getClosedMultiEntityTypeFromMap, + getDisplayFieldsForClosedEntityType, +} from "@local/hash-graph-sdk/entity"; +import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; + +import { Button } from "../../../../shared/ui/button"; + +import type { Position } from "../geometry"; +import type { + BaseUrl, + ClosedMultiEntityType, +} from "@blockprotocol/type-system"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; + +/** How many salient properties the card shows before it gets noisy. */ +const MAX_PROPERTIES = 4; + +interface EntityHoverCardProps extends Position { + readonly entity: HashEntity; + readonly closedMultiEntityTypesRootMap: + | ClosedMultiEntityTypesRootMap + | undefined; + readonly definitions: ClosedMultiEntityTypesDefinitions | undefined; + /** Incident-link count (needs the full entity set, so the bridge resolves it). */ + readonly degree: number; + /** + * When set, the card is "pinned" (a selection, not a hover): it renders an Open action + * that calls this. The card body stays click-through; only this button is interactive. + * Must be referentially stable across pans, or the memoized body re-renders every frame. + */ + readonly onOpen?: () => void; +} + +/** A short, single-line rendering of a property value. */ +function formatPropertyValue(value: unknown): string { + if (Array.isArray(value)) { + return value.map((item) => formatPropertyValue(item)).join(", "); + } + if (typeof value === "string") { + return value.length > 64 ? `${value.slice(0, 63)}…` : value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return "…"; +} + +const rise = keyframes` + from { opacity: 0; transform: translateY(3px); } + to { opacity: 1; transform: translateY(0); } +`; + +type EntityHoverCardBodyProps = Omit; + +/** + * The card's visual body. Memoized on the entity + type context (not the position), so a pan + * that moves the card every frame only updates the wrapper's transform -- this MUI tree (label, + * type, properties, footer) is laid out once per entity, never re-rendered per frame. For that + * to hold, every prop here must be referentially stable while the selection is unchanged. + */ +const EntityHoverCardBodyComponent = ({ + entity, + closedMultiEntityTypesRootMap, + definitions, + degree, + onOpen, +}: EntityHoverCardBodyProps) => { + // Memoize label/type/property resolution on entity identity so pan frames only move + // the wrapper transform. + const content = useMemo(() => { + if (!closedMultiEntityTypesRootMap) { + return null; + } + + let closedType: ClosedMultiEntityType; + try { + closedType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesRootMap, + entity.metadata.entityTypeIds, + ); + } catch { + return null; + } + + const { icon, isLink, labelProperty } = + getDisplayFieldsForClosedEntityType(closedType); + + const properties: { title: string; value: string }[] = []; + for (const [baseUrl, raw] of Object.entries(entity.properties)) { + if (baseUrl === labelProperty || raw === null) { + continue; + } + + const refSchema = closedType.properties[baseUrl as BaseUrl]; + if (!refSchema) { + continue; + } + + const propertyTypeId = + "$ref" in refSchema ? refSchema.$ref : refSchema.items.$ref; + + const title = definitions?.propertyTypes[propertyTypeId]?.title; + const value = formatPropertyValue(raw); + if (!title || value === "") { + continue; + } + properties.push({ title, value }); + if (properties.length >= MAX_PROPERTIES) { + break; + } + } + + const createdIso = entity.metadata.provenance.createdAtDecisionTime; + const createdDate = createdIso ? new Date(createdIso) : undefined; + const created = + createdDate && !Number.isNaN(createdDate.getTime()) + ? createdDate.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }) + : undefined; + + return { + label: generateEntityLabel(closedType, entity), + typeTitle: closedType.allOf[0].title, + icon, + isLink, + properties, + created, + }; + }, [entity, closedMultiEntityTypesRootMap, definitions]); + + if (!content) { + return null; + } + + // A link entity's "degree" (links pointing AT the link) is ~always 0 and only confuses, so + // the link count is shown for nodes only. + const hasFooter = + (degree > 0 && !content.isLink) || content.created !== undefined; + + return ( + ({ + position: "relative", + minWidth: 208, + maxWidth: 300, + bgcolor: onOpen ? palette.blue[10] : palette.common.white, + border: `1px solid ${onOpen ? palette.blue[30] : palette.gray[20]}`, + borderRadius: "8px", + boxShadow: boxShadows.md, + animation: `${rise} 130ms cubic-bezier(0.22, 1, 0.36, 1)`, + "@media (prefers-reduced-motion: reduce)": { animation: "none" }, + })} + > + {/* Identity: type icon anchors the name + type, so they read as one block. */} + + ({ + flexShrink: 0, + width: 32, + height: 32, + borderRadius: "7px", + bgcolor: palette.gray[10], + border: `1px solid ${palette.gray[20]}`, + display: "flex", + alignItems: "center", + justifyContent: "center", + })} + > + palette.gray[70]} + /> + + + palette.gray[90], + wordBreak: "break-word", + }} + > + {content.label} + + palette.gray[70], + }} + > + {content.typeTitle} + + + + + {content.properties.length > 0 && ( + <> + palette.gray[20] }} /> + + {content.properties.map((property) => ( + + palette.gray[70], + }} + > + {property.title} + + palette.gray[90], + }} + > + {property.value} + + + ))} + + + )} + + {hasFooter && ( + <> + palette.gray[20] }} /> + + {!content.isLink && ( + palette.gray[70] }} + > + {degree} {degree === 1 ? "link" : "links"} + + )} + {content.created !== undefined && ( + palette.gray[70] }} + > + {content.created} + + )} + + + )} + + {onOpen ? ( + <> + palette.gray[20] }} /> + + + ) : null} + + ); +}; + +const EntityHoverCardBody = memo(EntityHoverCardBodyComponent); + +/** + * Hover / selection card for an entity dot. Owns how an entity is presented (label, type, a few + * key properties, a creation date) in the hash-frontend design language. The wrapper positions + * the card via a GPU transform -- cheap to update every pan frame -- while {@link + * EntityHoverCardBody} renders the contents, memoized so that per-frame move never re-lays them + * out. Click-through (pointer-events disabled) except the Open button, so it never eats hovers. + */ +export const EntityHoverCard = ({ + entity, + closedMultiEntityTypesRootMap, + definitions, + degree, + x, + y, + onOpen, +}: EntityHoverCardProps) => ( +
+ +
+); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/ingest-mapping.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/ingest-mapping.ts new file mode 100644 index 00000000000..41bbb619c93 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/ingest-mapping.ts @@ -0,0 +1,165 @@ +/** + * Pure mapping from the page's HashEntity + closed-type data to the worker's + * ingest and schema-registration payloads. Used for the initial prop-entity + * feed and for frontier expansions (which arrive with their own type maps). + */ +import { extractBaseUrl } from "@blockprotocol/type-system"; +import { getClosedMultiEntityTypeFromMap } from "@local/hash-graph-sdk/entity"; + +import type { + IngestEntity, + PropertySchemaEntry, + TypeSchemaEntry, +} from "../worker/protocol"; +import type { EntityId, VersionedUrl } from "@blockprotocol/type-system"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; + +export const toIngestEntities = ( + entities: readonly HashEntity[], + rootIds: ReadonlySet | undefined, +): IngestEntity[] => + entities.map((entity) => { + const entityId = entity.metadata.recordId.entityId; + const isLink = entity.linkData !== undefined; + + return { + entityId, + entityTypeIds: entity.metadata.entityTypeIds, + isLink, + // A link is never a root. With no root set, every node is a root (no frontier); otherwise + // root-ness is set membership -- non-members render as greyed-out frontier nodes. + isRoot: !isLink && (rootIds === undefined || rootIds.has(entityId)), + linkData: entity.linkData, + // Property values, for NODE entities only, so the worker can name embedding clusters + // by their distinctive shared properties. Links are never embedding-clustered. + properties: isLink ? undefined : entity.properties, + }; + }); + +/** + * Best-effort human title from a versioned type URL (".../entity-type//v/N"). + * Used only as a fallback when an ancestor type is absent from `definitions`, so + * a registered parent never ends up with an empty title. + */ +function titleFromUrl(versionedUrl: VersionedUrl): string { + const slug = /\/entity-type\/(?[^/]+)\//.exec(versionedUrl)?.groups + ?.slug; + + if (!slug) { + return ""; + } + + return slug + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +/** + * Build one {@link TypeSchemaEntry} per unique VersionedUrl reachable from the + * entities' types — INCLUDING inherited ancestor types that no entity uses + * directly. + * + * Each `entry.allOf` is the inheritance chain of a directly-applied type: the + * type itself at depth 0 followed by its ancestors. Those ancestor entries are + * {@link EntityTypeDisplayMetadata} — they carry `$id`/`depth`/`icon` but NO + * title — so titles for ancestors are looked up in `definitions.entityTypes`. + * + * We register every ancestor in the inheritance chain, not only the leaf: the + * worker needs TypeInfo for ancestor URLs to resolve roots and avoid routing + * entities into the nameless "Other" bucket. + */ +export function extractTypeSchemas( + entities: readonly HashEntity[], + typeMap: ClosedMultiEntityTypesRootMap | undefined, + definitions: ClosedMultiEntityTypesDefinitions | undefined, +): TypeSchemaEntry[] { + if (!typeMap) { + return []; + } + + const seen = new Map(); + + for (const entity of entities) { + let closedType; + try { + closedType = getClosedMultiEntityTypeFromMap( + typeMap, + entity.metadata.entityTypeIds, + ); + } catch { + continue; + } + + for (const entry of closedType.allOf) { + // Chain ordered shallow-to-deep: index 0 is the type itself, the rest are + // its ancestors (closest first). + const chain = [...entry.allOf].sort((a, b) => a.depth - b.depth); + + for (let depthIdx = 0; depthIdx < chain.length; depthIdx++) { + // depthIdx is bounded by chain.length from the enclosing for-loop. + const node = chain[depthIdx]!; + if (seen.has(node.$id)) { + continue; + } + + // Deeper entries are this type's ancestors. Pointing at all of them + // (not only the direct parent) over-approximates the inheritance DAG + // with transitive edges — harmless for the worker's root resolution + // (root = union of parents' roots) and robust to multiple inheritance + // without reconstructing the DAG here. + const allOfRefs = chain + .slice(depthIdx + 1) + .map((ancestor) => ancestor.$id); + + const definition = definitions?.entityTypes[node.$id]; + + seen.set(node.$id, { + url: node.$id, + // Leaf has its title on `entry`; ancestors come via `definitions`, + // falling back to the URL slug so a parent is never title-less. + title: + definition?.title ?? + (depthIdx === 0 ? entry.title : titleFromUrl(node.$id)), + // Link types carry an inverse (target -> source) title; the leaf has it on `entry`, + // ancestors via `definitions`. Undefined for non-link types (no inverse). + inverseTitle: + definition?.inverse?.title ?? + (depthIdx === 0 ? entry.inverse?.title : undefined), + icon: node.icon ?? definition?.icon, + allOfRefs, + }); + } + } + } + + return [...seen.values()]; +} + +/** + * Property display titles keyed by base URL, for every property type referenced by the + * loaded entities. Shipped to the worker so a distinctive-feature cluster label reads + * "Destination = ..." with the human title rather than a raw base URL. The worker holds + * the property VALUES (on the ingested entities); this supplies their names. + */ +export function extractPropertySchemas( + definitions: ClosedMultiEntityTypesDefinitions | undefined, +): PropertySchemaEntry[] { + if (!definitions) { + return []; + } + + const seen = new Map(); + for (const propertyType of Object.values(definitions.propertyTypes)) { + const baseUrl = extractBaseUrl(propertyType.$id); + if (!seen.has(baseUrl)) { + seen.set(baseUrl, { baseUrl, title: propertyType.title }); + } + } + + return [...seen.values()]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/overlays.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/overlays.tsx new file mode 100644 index 00000000000..ad377aa313f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/overlays.tsx @@ -0,0 +1,198 @@ +/** + * Leaf components for the HTML overlays the Scene drives (hover cards, the pinned + * selection card, hub labels). Each subscribes to exactly the {@link SceneOverlayStore} + * slice it renders, so the Scene's per-frame position re-emissions re-render only the + * one affected leaf — never the bridge component that owns the worker and data flow. + */ +import { useCallback } from "react"; + +import { NodeLabelOverlay } from "../components/node-label-overlay"; +import { useOverlaySlice } from "../components/scene-overlay-store"; +import { FrontierClusterCard } from "./frontier-cluster-card"; +import { HighwaySummaryCard } from "./highway-summary-card"; +import { EntityHoverCard } from "./hover-card"; + +import type { SceneOverlayStore } from "../components/scene-overlay-store"; +import type { EntityCardContext } from "./frontier-expansion-store"; +import type { EntityId } from "@blockprotocol/type-system"; +import type { ClosedMultiEntityTypesRootMap } from "@local/hash-graph-sdk/ontology"; + +interface EntityCardResolvers { + readonly getCardContext: ( + entityId: EntityId, + ) => EntityCardContext | undefined; + readonly degreeById: ReadonlyMap; +} + +/** + * The transient hover card over a flat-tier dot. Suppressed while that same dot is + * pinned (selected) so the two cards don't stack. + */ +export const EntityHoverOverlay: React.FC< + EntityCardResolvers & { + readonly overlayStore: SceneOverlayStore; + } +> = ({ overlayStore, getCardContext, degreeById }) => { + const hover = useOverlaySlice(overlayStore.nodeHover); + const selection = useOverlaySlice(overlayStore.selection); + + if (hover === null || hover.nodeId === selection?.nodeId) { + return null; + } + + const context = getCardContext(hover.nodeId); + if (context === undefined) { + return null; + } + + return ( + + ); +}; + +interface SelectionOverlayProps extends EntityCardResolvers { + readonly overlayStore: SceneOverlayStore; + readonly onEntityClick?: (entityId: EntityId) => void; +} + +/** + * The selected entity's pinned card (with an Open action) that tracks the node's + * on-screen position; the Scene re-emits the position through settle + pan/zoom. + */ +export const SelectionOverlay: React.FC = ({ + overlayStore, + getCardContext, + degreeById, + onEntityClick, +}) => { + const selection = useOverlaySlice(overlayStore.selection); + + // Keyed on the id, not the per-frame selection object, so the memoized card body + // stays referentially stable while the card tracks the node across pan frames. + const selectedEntityId = selection?.nodeId; + const handleOpen = useCallback(() => { + if (selectedEntityId !== undefined) { + onEntityClick?.(selectedEntityId); + } + }, [onEntityClick, selectedEntityId]); + + if (selection === null) { + return null; + } + + const context = getCardContext(selection.nodeId); + if (context === undefined) { + return null; + } + + return ( + + ); +}; + +interface HighwayHoverOverlayProps { + readonly overlayStore: SceneOverlayStore; + readonly closedMultiEntityTypesRootMap: + | ClosedMultiEntityTypesRootMap + | undefined; +} + +/** The hovered aggregated-highway summary card, anchored to the lane. */ +export const HighwayHoverOverlay = ({ + overlayStore, + closedMultiEntityTypesRootMap, +}: HighwayHoverOverlayProps) => { + const highwayHover = useOverlaySlice(overlayStore.highwayHover); + + if (highwayHover === null) { + return null; + } + + return ( + + ); +}; + +interface ClusterHoverOverlayProps { + readonly overlayStore: SceneOverlayStore; + readonly isFetching: boolean; + readonly onLoadFrontier: (entityIds: readonly EntityId[]) => void; +} + +/** + * The interactive load card at a wholly-frontier cluster bubble's edge. The store keeps + * it open across the cursor's bubble-to-card handoff; Load expands every frontier entity + * the bubble holds (read from the live slice, so the handler stays stable per frame) + * and dismisses the card. + */ +export const ClusterHoverOverlay = ({ + overlayStore, + isFetching, + onLoadFrontier, +}: ClusterHoverOverlayProps) => { + const clusterHover = useOverlaySlice(overlayStore.clusterHover); + + const handleLoad = useCallback(() => { + const hover = overlayStore.clusterHover.getValue(); + overlayStore.dismissClusterCard(); + if (hover !== null) { + onLoadFrontier(hover.frontierEntityIds); + } + }, [overlayStore, onLoadFrontier]); + + if (clusterHover === null) { + return null; + } + + return ( + + ); +}; + +interface EntityLabelsOverlayProps { + readonly overlayStore: SceneOverlayStore; +} + +/** + * The always-on hub labels, re-emitted by the Scene each frame with current on-screen + * positions so they track the camera + settling layout. + */ +export const EntityLabelsOverlay = ({ + overlayStore, +}: EntityLabelsOverlayProps) => { + const labels = useOverlaySlice(overlayStore.nodeLabels); + + return ; +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/use-display.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/use-display.ts new file mode 100644 index 00000000000..e7fb0dda688 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/use-display.ts @@ -0,0 +1,229 @@ +/** + * Resolves what an entity LOOKS like — display label, type icon, hover-card data, + * incident-link degree — from the two entity sources: the prop `entities` (resolved + * against the prop type maps) and frontier expansions (resolved against the maps each + * expansion arrived with, held in the {@link FrontierExpansionStore}). + */ +import { useCallback, useMemo } from "react"; + +import { + getClosedMultiEntityTypeFromMap, + getDisplayFieldsForClosedEntityType, +} from "@local/hash-graph-sdk/entity"; +import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; + +import type { + EntityCardContext, + FrontierExpansionStore, +} from "./frontier-expansion-store"; +import type { EntityId } from "@blockprotocol/type-system"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; + +interface UseEntityDisplayOptions { + readonly entities: readonly HashEntity[] | undefined; + readonly closedMultiEntityTypesRootMap: + | ClosedMultiEntityTypesRootMap + | undefined; + readonly definitions: ClosedMultiEntityTypesDefinitions | undefined; + /** Read imperatively (latest snapshot) by the Scene-facing resolvers. */ + readonly frontierStore: FrontierExpansionStore; + /** + * The render-committed expansion map, for everything that resolves DURING render + * (`getCardContext`, `degreeById`) so renders stay consistent with one snapshot. + */ + readonly expandedById: ReadonlyMap; +} + +interface UseEntityDisplayResult { + /** + * The card data for an entity: the prop maps for a prop entity, else the expansion it + * arrived in (a node a frontier expand revealed is not in the prop `entities`). + * Undefined when the entity is held by neither source. + */ + readonly getCardContext: ( + entityId: EntityId, + ) => EntityCardContext | undefined; + /** + * Resolve a dot's entity to its display label (its name, e.g. "Alice"), the SAME way the + * hover card does. WHICH dots are hubs (and so get an always-on label) is decided by the + * Scene from the worker's by-degree radius, not here; this just names whatever it is asked + * about. The Scene calls it only when it rebuilds the label set (zoom / structure change), + * never per frame. Undefined on any miss (entity not held, or its closed type can't be + * resolved) so the dot simply goes unlabelled. + */ + readonly resolveEntityLabel: (entityId: EntityId) => string | undefined; + /** + * Resolve a dot's entity to its TYPE ICON as an atlas key -- the same display field the + * hover card's icon uses (`getDisplayFieldsForClosedEntityType(...).icon`, which walks the + * type hierarchy). The Scene calls this only when it rebuilds the flat-tier icon set (a + * structure change), never per frame. Returns the key only when it's a non-empty STRING + * (an emoji or an image URL); a ReactElement icon (system-type override) or none -> null -> + * no atlas entry, so that dot simply shows no icon. NOT gated on hubs: the IconLayer's + * soft-LOD sizing hides icons on dots that are small on screen, so every entity is eligible. + */ + readonly resolveEntityIcon: (entityId: EntityId) => string | null; + /** + * Each entity's incident-link count (degree): a link entity carries both endpoints, so one + * pass over the links tallies both sides. Counts the prop links AND the links pulled in by + * frontier expansion -- the union the worker itself ingested -- so a hub enlarged by + * expansion reports its true loaded degree, not just its prop-visible links. + */ + readonly degreeById: ReadonlyMap; +} + +export function useEntityDisplay({ + entities, + closedMultiEntityTypesRootMap, + definitions, + frontierStore, + expandedById, +}: UseEntityDisplayOptions): UseEntityDisplayResult { + const entityById = useMemo(() => { + const map = new Map(); + + for (const entity of entities ?? []) { + map.set(entity.metadata.recordId.entityId, entity); + } + + return map; + }, [entities]); + + const getCardContext = useCallback( + (entityId: EntityId): EntityCardContext | undefined => { + const propEntity = entityById.get(entityId); + + if (propEntity) { + return { + entity: propEntity, + rootMap: closedMultiEntityTypesRootMap, + definitions, + }; + } + + return expandedById.get(entityId); + }, + [entityById, closedMultiEntityTypesRootMap, definitions, expandedById], + ); + + // The Scene-facing lookup: identical two-source resolution, but against the store's LATEST + // snapshot. The Scene invokes these between renders (on structure/zoom changes), so they + // must see an expansion the moment it lands, not at the next commit. + const lookupLatest = useCallback( + (entityId: EntityId): EntityCardContext | undefined => { + const propEntity = entityById.get(entityId); + + if (propEntity) { + return { + entity: propEntity, + rootMap: closedMultiEntityTypesRootMap, + definitions, + }; + } + + return frontierStore.getSnapshot().expandedById.get(entityId); + }, + [entityById, closedMultiEntityTypesRootMap, definitions, frontierStore], + ); + + const resolveEntityLabel = useCallback( + (entityId: EntityId): string | undefined => { + const context = lookupLatest(entityId); + if (!context || !context.rootMap) { + return undefined; + } + + try { + const closedType = getClosedMultiEntityTypeFromMap( + context.rootMap, + context.entity.metadata.entityTypeIds, + ); + + return generateEntityLabel(closedType, context.entity); + } catch (exception) { + // eslint-disable-next-line no-console + console.warn("Unable to resolve entity label", entityId, exception); + + return undefined; + } + }, + [lookupLatest], + ); + + // Icon resolution walks the type hierarchy; memo by type-set key so each distinct set + // resolves once. The cache resets when the root map changes. Mutated only from + // resolveEntityIcon, which the Scene calls imperatively (never during a render). + const iconByTypeKey = useMemo(() => { + void closedMultiEntityTypesRootMap; + return new Map(); + }, [closedMultiEntityTypesRootMap]); + + const resolveEntityIcon = useCallback( + (entityId: EntityId): string | null => { + const context = lookupLatest(entityId); + if (!context || !context.rootMap) { + return null; + } + + const typeKey = [...context.entity.metadata.entityTypeIds] + .sort() + .join("\u0000"); + + const cached = iconByTypeKey.get(typeKey); + if (cached !== undefined) { + return cached; + } + + let resolved: string | null; + try { + const closedType = getClosedMultiEntityTypeFromMap( + context.rootMap, + context.entity.metadata.entityTypeIds, + ); + const { icon } = getDisplayFieldsForClosedEntityType(closedType); + resolved = typeof icon === "string" && icon.length > 0 ? icon : null; + } catch (exception) { + // eslint-disable-next-line no-console + console.warn("Unable to resolve entity icon", entityId, exception); + + resolved = null; + } + + iconByTypeKey.set(typeKey, resolved); + return resolved; + }, + [lookupLatest, iconByTypeKey], + ); + + const degreeById = useMemo(() => { + const map = new Map(); + + const tally = (entity: HashEntity): void => { + const { linkData } = entity; + if (!linkData) { + return; + } + + map.set(linkData.leftEntityId, (map.get(linkData.leftEntityId) ?? 0) + 1); + map.set( + linkData.rightEntityId, + (map.get(linkData.rightEntityId) ?? 0) + 1, + ); + }; + + for (const entity of entities ?? []) { + tally(entity); + } + + for (const context of expandedById.values()) { + tally(context.entity); + } + + return map; + }, [entities, expandedById]); + + return { getCardContext, resolveEntityLabel, resolveEntityIcon, degreeById }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/use-frontier-expansion.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/use-frontier-expansion.ts new file mode 100644 index 00000000000..18a95b88a7a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/use-frontier-expansion.ts @@ -0,0 +1,110 @@ +/** + * React bindings for {@link FrontierExpansionStore}. + * + * Ownership is split in two: + * + * - {@link useOwnedFrontierStore} runs where the entity query lives (the + * entities page, an entity slide, the dev harness): it creates the store, + * resets it when the query identity changes, and deactivates it on + * unmount. Owning it above the visualizer is what lets expansions survive + * view switches and surface outside the graph (filter pill, table rows). + * - {@link useFrontierExpansion} runs inside the visualizer: it attaches the + * store to the current worker connection (replaying committed expansions + * into a recreated worker), subscribes to snapshots, and derives the + * not-yet-expanded frontier id list. + */ +import { useEffect, useMemo, useState } from "react"; + +import { + collectFrontierEntityIds, + FrontierExpansionStore, + useFrontierExpansionStore, +} from "./frontier-expansion-store"; + +import type { EntityWorkerHandle } from "../render/entity-worker-connection"; +import type { FrontierSnapshot } from "./frontier-expansion-store"; +import type { EntityId } from "@blockprotocol/type-system"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; + +/** + * Create and own a {@link FrontierExpansionStore} scoped to `resetKey`, the + * identity of the entity set expansions extend (a filter change or an + * explicit "clear additions" flips it). A key change swaps in a fresh store, + * deactivating the orphan so its in-flight expansions stop; every local + * mirror of the old generation resets with it. + */ +export function useOwnedFrontierStore( + resetKey: string | undefined, +): FrontierExpansionStore { + const [entry, setEntry] = useState(() => ({ + key: resetKey, + store: new FrontierExpansionStore(), + })); + + // State-adjust during render (not an effect) so no frame ever pairs the new + // key's UI with the old generation's expansions. + if (entry.key !== resetKey) { + setEntry({ key: resetKey, store: new FrontierExpansionStore() }); + } + + useEffect(() => { + entry.store.activate(); + + return () => { + entry.store.deactivate(); + }; + }, [entry.store]); + + return entry.store; +} + +interface UseFrontierExpansionOptions { + /** The owner-scoped store (see {@link useOwnedFrontierStore}). */ + readonly store: FrontierExpansionStore; + readonly handle: EntityWorkerHandle | undefined; + readonly entities: readonly HashEntity[] | undefined; + readonly rootIdSet: ReadonlySet | undefined; +} + +interface UseFrontierExpansionResult { + readonly snapshot: FrontierSnapshot; + /** Every loaded entity that is still frontier: not a root, not expanded, not in flight. */ + readonly frontierEntityIds: readonly EntityId[]; +} + +export function useFrontierExpansion({ + store, + handle, + entities, + rootIdSet, +}: UseFrontierExpansionOptions): UseFrontierExpansionResult { + // Bind the store to the live worker. A recreated worker (sourceKey change, + // including a cleared-additions epoch bump) attaches fresh and receives the + // record replay; posting stops the moment the old worker is torn down. + useEffect(() => { + if (!handle) { + return; + } + + store.attach(handle); + + return () => { + store.detach(); + }; + }, [store, handle]); + + const snapshot = useFrontierExpansionStore(store); + const { expandedRoots, inFlight, expandedById } = snapshot; + + const frontierEntityIds = useMemo( + () => + collectFrontierEntityIds(entities, rootIdSet, { + expandedRoots, + inFlight, + expandedById, + }), + [entities, rootIdSet, expandedRoots, inFlight, expandedById], + ); + + return { snapshot, frontierEntityIds }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/use-ingest.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/use-ingest.ts new file mode 100644 index 00000000000..5da61bfadfd --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/use-ingest.ts @@ -0,0 +1,62 @@ +/** + * Streams the prop `entities` into the worker as append-only tail batches. + * + * Within one data source the array only grows (see the `sourceKey` prop), so each run + * hands the worker just the yet-unsent tail. The sent count is keyed to the worker + * handle it was sent to: a source change recreates the worker (a new handle), which + * restarts the count at zero for a clean re-ingest — no reset effect needed. + */ +import { useEffect, useRef } from "react"; + +import { toIngestEntities } from "./ingest-mapping"; + +import type { EntityWorkerHandle } from "../render/entity-worker-connection"; +import type { EntityId } from "@blockprotocol/type-system"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; + +interface UseEntityIngestOptions { + readonly handle: EntityWorkerHandle | undefined; + readonly ready: boolean; + readonly entities: readonly HashEntity[] | undefined; + /** + * Ingest waits for the first schema registration so the worker can group and + * label the very first batch correctly (registration happens in + * `useGraphWorker`'s effect, which runs before this one). + */ + readonly schemasRegistered: boolean; + readonly rootIdSet: ReadonlySet | undefined; +} + +interface SentProgress { + readonly handle: EntityWorkerHandle; + count: number; +} + +export function useEntityIngest({ + handle, + ready, + entities, + schemasRegistered, + rootIdSet, +}: UseEntityIngestOptions): void { + const sentRef = useRef(null); + + useEffect(() => { + if (!handle || !ready || !entities?.length || !schemasRegistered) { + return; + } + + if (sentRef.current?.handle !== handle) { + sentRef.current = { handle, count: 0 }; + } + + const sent = sentRef.current; + if (sent.count >= entities.length) { + return; + } + + const delta = entities.slice(sent.count); + sent.count = entities.length; + handle.ingestBatch(toIngestEntities(delta, rootIdSet)); + }, [handle, ready, entities, schemasRegistered, rootIdSet]); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/visualizer.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/visualizer.tsx new file mode 100644 index 00000000000..4bbdf8c9803 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/entity-graph/visualizer.tsx @@ -0,0 +1,340 @@ +/** + * Production bridge from Hash entity query results to the graph worker and overlay + * UI: ingests entities, drives frontier expansion, and renders hover/selection cards. + * + * It feeds entities into the worker (`use-ingest.ts`), expands the frontier on + * demand (`frontier-expansion-store.ts`), resolves display fields + * (`use-display.ts`), and renders the HTML overlays. The Scene's per-frame + * position reports flow through the {@link SceneOverlayStore} into leaf overlay + * components, so this component itself re-renders only when data (not the camera) + * changes. + */ +import { Box } from "@mui/material"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; + +import { FrontierControls } from "../components/frontier-controls"; +import { GraphStatusOverlay } from "../components/graph-status-overlay"; +import { SceneOverlayStore } from "../components/scene-overlay-store"; +import { GraphVisualizer } from "../graph-visualizer"; +import { useGraphGuidanceDismissal } from "../interactivity/use-graph-guidance-dismissal"; +import { useSimulationPause } from "../interactivity/use-simulation-pause"; +import { useLayoutFixtureCaptureHook } from "../layout-fixture-capture"; +import { useGraphWorker } from "../render/use-graph-worker"; +import { FrontierLegend } from "./frontier-legend"; +import { GraphGuidanceCard } from "./guidance-card"; +import { extractPropertySchemas, extractTypeSchemas } from "./ingest-mapping"; +import { + ClusterHoverOverlay, + EntityHoverOverlay, + EntityLabelsOverlay, + HighwayHoverOverlay, + SelectionOverlay, +} from "./overlays"; +import { useEntityDisplay } from "./use-display"; +import { useFrontierExpansion } from "./use-frontier-expansion"; +import { useEntityIngest } from "./use-ingest"; + +import type { VizConfig } from "../config"; +import type { EntityIndex } from "../ids"; +import type { EntityWorkerHandle } from "../render/entity-worker-connection"; +import type { EntitySelection, Scene } from "../render/scene/scene"; +import type { FrontierExpansionStore } from "./frontier-expansion-store"; +import type { EntityId } from "@blockprotocol/type-system"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; +import type { ReactElement } from "react"; + +interface EntityGraphVisualizerProps { + readonly entities?: HashEntity[]; + /** + * EntityIds of the query roots. Any entity in `entities` not in this set is a frontier node -- a + * fetched link endpoint rendered greyed-out until expanded. Omit to treat every entity as a root + * (no frontier). + */ + readonly rootEntityIds?: readonly EntityId[]; + readonly closedMultiEntityTypesRootMap?: ClosedMultiEntityTypesRootMap; + // The full type-definition map (every referenced type, including inherited + // ancestors, with titles). Required so the worker learns about parent types + // that no entity uses directly; see {@link extractTypeSchemas}. + readonly definitions?: ClosedMultiEntityTypesDefinitions; + readonly loadingComponent: ReactElement; + /** Open an entity, wired to clicking a flat-tier dot (resolved via the join key). */ + readonly onEntityClick?: (entityId: EntityId) => void; + /** Open the underlying link entities of an aggregated "highway" edge in a table. */ + readonly onOpenLinkTable?: (linkEntityIds: readonly EntityId[]) => void; + /** + * Override the worker's layout/scale config; omit to use the defaults. May + * change while mounted: a new reference is applied to the live worker + * (which re-tunes and re-lays out in place), keeping graph and camera. + */ + readonly config?: VizConfig; + /** + * A stable identity for the data source (the query/filter behind `entities`), not the result. + * `entities` streaming in for the same source keeps this constant; a filter change flips it. The + * worker's ingest is additive (no retract), so a changed key recreates it for a clean re-ingest. + * Omit (e.g. a fixed ego graph) to never recreate -- `entities` is then assumed append-only. + */ + readonly sourceKey?: string; + /** + * True while other UI is layered over this visualizer (a slide covering the page, a slide + * covered by a later slide): occlusion only the caller can know about. While not visible + * (this, a hidden tab, or scrolled out of view) the worker's simulation is paused: no CPU + * spent settling layouts, resuming from the same positions when visible again. + */ + readonly occluded?: boolean; + /** + * Frontier-expansion state, owned by the surface that owns the entity query (create it with + * `useOwnedFrontierStore`, keyed to the query's identity). Owned above this component so + * expansions persist across view switches and worker recreations; this component attaches + * the store to each worker it creates, replaying committed expansions into it. + */ + readonly frontierStore: FrontierExpansionStore; + /** + * Debug affordance: receives the live {@link EntityWorkerHandle} (undefined on teardown) so debug + * surfaces outside the visualizer (the dev harness) can issue worker queries, e.g. the + * capture-live-fixture hook (`handle.captureLayoutFixture()`). + */ + readonly onWorkerHandle?: (handle: EntityWorkerHandle | undefined) => void; + /** + * Debug affordance: receives the live {@link Scene} (null on teardown) so + * the dev harness render benchmark can drive captures and camera sweeps. + */ + readonly onSceneReady?: ( + scene: Scene | null, + ) => void; +} + +export const EntityGraphVisualizer: React.FC = memo( + ({ + entities, + rootEntityIds, + closedMultiEntityTypesRootMap, + definitions, + loadingComponent, + onEntityClick, + onOpenLinkTable, + config, + sourceKey, + occluded = false, + frontierStore, + onWorkerHandle, + onSceneReady, + }) => { + const typeSchemas = useMemo( + () => + extractTypeSchemas( + entities ?? [], + closedMultiEntityTypesRootMap, + definitions, + ), + [entities, closedMultiEntityTypesRootMap, definitions], + ); + + const propertySchemas = useMemo( + () => extractPropertySchemas(definitions), + [definitions], + ); + + const rootIdSet = useMemo( + () => (rootEntityIds ? new Set(rootEntityIds) : undefined), + [rootEntityIds], + ); + + // The data source's identity drives a worker recreate: a changed `sourceKey` (filter change) + // purges the additive worker, since it can't retract the old set. Same key -> `entities` only + // grows, streamed as a tail append. + const { handle, ready, error } = useGraphWorker({ + config, + typeSchemas, + propertySchemas, + resetKey: sourceKey, + }); + + // Forward the live worker handle whenever it changes so debug tooling can query + // layout state. + useEffect(() => { + onWorkerHandle?.(handle); + return () => { + onWorkerHandle?.(undefined); + }; + }, [handle, onWorkerHandle]); + + // Console debug hook (all surfaces, prod included): dump the live layout + // as a replayable fixture via `__hashGraphCaptureLayoutFixture()`. + useLayoutFixtureCaptureHook(handle); + + useEntityIngest({ + handle, + ready, + entities, + schemasRegistered: typeSchemas.length > 0, + rootIdSet, + }); + + const containerRef = useSimulationPause({ handle, ready, occluded }); + + const { snapshot: frontier, frontierEntityIds } = useFrontierExpansion({ + store: frontierStore, + handle, + entities, + rootIdSet, + }); + + const [overlayStore] = useState(() => new SceneOverlayStore()); + // A recreated worker gets a fresh Scene: clear the old scene's overlay reports (and + // the cluster card's grace timer) so nothing stale lingers over the new canvas. + useEffect( + () => () => { + overlayStore.reset(); + }, + [overlayStore, handle], + ); + + const { + getCardContext, + resolveEntityLabel, + resolveEntityIcon, + degreeById, + } = useEntityDisplay({ + entities, + closedMultiEntityTypesRootMap, + definitions, + frontierStore, + expandedById: frontier.expandedById, + }); + + const { shouldShowGuidance, dismissGuidance } = useGraphGuidanceDismissal(); + + const expandFrontier = useCallback( + (entityIds: readonly EntityId[]) => { + void frontierStore.expand(entityIds); + }, + [frontierStore], + ); + + const fetchCompleteFrontier = useCallback(() => { + void frontierStore.expand(frontierEntityIds); + }, [frontierStore, frontierEntityIds]); + + const handleEntitySelect = useCallback( + (selection: EntitySelection | null) => { + overlayStore.selection.setValue(selection); + // Selecting a frontier node also expands its neighbourhood; the store dedupes, + // so the Scene's per-frame re-emission of the selection expands at most once. + if (selection && rootIdSet && !rootIdSet.has(selection.nodeId)) { + void frontierStore.expand([selection.nodeId]); + } + }, + [overlayStore, frontierStore, rootIdSet], + ); + + if (error) { + return ( + + + + ); + } + + if (!handle) { + return ( + + {loadingComponent} + + + ); + } + + if (ready && (entities?.length ?? 0) === 0) { + return ( + + + + ); + } + + return ( + + + } + onNodeHover={overlayStore.nodeHover.setValue} + onHighwayHover={overlayStore.highwayHover.setValue} + onNodeSelect={handleEntitySelect} + onClusterHover={overlayStore.handleClusterHover} + onOpenLinkTable={onOpenLinkTable} + resolveNodeLabel={resolveEntityLabel} + resolveNodeIcon={resolveEntityIcon} + onNodeLabels={overlayStore.nodeLabels.setValue} + onSceneReady={onSceneReady} + /> + + {shouldShowGuidance ? ( + + ) : ( + + )} + + + + + + + ); + }, +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/fetch-frontier-expansion.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/fetch-frontier-expansion.ts new file mode 100644 index 00000000000..9b4aaff1b74 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/fetch-frontier-expansion.ts @@ -0,0 +1,117 @@ +/** + * GraphQL fetch for one frontier-expansion step: expanded nodes plus link endpoints + * and the type maps needed to register them. + */ +import { getLatestEntityVertices } from "@blockprotocol/graph/stdlib"; +import { deserializeQueryEntitySubgraphResponse } from "@local/hash-graph-sdk/entity"; +import { + currentTimeInstantTemporalAxes, + generateEntityIdFilter, +} from "@local/hash-isomorphic-utils/graph-queries"; + +import { queryEntitySubgraphQuery } from "../../../graphql/queries/knowledge/entity.queries"; +import { apolloClient } from "../../../lib/apollo-client"; + +import type { + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables, +} from "../../../graphql/api-types.gen"; +import type { EntityId } from "@blockprotocol/type-system"; +import type { TraversalPath } from "@local/hash-graph-client"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; + +/** + * Resolve the links into and out of the expanded nodes so their endpoints come back too and + * become the next frontier (left/right entity edges, both directions). + */ +const frontierTraversalPaths: TraversalPath[] = [ + { + edges: [ + { kind: "has-left-entity", direction: "incoming" }, + { kind: "has-right-entity", direction: "outgoing" }, + ], + }, + { + edges: [ + { kind: "has-right-entity", direction: "incoming" }, + { kind: "has-left-entity", direction: "outgoing" }, + ], + }, +]; + +/** + * The neighbourhood of an expanded frontier set: the fetched entities plus the type data the + * caller needs to register them. The entities' ids are the new roots; the rest of the + * neighbourhood is the next frontier. + */ +export interface FrontierExpansion { + entities: HashEntity[]; + closedMultiEntityTypes: QueryEntitySubgraphQuery["queryEntitySubgraph"]["closedMultiEntityTypes"]; + definitions: QueryEntitySubgraphQuery["queryEntitySubgraph"]["definitions"]; + /** + * The response's subgraph as serialized on the wire, in the shape + * table-row generation consumes (see `generate-table-data-from-rows.ts`), + * so expanded entities can be presented as table rows outside the graph. + */ + subgraph: QueryEntitySubgraphQuery["queryEntitySubgraph"]["subgraph"]; +} + +/** + * Fetches the neighbourhood of a set of frontier nodes to feed the worker's additive + * ingest (the whole point of incremental loading). + * + * Deliberately a plain async function, not a reactive hook: it has no React-state + * dependencies, and a reactive query here would fight the additive model. Each id is + * rooted via {@link generateEntityIdFilter}, so every expanded node brings its links + * and endpoints back in the same response. + * + * @returns `undefined` only when `entityIds` is empty; otherwise the fetched + * expansion (which may itself carry no new entities). + * @throws Propagates Apollo/network and GraphQL errors from the underlying query; + * callers are responsible for catching and surfacing them. + */ +export async function fetchFrontierExpansion( + entityIds: readonly EntityId[], +): Promise { + if (entityIds.length === 0) { + return undefined; + } + + const { data: expansion } = await apolloClient.query< + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables + >({ + query: queryEntitySubgraphQuery, + // network-only: frontier expansion must reflect server state after prior + // ingests, not a cached subgraph. + fetchPolicy: "network-only", + variables: { + request: { + filter: { + any: entityIds.map((entityId) => + generateEntityIdFilter({ entityId, includeArchived: false }), + ), + }, + traversalPaths: frontierTraversalPaths, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: false, + includeEntityTypes: "resolvedWithDataTypeChildren", + includePermissions: false, + }, + }, + }); + + const expandedSubgraph = deserializeQueryEntitySubgraphResponse( + expansion.queryEntitySubgraph, + ).subgraph; + + return { + entities: getLatestEntityVertices(expandedSubgraph).map( + (vertex) => vertex.inner, + ), + closedMultiEntityTypes: + expansion.queryEntitySubgraph.closedMultiEntityTypes, + definitions: expansion.queryEntitySubgraph.definitions, + subgraph: expansion.queryEntitySubgraph.subgraph, + }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/frames.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/frames.ts new file mode 100644 index 00000000000..9c05162eaf6 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/frames.ts @@ -0,0 +1,325 @@ +import type { Position } from "./geometry"; +import type { ClusterId, TypeId, VizMode } from "./ids"; +/** + * Types the rendering layer owns: the payloads the worker sends to the main + * thread for Deck.gl to consume. + * + * The data flow is split by update rate, so that the expensive O(entities) and + * O(links) work happens only when topology changes, never on a position tick: + * + * - {@link StructureFrame}: identities, colors, labels, radii, and edge + * topology. Sent only when the visible cut (LOD) changes. Held in a ref on + * the main thread; a version counter drives Deck.gl `updateTriggers`. + * + * - {@link PositionsFrame}: world positions for the (bounded) set of visible + * clusters plus freshly-computed highway/feeder Bezier control points. Sent + * on every force-layout tick while the macro layout is unsettled, then it + * stops (positions are frozen between cut changes). Cluster geometry is tiny + * (bounded by the render budget), so it travels by `postMessage`. + * + * - Entity positions: millions-scale, so they never travel by message. They + * live in a `SharedArrayBuffer` per open leaf (see {@link LayoutCreatedMessage}) + * and are read directly by the GPU. Entity-incident edges are composed on + * the main thread from that same SAB, so dots and their edges cannot tear. + */ +import type { EntityId, VersionedUrl } from "@blockprotocol/type-system"; + +export type Color = readonly [ + red: number, + green: number, + blue: number, + alpha: number, +]; + +/** + * A cluster bubble: identity and style. Its world position is delivered + * separately in the index-aligned {@link PositionsFrame.clusterPositions}, so + * a bubble can move (force layout settling) without resending its identity. + */ +export interface RenderCluster { + readonly id: ClusterId; + readonly color: Color; + readonly label: string; + readonly count: number; + /** World-space radius (from stable packing; constant between cut changes). */ + readonly radius: number; + /** + * Nesting depth among open containers. 0 = a leaf/standalone bubble; >0 = an + * opened container, rendered as a faint outline with its label near the top. + */ + readonly depth: number; + /** + * How many of this cluster's members are frontier (fetched-but-unexpanded) + * entities; equals {@link count} when every member is frontier. + */ + readonly frontierCount: number; + /** + * The frontier members' EntityIds, set only when every member is frontier + * ({@link frontierCount} === {@link count}); absent otherwise. + */ + readonly frontierEntityIds?: readonly EntityId[]; +} + +/** + * Describes the individual-entity edges for one open entity-mode leaf. The + * geometry is composed on the main thread from the leaf's position SAB, so it + * tracks the dots exactly. Endpoints are local to the leaf's center; the main + * thread adds the leaf's world position (see {@link leafClusterIndex}). + */ +export interface RenderEntityLayer { + /** The leaf cluster id; matches a `LayoutCreatedMessage.clusterId` SAB. */ + readonly layoutId: ClusterId; + /** + * Index of this leaf within {@link StructureFrame.clusters} (and therefore + * within {@link PositionsFrame.clusterPositions}). Used to resolve the + * leaf's world center, which is the origin for its entities' local coords. + */ + readonly leafClusterIndex: number; + readonly count: number; + /** Uniform entity-dot radius in world units. */ + readonly radius: number; + readonly color: Color; + /** + * Entity-to-entity internal links: interleaved local index pairs + * `[a0, b0, a1, b1, ...]` into the leaf's position SAB. This is topology (which + * dots link), unchanged between cut changes; the positions come from the SAB. + */ + readonly internalEdges: Uint32Array; + readonly fanOutColor: Color; +} + +/** + * Per open entity-mode leaf, the fan-out feeder endpoints for the current + * positions: interleaved `[localIdx, exitLocalX, exitLocalY, ...]`, the exit in + * the leaf's local frame. This is positional: the exit moves as the leaf's + * ports re-slot while the macro layout settles, so it rides the + * {@link PositionsFrame}, not the (topology-only) {@link StructureFrame}. The + * main thread pairs it with the leaf's {@link RenderEntityLayer} (for + * `leafClusterIndex` + `fanOutColor`) by `layoutId`. + */ +export interface RenderEntityFanOut { + readonly layoutId: ClusterId; + readonly fanOut: Float32Array; +} + +/** + * The whole-graph individual-entity view for `flat-force` and `community-force` + * (one placement regime with identical nodes and edges; the modes differ only in + * whether BubbleSets highlight subcommunities). Unlike the hierarchical + * {@link RenderEntityLayer} (one open leaf, uniform colour/radius), + * this is the entire entity set as one graph, each entity coloured by its type + * (hierarchy-aware) and sized by its degree. + * + * All per-node GPU data (positions, radii, colours) lives in one SAB (a + * `FlatGraphBuffer`, delivered via `LayoutCreatedMessage` keyed by {@link + * layoutId}), so the renderer reads it directly and per-node updates are written + * in place. Positions are world coords centred on the origin (no leaf offset). + * This payload therefore carries NO per-node arrays, only the identity + count. + * Edges are worker-built bezier segments ({@link PositionsFrame.beziers}, one per + * link, coloured by the link's own type), drawn separately. + */ +export interface RenderFlatGraph { + /** Matches a {@link "../worker/protocol".LayoutCreatedMessage} clusterId SAB. */ + readonly layoutId: ClusterId; + /** Live node count (<= the SAB capacity); how many instances to render. */ + readonly count: number; + /** + * Per-node Louvain community id, in SAB record order (`-1` = unassigned). Present + * only in `community-force` (the `CommunityLayout` exposes it). The BubbleSets + * layer groups nodes by this and shades each community; the positions it shades + * come live from the SAB. Changes only on a Louvain (re)run, so it rides the + * (rare) structure frame rather than streaming. + */ + readonly communities?: Int32Array; +} + +/** + * A small summary of one rendered highway lane (an aggregated cluster-to-cluster + * bezier), carried in {@link StructureFrame.highwayLanes} and indexed by the + * lane's `laneId` (its index in the worker's visual-edge list, which is also the + * `id` carried on the lane's bezier segments). A clicked highway segment reads + * its `id`, looks up this summary, and can ask the worker for the full link set. + * + * The array is dense over every visual edge (aggregate and individual), so the + * index lines up with `laneId`; an individual (non-aggregate) edge gets the + * placeholder `{ typeId: null, typeLabel: "", count: 0, direction: "both" }`. + */ +export interface HighwayLaneSummary { + /** + * The lane's single link type as a VersionedUrl (a lane is single-type by definition), or + * `null` for a multi-type rollup (the `> maxParallelEdgeTypes` collapse). The main thread + * resolves the type's icon + title from the closed type schema it already holds. + */ + readonly typeId: VersionedUrl | null; + readonly typeLabel: string; + readonly count: number; + readonly direction: "forward" | "reverse" | "both"; +} + +/** + * One rendered type-graph edge (source type to target type via a link type). + * The edge's index in {@link StructureFrame.typeEdges} is the id carried on + * its bezier segments ({@link RenderBezierBuffers.ids}), so a picked or + * hovered edge resolves to the link type naming it. Ids resolve to urls via + * the type-id table (see {@link "./worker/type-graph/protocol"}). + */ +export interface RenderTypeEdge { + readonly source: TypeId; + readonly target: TypeId; + readonly linkTypeId: TypeId; +} + +export interface StructureFrame { + readonly version: number; + readonly mode: VizMode; + /** + * Visible clusters in a stable order. {@link PositionsFrame.clusterPositions} + * is index-aligned with this array until the next structure frame. Empty in + * the flat tiers (see {@link flatGraph}). + */ + readonly clusters: readonly RenderCluster[]; + readonly entityLayers: readonly RenderEntityLayer[]; + /** + * Present in `flat-force` / `community-force`: the whole entity set as one + * individual-entity graph. Mutually exclusive with {@link clusters} / + * {@link entityLayers} (which are empty then). Undefined in `hierarchical-lod`. + */ + readonly flatGraph?: RenderFlatGraph; + /** + * Per-lane summaries for the rendered highways, indexed by `laneId` (the + * lane's index in the worker's visual-edge list). Individual (non-aggregate) + * visual edges occupy their slot with a placeholder so the index aligns. + */ + readonly highwayLanes: readonly HighwayLaneSummary[]; + /** + * Present in the type-graph lifecycle: the rendered edges, indexed by the + * bezier segment ids. Absent in the entity lifecycle (whose flat edges + * carry link EntityIdx ids instead). + */ + readonly typeEdges?: readonly RenderTypeEdge[]; +} + +/** + * Packed edge segments. Hierarchical highways/feeders feed {@link "./render/gpu/bezier-sdf-layer"} + * as cubic Beziers; flat-tier lanes use the same packed p0/p3 endpoints with Deck's `LineLayer`. + * The buffers are parallel: index `i` addresses one segment across all of them. Backing + * `ArrayBuffer`s are transferred (not copied), so a frame carrying them may be consumed only once. + */ +export interface RenderBezierBuffers { + /** + * 8 floats per segment, interleaved: + * `p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y` (four `vec2`s, stride 32 bytes). + */ + readonly positions: Float32Array; + /** 4 bytes per segment: `r, g, b, a` (unsigned, 0..255). */ + readonly colors: Uint8Array; + /** 1 float per segment: stroke width in common/world units. */ + readonly widths: Float32Array; + /** + * 6 floats per segment: two clip circles `(cx, cy, signedRadius)` (stride 24 + * bytes), one per end. Each erases the edge on one side of the circle so it + * ends flush on a bubble wall; `signedRadius > 0` erases inside, `< 0` outside, + * `0` = no clip. (See `ClipCircle` in `edge-geometry.ts`.) + */ + readonly clips: Float32Array; + /** + * 1 u32 per segment, identifying what the segment draws so a picked edge can be + * resolved: + * - Flat tier link: the EntityIdx of the link entity. + * - Hierarchical highway/feeder lane: the lane's `laneId` (its index in the + * worker's visual-edge list, also the index into + * {@link StructureFrame.highwayLanes}). The main thread resolves the full set + * of links via a `QUERY_HIGHWAY_LINKS` round-trip. + * - {@link BEZIER_NO_LINK} when the segment has no resolvable identity. + */ + readonly ids: Uint32Array; + readonly segmentCount: number; +} + +/** Sentinel `RenderBezierBuffers.ids` value for a segment with no single link (a highway). */ +export const BEZIER_NO_LINK = 0xffffffff; + +/** + * A highway label at an edge midpoint: how strongly two clusters are connected. + * Position moves with the layout, so it rides the positions frame. The main + * thread culls these by on-screen chord length to avoid clutter. + */ +export interface RenderEdgeLabel extends Position { + readonly text: string; + /** Degrees (kept upright): rotates the label to ride along its lane. */ + readonly angle: number; + /** + * Label size in world/common units, matching the lane it annotates. + */ + readonly size: number; + /** World-space lane length, for screen-space culling. */ + readonly chord: number; +} + +/** + * A directional arrowhead riding a rendered aggregate highway lane. The worker emits this beside + * the Bezier geometry so the main thread does not need to scan edge topology or re-solve routes. + */ +export interface RenderEdgeArrow extends Position { + readonly kind: "lane" | "endpoint"; + + /** World-space angle in radians, pointing in the link flow direction. */ + readonly angle: number; + /** World/common size, derived from the lane width. */ + readonly size: number; + readonly color: Color; + /** World-space lane length, for screen-space culling. */ + readonly chord: number; +} + +/** + * Packed per-link endpoint arrowheads for the flat tier, one record per + * rendered edge across parallel buffers (index `i` addresses one arrow). + * + * The flat tier emits one arrow per edge — 22k+ on large graphs, every + * positions frame — so the object form ({@link RenderEdgeArrow}) is emitted + * only by the hierarchical tier (a few hundred lane marks): structured-clone + * of tens of thousands of small objects dominated the worker's frame cost + * (measured 38.5s of a 53s settle on a 20k graph). These buffers transfer + * instead, and the render side feeds them to the GPU as binary attributes; + * chord-based fade happens in the shader (zoom is a uniform), never per + * arrow on the CPU. Backing `ArrayBuffer`s are transferred, so a frame + * carrying them may be consumed only once. + */ +export interface RenderEndpointArrowBuffers { + /** 2 floats per arrow: world x, y. */ + readonly positions: Float32Array; + /** 1 float per arrow: world-space angle (radians) in link flow direction. */ + readonly angles: Float32Array; + /** 1 float per arrow: world/common size (from the lane width). */ + readonly sizes: Float32Array; + /** 1 float per arrow: world-space visible chord, for screen-space fade. */ + readonly chords: Float32Array; + /** 4 bytes per arrow: `r, g, b, a` (unsigned, 0..255). */ + readonly colors: Uint8Array; + readonly count: number; +} + +/** + * High-frequency position update, valid only against the current + * {@link StructureFrame}. Sent on every tick while the macro layout is + * unsettled, then it stops. Cluster geometry is bounded by the render budget, + * so it travels by `postMessage`. + */ +export interface PositionsFrame { + readonly version: number; + /** True once every layout (cluster and entity/flat) has settled. */ + readonly settled: boolean; + /** World positions, index-aligned with {@link StructureFrame.clusters}. */ + readonly clusterPositions: Float32Array; + /** Freshly-computed highway/feeder geometry for the current positions. */ + readonly beziers: RenderBezierBuffers; + /** Top connections by count, labelled at their midpoints. */ + readonly edgeLabels: readonly RenderEdgeLabel[]; + /** Direction marks for directed aggregate highway lanes (hierarchical tier). */ + readonly edgeArrows: readonly RenderEdgeArrow[]; + /** Packed per-edge endpoint arrows (flat tier; see the type's rationale). */ + readonly flatArrows?: RenderEndpointArrowBuffers; + /** Per open entity-mode leaf, fan-out feeder endpoints (positional). */ + readonly entityFanOut: readonly RenderEntityFanOut[]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/geometry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/geometry.ts new file mode 100644 index 00000000000..8f7db7a6a63 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/geometry.ts @@ -0,0 +1,124 @@ +/* eslint-disable no-param-reassign */ +/** + * Geometric primitives for layout and hit testing. + * + * Circle is the readonly interface: LOD, rendering, and hit testing + * read positions through it. MutableCircle is the concrete class + * used during layout passes where positions change in place. + */ + +export interface Position { + readonly x: number; + readonly y: number; +} + +export interface MutablePosition { + x: number; + y: number; +} + +export interface Circle extends Position { + readonly radius: number; +} + +export function screenRadius(circle: Circle, zoom: number): number { + return circle.radius * 2 ** zoom; +} + +/** + * Mutable circle for layout passes. Collision resolution runs + * thousands of iterations and must mutate positions in place. + */ +export class MutableCircle implements Circle { + x: number; + y: number; + + radius: number; + + constructor(x?: number, y?: number, radius?: number) { + this.x = x ?? 0; + this.y = y ?? 0; + this.radius = radius ?? 0; + } + + get isOrigin(): boolean { + return this.x === 0 && this.y === 0 && this.radius === 0; + } + + /** + * Push this circle and `other` apart so they no longer overlap. + * Each moves half the overlap distance along the center-to-center axis. + * + * Returns true if the circles were pushed apart. Returns false + * if they don't overlap, or if they're coincident (distance ≈ 0) + * and can't determine a separation direction. The caller must + * handle the coincident case with domain-specific logic. + */ + pushApart(other: MutableCircle, padding: number): boolean { + const dx = other.x - this.x; + const dy = other.y - this.y; + const dist = Math.hypot(dx, dy); + const minDist = this.radius + other.radius + padding; + + if (dist >= minDist || dist <= 0.001) { + return false; + } + + const push = (minDist - dist) / 2; + const nx = dx / dist; + const ny = dy / dist; + this.x -= nx * push; + this.y -= ny * push; + other.x += nx * push; + other.y += ny * push; + + return true; + } +} + +export class Bbox { + readonly left: number; + readonly right: number; + readonly top: number; + readonly bottom: number; + + constructor(left: number, right: number, top: number, bottom: number) { + this.left = left; + this.right = right; + this.top = top; + this.bottom = bottom; + } + + static fromViewport( + centerX: number, + centerY: number, + width: number, + height: number, + zoom: number, + ): Bbox { + const scale = 2 ** zoom; + const halfW = width / 2 / scale; + const halfH = height / 2 / scale; + return new Bbox( + centerX - halfW, + centerX + halfW, + centerY - halfH, + centerY + halfH, + ); + } + + containsPoint(x: number, y: number): boolean { + return ( + x >= this.left && x <= this.right && y >= this.top && y <= this.bottom + ); + } + + intersectsCircle(circle: Circle): boolean { + return ( + circle.x + circle.radius >= this.left && + circle.x - circle.radius <= this.right && + circle.y + circle.radius >= this.top && + circle.y - circle.radius <= this.bottom + ); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container.tsx deleted file mode 100644 index ffd780ba570..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { Box, Stack, useTheme } from "@mui/material"; -import { SigmaContainer } from "@react-sigma/core"; -import { EdgeCurvedArrowProgram } from "@sigma/edge-curve"; -import { createNodeBorderProgram } from "@sigma/node-border"; -import { createNodeImageProgram } from "@sigma/node-image"; -import { MultiDirectedGraph } from "graphology"; -import { memo, useMemo, useRef } from "react"; -import { createNodeCompoundProgram, EdgeArrowProgram } from "sigma/rendering"; - -import { FullScreenButton } from "./graph-container/full-screen-button"; -import { GraphDataLoader } from "./graph-container/graph-data-loader"; -import { PathFinderControl } from "./graph-container/path-finder-control"; -import { SearchControl } from "./graph-container/search-control"; -import { ConfigControl } from "./graph-container/shared/config-control"; -import { FilterControl } from "./graph-container/shared/filter-control"; -import { FullScreenContextProvider } from "./graph-container/shared/full-screen-context"; -import { GraphContextProvider } from "./graph-container/shared/graph-context"; -import { ZoomControl } from "./graph-container/zoom-control"; - -import type { GraphLoaderProps } from "./graph-container/graph-data-loader"; -import type { - DynamicNodeSizing, - GraphVizConfig, - StaticNodeSizing, -} from "./graph-container/shared/config-control"; -import type { GraphVizFilters } from "./graph-container/shared/filter-control"; - -export type GraphContainerProps< - NodeSizing extends DynamicNodeSizing | StaticNodeSizing, -> = Omit & { - defaultConfig: GraphVizConfig; - defaultFilters?: GraphVizFilters; - /** - * When toggling fullscreen, whether: - * 1. the whole document will be sent into fullscreen, or - * 2. only the graph container will be sent into fullscreen - * - * The latter may be suitable when the graph is part of a larger layout. - */ - fullScreenMode?: "document" | "element"; - onRender?: () => void; -}; - -const borderRadii = { - borderBottomLeftRadius: "8px", - borderBottomRightRadius: "8px", -}; - -const bordered = createNodeBorderProgram({ - borders: [ - { - size: { value: 2, mode: "pixels" }, - color: { attribute: "borderColor" }, - }, - { size: { fill: true }, color: { attribute: "color" } }, - ], -}); - -const NodePictogramCustomProgram = createNodeImageProgram({ - padding: 0.35, - drawingMode: "color", - colorAttribute: "iconColor", - objectFit: "contain", -}); - -const icon = createNodeCompoundProgram([bordered, NodePictogramCustomProgram]); - -export const GraphContainer = memo( - ({ - defaultConfig, - defaultFilters, - edges, - fullScreenMode, - nodes, - onEdgeClick, - onNodeSecondClick, - onRender, - }: GraphContainerProps) => { - const containerRef = useRef(null); - - const { palette } = useTheme(); - - /** - * These are the settings that won't change in the lifetime of the graph - * (unless code is changed to make onEdgeClick or palette dynamic). - * - * If you need to make some settings dependent on potentially fast-changing state (e.g. viz config), - * put them in {@link useSetDrawSettings} and {@link useEventHandlers}. - */ - const settings = useMemo( - () => ({ - defaultNodeType: "bordered", - enableEdgeEvents: !!onEdgeClick, - /** - * Edge labels are only shown on hover, controlled in the event handlers. - */ - edgeLabelColor: { color: "rgba(80, 80, 80, 1)" }, - edgeLabelFont: `"Inter", "Helvetica", "sans-serif"`, - edgeLabelSize: 11, - edgeLabelWeight: "600", - edgeProgramClasses: { - arrow: EdgeArrowProgram, - curved: EdgeCurvedArrowProgram, - }, - labelFont: `"Inter", "Helvetica", "sans-serif"`, - labelSize: 12, - labelColor: { color: palette.black }, - /** - * Controls how many labels will be rendered in the given visible area. - * Higher density = more labels - * - * Labels are prioritised for display by node size. - */ - labelDensity: 1, - nodeProgramClasses: { - bordered, - icon, - }, - renderEdgeLabels: true, - zoomDuration: 0.05, - zoomingRatio: 1.25, - zIndex: true, - }), - [onEdgeClick, palette], - ); - - return ( - - - - - - - - - - - - - - - - - - ); - }, -); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/full-screen-button.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/full-screen-button.tsx deleted file mode 100644 index 382943d9eb4..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/full-screen-button.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { - ArrowDownLeftAndArrowUpRightToCenterIcon, - ArrowUpRightAndArrowDownLeftFromCenterIcon, -} from "@hashintel/design-system"; - -import { GrayToBlueIconButton } from "../../gray-to-blue-icon-button"; -import { useFullScreen } from "./shared/full-screen-context"; - -export const FullScreenButton = () => { - const { isFullScreen, toggleFullScreen } = useFullScreen(); - - if (!document.fullscreenEnabled) { - return null; - } - - return ( - - {isFullScreen ? ( - - ) : ( - - )} - - ); -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/graph-data-loader.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/graph-data-loader.tsx deleted file mode 100644 index 0e400d973b6..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/graph-data-loader.tsx +++ /dev/null @@ -1,441 +0,0 @@ -import { useLoadGraph, useSigma } from "@react-sigma/core"; -import { MultiDirectedGraph } from "graphology"; -import { memo, useEffect } from "react"; - -import { customColors } from "@hashintel/design-system/theme"; - -import { useGraphContext } from "./shared/graph-context"; -import { useLayout } from "./use-layout"; - -import type { GraphVizEdge, GraphVizNode } from "./shared/types"; -import type { RegisterEventsArgs } from "./shared/use-event-handlers"; - -export type GraphLoaderProps = { - edges: GraphVizEdge[]; - nodes: GraphVizNode[]; -} & Pick; - -export const GraphDataLoader = memo(({ edges, nodes }: GraphLoaderProps) => { - /** - * Hooks provided by the react-sigma library to simplify working with the sigma instance. - */ - const loadGraph = useLoadGraph(); - const sigma = useSigma(); - - const { config, filters } = useGraphContext(); - - /** - * Custom hooks for laying out the graph. - */ - const layout = useLayout(); - - useEffect(() => { - const graph = new MultiDirectedGraph(); - - const seenNodeIds = new Set(); - - for (const [index, node] of nodes.entries()) { - if ( - node.nodeTypeId && - filters.includeByNodeTypeId?.[node.nodeTypeId] === false - ) { - continue; - } - - const hasUrlImage = - !!node.icon?.startsWith("http://") || - !!node.icon?.startsWith("https://") || - node.icon?.startsWith("/"); - - const imageUrl = node.icon?.startsWith("/") - ? new URL(node.icon, window.location.origin).href - : node.icon; - - graph.addNode(node.nodeId, { - borderColor: node.borderColor ?? node.color, - /** - * This color may be overwritten in the reducer {@link useSetDrawSettings} - * We don't want this effect depending on the color options, - * because we don't want to lay out the graph again if the color of nodes change. - */ - color: node.color, - x: index % 20, - y: Math.floor(index / 20), - iconColor: customColors.gray[10], - image: imageUrl, - label: node.label, - nodeId: node.nodeId, - nodeTypeId: node.nodeTypeId, - /** - * We'll set this to 0 for now, and update it later when we have processed all the edges and sized the nodes. - */ - significance: 0, - size: - config.nodeSizing.mode === "byEdgeCount" - ? config.nodeSizing.min - : node.size, - type: hasUrlImage ? "icon" : "bordered", - } satisfies GraphVizNode & { - x: number; - y: number; - iconColor: string; - image?: string | null; - significance: number; - type: "icon" | "bordered"; - }); - - seenNodeIds.add(node.nodeId); - } - - /** - * We need to resize edges as we discover - * (1) additional 'same source/target, same type' edges to aggregate under them - * (2) additional edges pointing to them - * - * We'll do all that before adding it to the sigma graph to save repeatedly retrieving and updating it there. - */ - const aggregateEdgesById: Record< - string, - GraphVizEdge & { - aggregatedEdgeCount: number; - aggregatedIncomingEdgeCount: number; - color: string; - significance: number; - type: string; - } - > = {}; - - /** - * We take into account how many edges point to an edge as part of measuring its significance and thus size. - * - * Note that many graphs will not have any links to a link, this is currently expected to be a minority of cases. - */ - const maybeEdgeIdToNumberOfIncomingEdges: Record = {}; - - /** - * We aggregate edges of the same type between the same nodes into a single drawn edge. - * This record keeps track of the edges aggregated under each aggregateEdgeId - * – we need this to assign additional significance to edges that have more edges pointing to them. - * We could alternatively offer the option to show them in parallel, which would work for a small number. - */ - const aggregateEdgeToIndividualEdgeIds: Record = {}; - - for (const edge of edges) { - const { source, target } = edge; - - if (!seenNodeIds.has(target)) { - /** - * The target might be a node that has been filtered out, or it might itself be an edge. - * - * We'll assume it's an edge for now. When we're adding significance to edges based on their incoming edges, - * we will skip over any ids in this object that we haven't encountered an edge for. - * - * We don't know if we've encountered the target edge yet, if it is one, so we need to deal with these counts afterward. - */ - maybeEdgeIdToNumberOfIncomingEdges[target] ??= 0; - maybeEdgeIdToNumberOfIncomingEdges[target]++; - - /** - * Don't do anything else for now because we don't want to draw this edge in the visualization. - * - * This assumes that we don't need to handle edges that point to THIS edge (i.e. edge -> edge -> edge) - */ - continue; - } - - if (!seenNodeIds.has(source)) { - /** - * The source of this edge is either a node that has been filtered out, or an edge. - * We don't do anything with it in either case. We could offer the option for this to have some significance. - */ - continue; - } - - const aggregateEdgeId = `${source}-${target}`; - aggregateEdgeToIndividualEdgeIds[aggregateEdgeId] ??= []; - aggregateEdgeToIndividualEdgeIds[aggregateEdgeId].push(edge.edgeId); - - if (aggregateEdgesById[aggregateEdgeId]) { - aggregateEdgesById[aggregateEdgeId].aggregatedEdgeCount++; - aggregateEdgesById[aggregateEdgeId].significance++; - } else { - aggregateEdgesById[aggregateEdgeId] = { - ...edge, - edgeId: aggregateEdgeId, - aggregatedEdgeCount: 1, - aggregatedIncomingEdgeCount: 0, - color: "rgba(180, 180, 180, 0.8)", - significance: 1, - type: "curved", - }; - } - } - - /** - * The 'significance' of an edge will be the total of the number of edges it aggregates, - * plus the number of edges that point to those edges (minus 1 – see calculation below). - */ - let highestSignificance = -Infinity; - let lowestSignificance = Infinity; - - /** - * Now we've created all the edges, we can update the significance of the aggregated edges, - * based on how many edges point to them. - */ - for (const aggregatedEdge of Object.values(aggregateEdgesById)) { - const individualEdges = - aggregateEdgeToIndividualEdgeIds[aggregatedEdge.edgeId]; - - for (const individualEdgeId of individualEdges ?? []) { - const incomingEdges = - maybeEdgeIdToNumberOfIncomingEdges[individualEdgeId]; - - let significance = aggregatedEdge.significance; - if (incomingEdges) { - /** - * We -1 to discount the first of the incoming edges pointing to this edge (which is one member of the aggregateEdge): - * - we want to count the number of edges aggregated, plus the number of edges that point to each of those - * - we already added 1 significance for each of the edges aggregated - * - if we add 1 for each edge pointing to the edge, an individual edge with one incoming edge would have a significance of 2, - * and each further edge pointing to it would only increase the significance by 1. - * - this overcounts the significance of the first edge pointing to that edge. - */ - significance = - aggregatedEdge.significance + - (incomingEdges > 0 ? incomingEdges - 1 : 0); - - aggregatedEdge.significance = significance; - } - - if (highestSignificance < significance) { - highestSignificance = significance; - } - if (lowestSignificance > significance) { - lowestSignificance = significance; - } - - aggregatedEdge.aggregatedIncomingEdgeCount += incomingEdges ?? 0; - } - } - - /** - * Now we know the range of edge counts and significance, we can set the size of the edges. - */ - const edgeSignificanceRange = highestSignificance - lowestSignificance; - - const minEdgeSize = config.edgeSizing.min; - const maxEdgeSize = config.edgeSizing.max; - const maxEdgeSizeIncrease = maxEdgeSize - minEdgeSize; - - const nodeIdToTotalEdgeSignificance: Record< - string, - { In: number; Out: number; All: number } - > = {}; - - let edgeGeometricSizeFactor: number | undefined; - if (config.edgeSizing.scale === "Geometric") { - const edgeSignificanceRatio = highestSignificance / lowestSignificance; - - edgeGeometricSizeFactor = - edgeSignificanceRatio === 1 - ? 1 - : (maxEdgeSize / minEdgeSize) ** - (1 / Math.log(edgeSignificanceRatio)); - } - - for (const aggregateEdge of Object.values(aggregateEdgesById)) { - const { significance, source, target } = aggregateEdge; - - let size: number | undefined; - if (config.edgeSizing.scale === "Linear") { - const relativeSignificance = - (significance - lowestSignificance) / edgeSignificanceRange; - size = Math.floor( - minEdgeSize + relativeSignificance * maxEdgeSizeIncrease, - ); - } else { - if (!edgeGeometricSizeFactor) { - throw new Error("Expected edgeGeometricSizeFactor to be defined"); - } - const normalizedSignificance = significance / lowestSignificance; - size = Math.max( - minEdgeSize, - Math.floor( - minEdgeSize * - edgeGeometricSizeFactor ** Math.log(normalizedSignificance), - ), - ); - } - - graph.addEdge(source, target, { - ...aggregateEdge, - size, - }); - - nodeIdToTotalEdgeSignificance[source] ??= { In: 0, Out: 0, All: 0 }; - nodeIdToTotalEdgeSignificance[source].Out += significance; - nodeIdToTotalEdgeSignificance[source].All += significance; - - nodeIdToTotalEdgeSignificance[target] ??= { In: 0, Out: 0, All: 0 }; - nodeIdToTotalEdgeSignificance[target].In += significance; - nodeIdToTotalEdgeSignificance[target].All += significance; - } - - if (config.nodeSizing.mode === "byEdgeCount") { - const countKey = config.nodeSizing.countEdges; - - const countValues = Object.values(nodeIdToTotalEdgeSignificance).map( - (counts) => counts[countKey], - ); - - const lowestCount = Math.min(...countValues); - const highestCount = Math.max(...countValues); - - const range = highestCount - lowestCount; - - if (range === 0 && countValues.length === seenNodeIds.size) { - /** - * All nodes have the same count, so we don't need to resize any. - */ - } else { - const maxNodeSize = config.nodeSizing.max; - const minNodeSize = config.nodeSizing.min; - - if (config.nodeSizing.scale === "Percentile") { - /** - * We have a different loop for Percentile scaling because we need to go through the counts in order, - * rather than go through the nodes in whatever order they happened to have been assigned in. - */ - const numberOfBuckets = 10; - const percentileBucketSize = Math.ceil( - countValues.length / numberOfBuckets, - ); - const nodeIdAndSignificances = Object.entries( - nodeIdToTotalEdgeSignificance, - ).map(([nodeId, counts]) => ({ - nodeId, - significance: counts[countKey], - })); - - nodeIdAndSignificances.sort( - (a, b) => a.significance - b.significance, - ); - const percentileSizeIncrement = - (maxNodeSize - minNodeSize) / (numberOfBuckets - 1); - - for (let i = 0; i < nodeIdAndSignificances.length; i++) { - const { nodeId, significance } = nodeIdAndSignificances[i]!; - graph.setNodeAttribute(nodeId, "significance", significance); - - const size = - minNodeSize + - Math.floor(i / percentileBucketSize) * percentileSizeIncrement; - - graph.setNodeAttribute(nodeId, "size", size); - } - } else { - /** - * Only bother to calculate these if we're using logarithmic or geometric scaling. - */ - let logMin: number | undefined; - let logRange: number | undefined; - let geometricSizeFactor: number | undefined; - - if (config.nodeSizing.scale === "Logarithmic") { - logMin = Math.log(lowestCount); - const logMax = Math.log(highestCount); - logRange = logMax - logMin; - } - - if (config.nodeSizing.scale === "Geometric") { - const countRatio = highestCount / lowestCount; - geometricSizeFactor = - countRatio === 1 - ? 1 - : (maxNodeSize / minNodeSize) ** (1 / Math.log(countRatio)); - } - - for (const [nodeId, significanceCounts] of Object.entries( - nodeIdToTotalEdgeSignificance, - )) { - const aggregatedEdgeSignificance = significanceCounts[countKey]; - graph.setNodeAttribute( - nodeId, - "significance", - aggregatedEdgeSignificance, - ); - - let size; - - const maxSizeIncrease = maxNodeSize - minNodeSize; - - switch (config.nodeSizing.scale) { - case "Linear": { - const relativeEdgeCount = - (aggregatedEdgeSignificance - lowestCount) / range; - size = minNodeSize + relativeEdgeCount * maxSizeIncrease; - break; - } - case "Logarithmic": { - if (logMin === undefined || logRange === undefined) { - throw new Error( - "Logarithmic scaling requires logMin and logRange to be defined", - ); - } - const logEdgeCount = Math.log(aggregatedEdgeSignificance); - const normalizedLogCount = (logEdgeCount - logMin) / logRange; - size = Math.floor( - minNodeSize + normalizedLogCount * maxSizeIncrease, - ); - - break; - } - case "Geometric": { - if (geometricSizeFactor === undefined) { - throw new Error( - "Geometric scaling requires geometricSizeFactor to be defined", - ); - } - - const normalizedCount = - aggregatedEdgeSignificance / lowestCount; - size = Math.floor( - minNodeSize * - geometricSizeFactor ** Math.log(normalizedCount), - ); - - break; - } - } - - graph.setNodeAttribute(nodeId, "size", size); - } - } - } - } - - loadGraph(graph); - - layout(); - }, [ - config.edgeSizing, - /** - * These are the config options that affect the layout and/or what nodes are included. - * We exclude config.nodeHighlighting as it doesn't affect the graph, only what is highlighted on hover/click. - * Re-rendering the graph without needing to lay it out again is handled in {@link ConfigControl} - */ - config.nodeSizing, - /** - * We don't include filters.colorByNodeTypeId here because it doesn't affect the layout of the graph. - * {@link FilterControl} handles re-rendering the graph when the colors change. - */ - filters.includeByNodeTypeId, - layout, - loadGraph, - sigma, - nodes, - edges, - ]); - - return null; -}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/path-finder-control.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/path-finder-control.tsx deleted file mode 100644 index da0f2b5d27d..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/path-finder-control.tsx +++ /dev/null @@ -1,674 +0,0 @@ -import { Box, Stack, Switch } from "@mui/material"; -import { useSigma } from "@react-sigma/core"; -import { MultiUndirectedGraph } from "graphology"; -import { dijkstra, edgePathFromNodePath } from "graphology-shortest-path"; -import { useCallback, useEffect, useMemo, useState } from "react"; - -import { IconDiagramNestedLight, Select } from "@hashintel/design-system"; -import { formatNumber } from "@local/hash-isomorphic-utils/format-number"; - -import { MenuItem } from "../../../../shared/ui/menu-item"; -import { GrayToBlueIconButton } from "../../gray-to-blue-icon-button"; -import { pathDirections, simplePathSorts } from "./path-finder-control/types"; -import { - ControlPanel, - ControlSectionContainer, - ItemLabel, -} from "./shared/control-components"; -import { useGraphContext } from "./shared/graph-context"; -import { IntegerInput } from "./shared/integer-input"; -import { SimpleAutocomplete } from "./shared/simple-autocomplete"; -import { selectSx } from "./shared/styles"; - -import type { - GenerateSimplePathsRequestMessage, - GenerateSimplePathsResultMessage, - NodeData, - Path, - PathDirection, - SimplePathSort, -} from "./path-finder-control/types"; -import type { GraphVizNode } from "./shared/types"; -import type { FunctionComponent } from "react"; - -type TypeData = { - label: string; - typeId: string; - valueForSelector: string; -}; - -type TypesByTypeId = { [nodeTypeId: string]: TypeData }; -type NodesByTypeId = { [nodeTypeId: string]: NodeData[] }; - -const PathTerminusSelector = ({ - label, - node, - nodeOptions, - setNode, - setType, - suffixKey, - type, - typeOptions, -}: { - label: "Start at" | "End at" | "Go via"; - node: NodeData | null; - nodeOptions: NodeData[]; - setNode: (node: NodeData | null) => void; - setType: (type: TypeData | null) => void; - suffixKey?: "shortestPathTo" | "shortestPathVia"; - type: TypeData | null; - typeOptions: TypeData[]; -}) => { - const lowercasedLabel = label.toLowerCase(); - - return ( - - - {typeOptions.length > 1 && ( - - )} - - - - ); -}; - -const generatePathKey = ({ - from, - to, - via, -}: { - from: string; - to: string; - via?: string; -}) => `${from}-${to}-${via}`; - -const PathFinderPanel: FunctionComponent<{ - nodes: GraphVizNode[]; - open: boolean; - onClose: () => void; -}> = ({ nodes, open, onClose }) => { - const { config, filters, setGraphState, graphContainerRef } = - useGraphContext(); - - const sigma = useSigma(); - - const { visibleNodesByTypeId, visibleTypesByTypeId, visibleNodeIds } = - useMemo(() => { - const { includeByNodeTypeId } = filters; - - const visibleNodes: NodesByTypeId = {}; - const visibleTypes: TypesByTypeId = {}; - const visibleIds = new Set(); - - for (const node of nodes) { - const { nodeTypeId = "none", nodeTypeLabel = "No type", nodeId } = node; - - if (node.nodeTypeId && !includeByNodeTypeId?.[node.nodeTypeId]) { - continue; - } - - visibleIds.add(nodeId); - - if (nodeTypeId && nodeTypeLabel) { - visibleTypes[nodeTypeId] ??= { - label: nodeTypeLabel, - typeId: nodeTypeId, - valueForSelector: nodeTypeId, - }; - - visibleNodes[nodeTypeId] ??= []; - visibleNodes[nodeTypeId].push({ ...node, valueForSelector: nodeId }); - } - } - - return { - visibleNodeIds: visibleIds, - visibleNodesByTypeId: visibleNodes, - visibleTypesByTypeId: visibleTypes, - }; - }, [filters, nodes]); - - const hasMultipleTypes = Object.keys(visibleTypesByTypeId).length > 1; - const firstType = Object.values(visibleTypesByTypeId)[0] ?? null; - - const [startNode, setStartNode] = useState(null); - const [startType, setStartType] = useState( - config.pathfinding?.startTypeId - ? (visibleTypesByTypeId[config.pathfinding.startTypeId] ?? null) - : hasMultipleTypes - ? null - : firstType, - ); - const [endNode, setEndNode] = useState(null); - const [endType, setEndType] = useState( - hasMultipleTypes ? null : firstType, - ); - - const [viaNode, setViaNode] = useState(null); - const [viaType, setViaType] = useState( - hasMultipleTypes ? null : firstType, - ); - - useEffect(() => { - if (!visibleTypesByTypeId[startType?.typeId ?? ""]) { - setStartType(null); - setStartNode(null); - } else if (!visibleNodeIds.has(startNode?.nodeId ?? "")) { - setStartNode(null); - } - - if (!visibleTypesByTypeId[endType?.typeId ?? ""]) { - setEndType(null); - setEndNode(null); - } else if (!visibleNodeIds.has(endNode?.nodeId ?? "")) { - setEndNode(null); - } - - if (!visibleTypesByTypeId[viaType?.typeId ?? ""]) { - setViaType(null); - setViaNode(null); - } else if (!visibleNodeIds.has(viaNode?.nodeId ?? "")) { - setViaNode(null); - } - }, [ - startNode, - startType, - endType, - endNode, - viaType, - viaNode, - visibleTypesByTypeId, - visibleNodeIds, - ]); - - const [maxSimplePathDepth, setMaxSimplePathDepth] = useState(3); - const [allowRepeatedNodeTypes, setAllowRepeatedNodeTypes] = useState(true); - const [pathDirection, setPathDirection] = - useState("Outgoing only"); - const [simplePathSort, setSimplePathSort] = - useState("Significance"); - - /** - * The Sigma graph is directed (edges point from a link's left entity to its - * right entity). When the user chooses to ignore direction we run the - * shortest-path queries against an undirected copy so links can be traversed - * either way. `nodes` is included as a dependency because it changes whenever - * the underlying graph data is reloaded. - */ - const pathfindingGraph = useMemo(() => { - const graph = sigma.getGraph(); - - if (pathDirection === "Outgoing only") { - return graph; - } - - const undirectedGraph = new MultiUndirectedGraph(); - graph.forEachNode((node, attributes) => { - undirectedGraph.addNode(node, attributes); - }); - graph.forEachEdge((edge, attributes, source, target) => { - /** - * Preserve the original edge keys so that an edge path derived from this - * graph still references the edge ids Sigma uses for rendering/highlighting. - */ - undirectedGraph.addEdgeWithKey(edge, source, target, attributes); - }); - - return undirectedGraph; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sigma, pathDirection, nodes]); - - const [simplePaths, setSimplePaths] = useState([]); - const [selectedSimplePath, _setSelectedSimplePath] = useState( - null, - ); - const [waitingSimplePathResult, setWaitingSimplePathResult] = useState(false); - - const highlightPath = useCallback( - (nodePath: string[] | null) => { - if (!nodePath || nodePath.length === 0) { - setGraphState("highlightedEdgePath", null); - sigma.refresh({ skipIndexation: true }); - return; - } - - const edgePath = edgePathFromNodePath(pathfindingGraph, nodePath); - - setGraphState("highlightedEdgePath", edgePath); - sigma.refresh({ skipIndexation: true }); - }, - [pathfindingGraph, sigma, setGraphState], - ); - - const setSelectedSimplePath = useCallback( - (path: Path | null) => { - _setSelectedSimplePath(path); - if (path) { - highlightPath(path.nodePath); - } - }, - [highlightPath], - ); - - const [worker, setWorker] = useState(null); - - useEffect(() => { - const webWorker = new Worker( - new URL("./path-finder-control/worker.ts", import.meta.url), - ); - setWorker(webWorker); - - return () => { - webWorker.terminate(); - }; - }, []); - - useEffect(() => { - if (!worker) { - return; - } - - worker.onmessage = ({ data }) => { - if ( - "type" in data && - data.type === - ("generateSimplePathsResult" satisfies GenerateSimplePathsResultMessage["type"]) - ) { - setSimplePaths((data as GenerateSimplePathsResultMessage).result); - setWaitingSimplePathResult(false); - } - }; - }, [worker]); - - useEffect(() => { - if (!startNode || !endNode) { - setSimplePaths([]); - return; - } - - setWaitingSimplePathResult(true); - const request: GenerateSimplePathsRequestMessage = { - type: "generateSimplePaths", - params: { - allowRepeatedNodeTypes, - endNode, - graph: sigma.getGraph().export(), - maxSimplePathDepth, - pathDirection, - simplePathSort, - startNode, - viaNode, - }, - }; - - if (!worker) { - throw new Error("No worker available"); - } - worker.postMessage(request); - }, [ - allowRepeatedNodeTypes, - endNode, - maxSimplePathDepth, - pathDirection, - simplePathSort, - sigma, - startNode, - viaNode, - worker, - ]); - - const sortedTypes = useMemo( - () => - Object.values(visibleTypesByTypeId).sort((a, b) => - a.label.localeCompare(b.label), - ), - [visibleTypesByTypeId], - ); - - const { endNodeOptions, viaNodeOptions } = useMemo(() => { - const endNodes = endType - ? (visibleNodesByTypeId[endType.typeId] ?? []) - : []; - const viaNodes = viaType - ? (visibleNodesByTypeId[viaType.typeId] ?? []) - : []; - - const shortestPathByKey: { [key: string]: string[] | null } = {}; - - const endOptions: NodeData[] = []; - const viaOptions: NodeData[] = []; - - if (startNode) { - for (const originalNode of endNodes) { - const node = { ...originalNode }; - - let shortestPath: string[] | null = null; - if (viaNode) { - const firstPart = dijkstra.bidirectional( - pathfindingGraph, - startNode.nodeId, - node.nodeId, - ) as string[] | null; // library types are wrong, might be null - const secondPart = dijkstra.bidirectional( - pathfindingGraph, - node.nodeId, - viaNode.nodeId, - ) as string[] | null; // library types are wrong, might be null - shortestPath = - firstPart && secondPart - ? firstPart.concat(secondPart.slice(1)) - : null; - - const pathKey = generatePathKey({ - from: startNode.nodeId, - to: viaNode.nodeId, - via: node.nodeId, - }); - shortestPathByKey[pathKey] = shortestPath; - - if (shortestPath) { - node.nodePathToHighlight = shortestPath; - } - } else { - shortestPath = dijkstra.bidirectional( - pathfindingGraph, - startNode.nodeId, - node.nodeId, - ); - } - - let pathLength: string; - if (shortestPath && shortestPath.length > 0) { - node.nodePathToHighlight = shortestPath; - pathLength = (shortestPath.length - 1).toString(); - } else { - pathLength = "None"; - node.disabled = true; - } - - node.shortestPathTo = `(${pathLength})`; - endOptions.push(node); - } - - if (endNode) { - for (const originalNode of viaNodes) { - const node = { ...originalNode }; - - let shortestPath: string[] | null | undefined = - shortestPathByKey[ - generatePathKey({ - from: startNode.nodeId, - to: endNode.nodeId, - via: node.nodeId, - }) - ]; - - if (!shortestPath) { - const firstPart = dijkstra.bidirectional( - pathfindingGraph, - startNode.nodeId, - node.nodeId, - ) as string[] | null; - const secondPart = dijkstra.bidirectional( - pathfindingGraph, - node.nodeId, - endNode.nodeId, - ) as string[] | null; - shortestPath = - firstPart && secondPart - ? firstPart.concat(secondPart.slice(1)) - : null; - } - - let pathLength: string; - if (shortestPath && shortestPath.length > 0) { - node.nodePathToHighlight = shortestPath; - pathLength = (shortestPath.length - 1).toString(); - } else { - pathLength = "None"; - node.disabled = true; - } - - node.shortestPathVia = `(${pathLength})`; - viaOptions.push(node); - } - } - } - - return { endNodeOptions: endOptions, viaNodeOptions: viaOptions }; - }, [ - endNode, - endType, - pathfindingGraph, - startNode, - viaNode, - viaType, - visibleNodesByTypeId, - ]); - - const selectStartType = (type: TypeData | null) => { - if (type !== startType) { - setStartNode(null); - } - setStartType(type); - }; - - const selectEndType = (type: TypeData | null) => { - if (type !== endType) { - setEndNode(null); - } - setEndType(type); - }; - - const selectViaType = (type: TypeData | null) => { - if (type !== viaType) { - setViaNode(null); - } - setViaType(type); - }; - - const selectStartNode = (newStartNode: NodeData | null) => { - if (newStartNode && endNode) { - const shortestPath = dijkstra.bidirectional( - pathfindingGraph, - newStartNode.nodeId, - endNode.nodeId, - ); - highlightPath(shortestPath); - } - setStartNode(newStartNode); - }; - - const selectEndNode = (newEndNode: NodeData | null) => { - highlightPath(newEndNode?.nodePathToHighlight ?? null); - setSelectedSimplePath(null); - setEndNode(newEndNode); - }; - - const selectViaNode = (newViaNode: NodeData | null) => { - highlightPath(newViaNode?.nodePathToHighlight ?? null); - setSelectedSimplePath(null); - setViaNode(newViaNode); - }; - - return ( - - - - - {!config.pathfinding?.hideVia && ( - - )} - - - - - - - Max depth - - { - setMaxSimplePathDepth(newValue); - setSelectedSimplePath(null); - }} - width={80} - /> - - - - Direction - - - - - - Sort - - - - - - Repeat types - - { - setAllowRepeatedNodeTypes(!allowRepeatedNodeTypes); - setSelectedSimplePath(null); - }} - size="small" - /> - - - - - ); -}; - -export const PathFinderControl = ({ nodes }: { nodes: GraphVizNode[] }) => { - const { pathFinderPanelOpen, setPathFinderPanelOpen } = useGraphContext(); - - return ( - <> - setPathFinderPanelOpen(false)} - /> - setPathFinderPanelOpen(true)} - sx={{ position: "absolute", top: 8, left: 8 }} - > - - - - ); -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/path-finder-control/types.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/path-finder-control/types.ts deleted file mode 100644 index 94402d572c4..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/path-finder-control/types.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { GraphVizNode } from "../shared/types"; -import type { SerializedGraph } from "graphology-types"; - -export type NodeData = GraphVizNode & { - disabled?: boolean; - nodePathToHighlight?: string[]; - shortestPathTo?: string; - shortestPathVia?: string; - valueForSelector: string; -}; - -export type Path = { - nodePath: string[]; - label: string; - significance?: number; - valueForSelector: string; -}; - -export const simplePathSorts = [ - "Alphabetical", - "Length", - "Significance", -] as const; - -export type SimplePathSort = (typeof simplePathSorts)[number]; - -export const pathDirections = ["Outgoing only", "Undirected"] as const; - -export type PathDirection = (typeof pathDirections)[number]; - -export type GenerateSimplePathsParams = { - allowRepeatedNodeTypes: boolean; - endNode: NodeData; - graph: SerializedGraph; - maxSimplePathDepth: number; - pathDirection: PathDirection; - simplePathSort: SimplePathSort; - startNode: NodeData; - viaNode: NodeData | null; -}; - -export type GenerateSimplePathsRequestMessage = { - type: "generateSimplePaths"; - params: GenerateSimplePathsParams; -}; - -export type GenerateSimplePathsResultMessage = { - type: "generateSimplePathsResult"; - result: Path[]; -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/path-finder-control/worker.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/path-finder-control/worker.ts deleted file mode 100644 index afba8e72126..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/path-finder-control/worker.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { MultiDirectedGraph, MultiUndirectedGraph } from "graphology"; -import { edgePathFromNodePath } from "graphology-shortest-path"; -import { allSimplePaths } from "graphology-simple-path"; - -import { generateUuid } from "@local/hash-isomorphic-utils/generate-uuid"; -import { sleep } from "@local/hash-isomorphic-utils/sleep"; - -import type { - GenerateSimplePathsParams, - GenerateSimplePathsRequestMessage, - GenerateSimplePathsResultMessage, - Path, -} from "./types"; - -let activeRequestId: string | null = null; - -const isCancelled = async (requestId: string) => { - await sleep(0); - return requestId !== activeRequestId; -}; - -const generateSimplePaths = async ({ - allowRepeatedNodeTypes, - endNode, - graph: serializedGraph, - maxSimplePathDepth, - pathDirection, - requestId, - simplePathSort, - startNode, - viaNode, -}: GenerateSimplePathsParams & { requestId: string }) => { - const directedGraph = new MultiDirectedGraph(); - directedGraph.import(serializedGraph); - - /** - * The serialized graph is always directed (edges point from a link's left - * entity to its right entity). When the user asks to ignore direction we - * rebuild an undirected copy so that traversal can follow links either way. - */ - let graph: MultiDirectedGraph | MultiUndirectedGraph = directedGraph; - if (pathDirection === "Undirected") { - const undirectedGraph = new MultiUndirectedGraph(); - directedGraph.forEachNode((node, attributes) => { - undirectedGraph.addNode(node, attributes); - }); - directedGraph.forEachEdge((_edge, attributes, source, target) => { - undirectedGraph.addEdge(source, target, attributes); - }); - graph = undirectedGraph; - } - - const unfilteredSimplePaths = allSimplePaths( - graph, - startNode.nodeId, - endNode.nodeId, - { maxDepth: maxSimplePathDepth }, - ); - - const pathOptions: Path[] = []; - - // eslint-disable-next-line no-labels - pathsLoop: for (const path of unfilteredSimplePaths) { - if (await isCancelled(requestId)) { - return "cancelled"; - } - - const seenTypes = new Set(); - - const labelParts: string[] = []; - - let hasGoneVia = !viaNode; - - for (const nodeId of path) { - if (viaNode && nodeId === viaNode.nodeId) { - hasGoneVia = true; - } - - const nodeData = graph.getNodeAttributes(nodeId); - - if (!allowRepeatedNodeTypes && seenTypes.has(nodeData.nodeTypeId)) { - /** - * Excluding paths which travel through the same type of node twice is a simple way of path options down. - */ - // eslint-disable-next-line no-labels - continue pathsLoop; - } - - labelParts.push(nodeData.label); - - seenTypes.add(nodeData.nodeTypeId); - } - - if (hasGoneVia) { - let totalSignificance = 0; - - let label: string; - if (simplePathSort === "Significance") { - const edges = edgePathFromNodePath(graph, path); - for (const [index, edge] of edges.entries()) { - const edgeData = graph.getEdgeAttributes(edge); - totalSignificance += edgeData.significance ?? 0; - labelParts[index] = - `${labelParts[index]} [${edgeData.significance ?? 0}]—> `; - } - label = `[${totalSignificance}] ${labelParts.join("")}`; - } else if (simplePathSort === "Length") { - label = `(${path.length - 1}) ${labelParts.join(" —> ")}`; - } else { - label = labelParts.join(" —> "); - } - - pathOptions.push({ - label, - valueForSelector: label, - nodePath: path, - significance: totalSignificance, - }); - } - } - - return pathOptions.sort((a, b) => { - switch (simplePathSort) { - case "Alphabetical": - return a.label.localeCompare(b.label); - case "Length": - return a.nodePath.length - b.nodePath.length; - case "Significance": - return (b.significance ?? 0) - (a.significance ?? 0); - } - throw new Error(`Unknown simple path sort: ${simplePathSort}`); - }); -}; - -// eslint-disable-next-line no-restricted-globals -self.onmessage = async ({ data }) => { - if ( - "type" in data && - data.type === - ("generateSimplePaths" satisfies GenerateSimplePathsRequestMessage["type"]) - ) { - const { params } = data as GenerateSimplePathsRequestMessage; - - const requestId = generateUuid(); - activeRequestId = requestId; - - const result = await generateSimplePaths({ ...params, requestId }); - - if (result === "cancelled") { - // eslint-disable-next-line no-console - console.info( - `Pathfinding request ${requestId} cancelled, superseded by a subsequent request`, - ); - return; - } - - // eslint-disable-next-line no-restricted-globals - self.postMessage({ - type: "generateSimplePathsResult", - result, - } satisfies GenerateSimplePathsResultMessage); - } -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/search-control.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/search-control.tsx deleted file mode 100644 index bff84733a2c..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/search-control.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { Box } from "@mui/material"; -import { useSigma } from "@react-sigma/core"; -import { useEffect, useMemo, useRef, useState } from "react"; - -import { SearchIcon } from "../../../../shared/icons/search-icon"; -import { GrayToBlueIconButton } from "../../gray-to-blue-icon-button"; -import { ControlPanel } from "./shared/control-components"; -import { useGraphContext } from "./shared/graph-context"; -import { SimpleAutocomplete } from "./shared/simple-autocomplete"; - -import type { GraphVizNode } from "./shared/types"; - -type NodeData = GraphVizNode & { - valueForSelector: string; -}; - -const Search = ({ - nodes, - open, - onClose, -}: { - nodes: GraphVizNode[]; - open: boolean; - onClose: () => void; -}) => { - const { filters, refreshGraphHighlights, graphState, setGraphState } = - useGraphContext(); - - const [selectedNode, _setSelectedNode] = useState(null); - - const sigma = useSigma(); - - useEffect(() => { - if (!open && !graphState.selectedNodeId) { - _setSelectedNode(null); - } - }, [open, graphState.selectedNodeId]); - - const nodeOptions = useMemo(() => { - const { includeByNodeTypeId } = filters; - - const options: NodeData[] = []; - - for (const node of nodes) { - const { nodeId } = node; - - if (node.nodeTypeId && !includeByNodeTypeId?.[node.nodeTypeId]) { - continue; - } - - options.push({ - ...node, - valueForSelector: nodeId, - }); - } - - return options; - }, [filters, nodes]); - - const setSelectedNode = (node: NodeData | null) => { - _setSelectedNode(node); - if (node) { - setGraphState("selectedNodeId", node.nodeId); - refreshGraphHighlights(); - - const coords = sigma.getNodeDisplayData(node.nodeId); - if (coords) { - void sigma.getCamera().animate( - { - x: coords.x, - y: coords.y, - ratio: 1, - }, - { duration: 500 }, - ); - } - } - }; - - const inputRef = useRef(null); - const panelRef = useRef(null); - - useEffect(() => { - const panel = panelRef.current; - - if (open && inputRef.current && panel) { - /** - * Once the panel is fully transitioned in, focus the input. - * Doing so any earlier will cause the dropdn - */ - panel.ontransitionend = () => { - inputRef.current?.focus(); - }; - } - - return () => { - if (panel) { - panel.ontransitionend = null; - } - }; - }, [open]); - - return ( - - - palette.gray[30] }} - /> - } - inputRef={inputRef} - options={nodeOptions} - placeholder="Search for node..." - setValue={setSelectedNode} - value={selectedNode} - /> - - - ); -}; - -export const SearchControl = ({ nodes }: { nodes: GraphVizNode[] }) => { - const { searchPanelOpen, setSearchPanelOpen } = useGraphContext(); - - return ( - <> - setSearchPanelOpen(false)} - /> - setSearchPanelOpen(true)} - sx={{ position: "absolute", top: 8, left: 42 }} - > - - - - ); -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/config-control.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/config-control.tsx deleted file mode 100644 index 1dd36b030df..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/config-control.tsx +++ /dev/null @@ -1,469 +0,0 @@ -import { Box } from "@mui/material"; - -import { GearIcon } from "@hashintel/block-design-system"; -import { Select } from "@hashintel/design-system"; - -import { MenuItem } from "../../../../../shared/ui/menu-item"; -import { GrayToBlueIconButton } from "../../../gray-to-blue-icon-button"; -import { - ControlPanel, - ControlSectionContainer, - ItemLabel, -} from "./control-components"; -import { useGraphContext } from "./graph-context"; -import { IntegerInput } from "./integer-input"; -import { selectSx } from "./styles"; - -import type { FunctionComponent } from "react"; - -const directionOptions = ["All", "In", "Out"] as const; - -type Direction = (typeof directionOptions)[number]; - -const scaleOptions = [ - "Geometric", - "Linear", - "Logarithmic", - "Percentile", -] as const; - -type Scale = (typeof scaleOptions)[number]; - -export type DynamicNodeSizing = { - mode: "byEdgeCount"; - /** - * The minimum size of a node. - * Nodes will default to this size. - * Nodes with the lowest number of relevant edges will be this size. - */ - min: number; - /** - * The maximum size of a node. - * The node with the greatest number of relevant edges will be this size. - */ - max: number; - /** - * Which edges to count when determining node size. - */ - countEdges: Direction; - /** - * How to scale node sizes based on their edge count within the range of edge counts. - * Logarithmic and geometric scaling is useful if there are outliers with high edge counts that dominate the range. - * Geometric is less aggressive in compressing the range than logarithmic. - * Percentile ensures an even distribution of node sizes. - */ - scale: Scale; -}; - -export type DynamicEdgeSizing = { - /** - * The minimum size of an edge. - * Edges will default to this size. - * Edges with the lowest weight will be this size. - */ - min: number; - /** - * The maximum size of an edge. - * The edge with the greatest weight will be this size. - */ - max: number; - /** - * The size threshold below which edges will not be shown unless they are part of a highlighted graph, - * i.e. after the user clicks on or hovers over a node. - */ - nonHighlightedVisibleSizeThreshold: number; - /** - * How to scale edge sizes based on their weight within the range of weights. - * Linear means that an edge which aggregates together twice as many edges as another will be roughly twice as big. - * Geometric smooths out differences between edges, while still being easily distinguishable. - */ - scale: Exclude; -}; - -export type StaticNodeSizing = { - /** - * Don't adjust node sizes – the parent component calling GraphVisualizer is responsible for setting them. - */ - mode: "static"; -}; - -export type GraphVizConfig< - NodeSizing extends DynamicNodeSizing | StaticNodeSizing, -> = { - edgeSizing: DynamicEdgeSizing; - /** - * A unique key for the graph, under which the viz settings will be stored in local storage. - */ - graphKey: string; - nodeHighlighting: { - /** - * When a node is hovered or clicked, the depth around it to which other nodes will be highlighted - */ - depth: number; - /** - * When a node is hovered or clicked, the links from it that will be followed to highlight neighbors, - * i.e. 'All', 'In'wards and 'Out'wards - */ - direction: Direction; - }; - nodeSizing: NodeSizing; - pathfinding?: { - startTypeId?: string; - endTypeId?: string; - hideVia?: boolean; - }; -}; - -const DirectionSelect = ({ - value, - setValue, -}: { - value: Direction; - setValue: (value: Direction) => void; -}) => { - /** - * We need the ref of the container so that the MUI Select can be told where to render its popup dropdown into. - * In full-screen mode, only part of the DOM is rendered, and we need MUI to render into the correct part, - * rather than attaching its dropdown to the body. If we don't do this the popup won't show properly in fullscreen. - */ - const { graphContainerRef } = useGraphContext(); - - return ( - - ); -}; - -const ScaleSelect = ({ - exclude, - value, - setValue, -}: { - exclude?: Exclusions[]; - value: Exclude; - setValue: (value: Exclude) => void; -}) => { - /** - * We need the ref of the container so that the MUI Select can be told where to render its popup dropdown into. - * In full-screen mode, only part of the DOM is rendered, and we need MUI to render into the correct part, - * rather than attaching its dropdown to the body. If we don't do this the popup won't show properly in fullscreen. - */ - const { graphContainerRef } = useGraphContext(); - - return ( - - ); -}; - -const ConfigPanel: FunctionComponent<{ - open: boolean; - onClose: () => void; -}> = ({ open, onClose }) => { - const { config, setConfig } = useGraphContext(); - - return ( - - - - - Depth - - { - setConfig({ - ...config, - nodeHighlighting: { - ...config.nodeHighlighting, - depth: newDepth, - }, - }); - }} - /> - - - - Direction - - - setConfig({ - ...config, - nodeHighlighting: { - ...config.nodeHighlighting, - direction: newDirection, - }, - }) - } - /> - - - {config.nodeSizing.mode === "byEdgeCount" && ( - <> - - - - Min - - - setConfig({ - ...config, - nodeSizing: { - ...(config.nodeSizing as DynamicNodeSizing), - min: newMin, - }, - }) - } - /> - - - Max - - setConfig({ - ...config, - nodeSizing: { - ...(config.nodeSizing as DynamicNodeSizing), - max: newMax, - }, - }) - } - /> - - - - Count edges - - - setConfig({ - ...config, - nodeSizing: { - ...(config.nodeSizing as DynamicNodeSizing), - countEdges: newDirection, - }, - }) - } - /> - - - - How to scale node size: - - Linear means that a node with twice as - many edges as another will be roughly twice as big. If - there is an outlier node with a much higher edge count - than most of the others, it will be very large and the - rest will appear almost equally small, even if there are - significant differences in edge count between those rest. - - - Geometric smooths out differences between - nodes more so that nodes with much higher edge counts - don't dominate as much, while still being easily - distinguishable. Medium count nodes will be closer in size - to the larger ones than in a linear scale. - - - Logarithmic means that node size grows - quickly at low edge counts and slows down at higher edge - counts. It is the most aggressive in reducing the size - difference for nodes with the largest edge counts. - - - Percentile arranges nodes into buckets of - 10% by edge count, and then increments each bucket by - 1/10th of the difference between the min and max sizes. It - ensures an even distribution of node sizes, although it - means that nodes with little to no difference in edge - count can appear significantly different in size, - depending on the range of edge counts present in the - graph. - - - } - > - Scaling - - - setConfig({ - ...config, - nodeSizing: { - ...(config.nodeSizing as DynamicNodeSizing), - scale: newScale, - }, - }) - } - /> -
- - - - - Min - - - setConfig({ - ...config, - edgeSizing: { - ...config.edgeSizing, - min: newMin, - }, - }) - } - /> - - - Max - - setConfig({ - ...config, - edgeSizing: { - ...config.edgeSizing, - max: newMax, - }, - }) - } - /> - - - - Visible min - - - setConfig({ - ...config, - edgeSizing: { - ...config.edgeSizing, - nonHighlightedVisibleSizeThreshold: newVisibleMin, - }, - }) - } - /> - - - - How to scale edge size: - - Linear means that an edge which - aggregates together twice as many edges as another will be - roughly twice as big. If there is an outlier edge with a - much higher aggregated edge count than most of the others, - it will be very large and the rest will appear almost - equally small, even if there are significant differences - in count between those rest. - - - Geometric smooths out differences between - edges more so that edges which aggregate up more - individual edges don't dominate as much, while still being - easily distinguishable. - - - } - > - Scaling - - - setConfig({ - ...config, - edgeSizing: { - ...config.edgeSizing, - scale: newScale, - }, - }) - } - /> -
- - - )} - - ); -}; - -export const ConfigControl = () => { - const { configPanelOpen, setConfigPanelOpen } = useGraphContext(); - - return ( - <> - setConfigPanelOpen(false)} - /> - setConfigPanelOpen(true)} - sx={{ position: "absolute", top: 8, right: 13 }} - > - - - - ); -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/control-components.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/control-components.tsx deleted file mode 100644 index 9e22d8d0f2b..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/control-components.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { Box, Stack, Typography } from "@mui/material"; - -import { IconButton } from "@hashintel/design-system"; - -import { ArrowRightToLineIcon } from "../../../../../shared/icons/arrow-right-to-line-icon"; -import { CircleInfoIcon } from "../../../../../shared/icons/circle-info-icon"; -import { GraphVizTooltip } from "./graph-viz-tooltip"; - -import type { PropsWithChildren, ReactElement, RefObject } from "react"; - -export const InfoIconTooltip = ({ - tooltip, -}: { - tooltip: string | ReactElement; -}) => { - return ( - - palette.gray[50], - fontSize: 10, - }} - > - - - - ); -}; - -export const ItemLabel = ({ - children, - fontSize = 13, - tooltip, - uppercase = false, -}: PropsWithChildren<{ - fontSize?: number; - tooltip: string | ReactElement; - uppercase?: boolean; -}>) => ( - - - uppercase ? palette.gray[70] : palette.common.black, - fontSize, - fontWeight: uppercase ? 600 : 400, - letterSpacing: uppercase ? 0.2 : 0, - textTransform: uppercase ? "uppercase" : "none", - }} - > - {children} - - - -); - -export const ControlSectionContainer = ({ - children, - label, - tooltip, -}: PropsWithChildren<{ label: string; tooltip: string }>) => { - return ( - - - {label} - - {children} - - ); -}; - -export const ControlPanel = ({ - children, - onClose, - open, - panelRef, - position, - title, -}: PropsWithChildren<{ - open: boolean; - onClose: () => void; - position: "left" | "right"; - panelRef?: RefObject; - title: string; -}>) => { - return ( - `calc(100% - ${spacing(4)})`, - transition: ({ transitions }) => transitions.create(["transform"]), - py: 1.2, - background: ({ palette }) => palette.white, - borderWidth: 1, - borderColor: ({ palette }) => palette.gray[20], - borderStyle: "solid", - borderTopWidth: 0, - borderRightWidth: 0, - borderLeftWidth: 1, - borderBottomLeftRadius: 4, - boxShadow: open ? ({ boxShadows }) => boxShadows.sm : undefined, - minWidth: 180, - overflowY: "auto", - }} - > - - palette.gray[90], - fontSize: 14, - fontWeight: 500, - }} - > - {title} - - onClose()} - sx={{ - padding: 0.5, - svg: { - fontSize: 16, - color: ({ palette }) => palette.gray[50], - }, - transform: position === "left" ? undefined : "rotate(180deg)", - }} - > - - - - {children} - - ); -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/filter-control.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/filter-control.tsx deleted file mode 100644 index 21212fc8b32..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/filter-control.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { Box, Stack, Typography } from "@mui/material"; -import { useEffect, useMemo } from "react"; - -import { FilterLightIcon } from "../../../../../shared/icons/filter-light-icon"; -import { GrayToBlueIconButton } from "../../../gray-to-blue-icon-button"; -import { ControlPanel, ItemLabel } from "./control-components"; -import { - filterButtonSx, - NodeTypeFilters, -} from "./filter-control/node-type-filters"; -import { useGraphContext } from "./graph-context"; - -import type { GraphVizNode } from "./types"; -import type { FunctionComponent } from "react"; - -type NodeTypesInData = { - [nodeTypeId: string]: { - color: string; - count: number; - nodeTypeLabel: string; - nodeTypeId: string; - }; -}; - -export type GraphVizFilters = { - includeByNodeTypeId?: { - /** - * Whether to show nodes of a given `nodeTypeId`. - */ - [nodeTypeId: string]: boolean; - }; - colorByNodeTypeId?: { - /** - * The color to use for nodes of a given `nodeTypeId`. - */ - [nodeTypeId: string]: string; - }; -}; - -const FilterPanel: FunctionComponent<{ - defaultFilters?: GraphVizFilters; - isFiltered: boolean; - nodeTypesInData: NodeTypesInData; - open: boolean; - onClose: () => void; -}> = ({ defaultFilters, isFiltered, nodeTypesInData, open, onClose }) => { - const { filters, setFilters } = useGraphContext(); - - return ( - - - - - Nodes - - {isFiltered && ( - { - setFilters({ - ...filters, - includeByNodeTypeId: { - /** - * We may have saved filters for types that aren't in this graph, - * and we want to preserve whatever visibility setting they had. - */ - ...filters.includeByNodeTypeId, - /** - * Reset all types in the current graph to the default if provided - */ - ...(defaultFilters - ? defaultFilters.includeByNodeTypeId /** - * Otherwise all types in the current graph to visible. - */ - : Object.values(nodeTypesInData).reduce< - Record - >((acc, type) => { - acc[type.nodeTypeId] = true; - return acc; - }, {})), - }, - }); - }} - sx={[filterButtonSx, { visibility: "visible" }]} - > - Reset - - )} - - - - - ); -}; - -export const FilterControl = ({ - defaultFilters, - nodes, -}: { - defaultFilters?: GraphVizFilters; - nodes: GraphVizNode[]; -}) => { - const { filters, filterPanelOpen, setFilters, setFilterPanelOpen } = - useGraphContext(); - - const nodeTypesInData = useMemo(() => { - const metadataByType: NodeTypesInData = {}; - - for (const node of nodes) { - if (!node.nodeTypeId) { - /** - * If a node doesn't have a type we can't offer any filtering options for it. - */ - continue; - } - - const { nodeTypeId, nodeTypeLabel, color } = node; - - metadataByType[nodeTypeId] ??= { - color, - count: 0, - nodeTypeId, - nodeTypeLabel: nodeTypeLabel ?? nodeTypeId, - }; - metadataByType[nodeTypeId].count++; - } - - return metadataByType; - }, [nodes]); - - useEffect(() => { - const typeIdsInData = new Set(Object.keys(nodeTypesInData)); - - /** - * See which of the nodeTypeIds in the data are already represented in the data. - * We assume that if it appears in colorsByNodeTypeId, it will also be in includeByNodeTypeId, - * because we set both below when we first see them. - */ - const typeIdsInFilters = new Set( - Object.keys(filters.colorByNodeTypeId ?? {}), - ); - - const missingTypeIds = typeIdsInData.difference(typeIdsInFilters); - - if (missingTypeIds.size) { - const newFilters = { ...filters }; - - for (const missingTypeId of missingTypeIds) { - const metadata = nodeTypesInData[missingTypeId]!; - - newFilters.colorByNodeTypeId ??= {}; - newFilters.colorByNodeTypeId[missingTypeId] = metadata.color; - - newFilters.includeByNodeTypeId ??= {}; - newFilters.includeByNodeTypeId[missingTypeId] = true; - } - setFilters(newFilters); - } - }, [filters, nodeTypesInData, nodes, setFilters]); - - const isFiltered = useMemo(() => { - const typeIdsInData = Object.keys(nodeTypesInData); - - return typeIdsInData.some( - /** - * Check against an explicit 'false', because if it's absent we haven't initialized it yet. - */ - (typeId) => filters.includeByNodeTypeId?.[typeId] === false, - ); - }, [filters.includeByNodeTypeId, nodeTypesInData]); - - if (!Object.keys(nodeTypesInData).length) { - return null; - } - - return ( - <> - setFilterPanelOpen(false)} - /> - setFilterPanelOpen(true)} - sx={{ position: "absolute", top: 8, right: 46 }} - > - - isFiltered ? palette.blue[70] : palette.gray[50], - transition: ({ transitions }) => transitions.create("fill"), - }} - /> - - - ); -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/filter-control/node-type-filters.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/filter-control/node-type-filters.tsx deleted file mode 100644 index 9736f7e7c73..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/filter-control/node-type-filters.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { - Box, - Checkbox, - FormControl, - FormControlLabel, - Stack, - Typography, -} from "@mui/material"; -import { useSigma } from "@react-sigma/core"; - -import { useGraphContext } from "../graph-context"; - -import type { Theme } from "@mui/material"; -import type { SystemStyleObject } from "@mui/system"; - -type CheckboxListProps = { - typesInData: { count: number; nodeTypeLabel: string; nodeTypeId: string }[]; -}; - -export const filterButtonSx: (theme: Theme) => SystemStyleObject = ({ - palette, - transitions, -}) => ({ - background: "transparent", - border: "none", - borderRadius: 1, - cursor: "pointer", - px: 1, - py: 0.5, - "& > span": { - color: palette.blue[70], - fontSize: 12, - }, - "&:hover": { - background: palette.blue[20], - }, - transition: transitions.create("background"), - visibility: "hidden", -}); - -export const NodeTypeFilters = ({ typesInData }: CheckboxListProps) => { - const { filters, setFilters } = useGraphContext(); - - const sigma = useSigma(); - - const { setGraphState } = useGraphContext(); - - const { colorByNodeTypeId, includeByNodeTypeId } = filters; - - return ( - - {Object.values(typesInData) - .sort((a, b) => a.nodeTypeLabel.localeCompare(b.nodeTypeLabel)) - .map(({ nodeTypeLabel, nodeTypeId, count }) => { - if (includeByNodeTypeId?.[nodeTypeId] === undefined) { - /** - * These should be set by {@link FilterControl} for any types in the graph. - * We'll just return null if they're absent, they should load in imminently. - */ - return null; - } - - return ( - - button": { visibility: "visible" } }} - > - { - setFilters({ - ...filters, - includeByNodeTypeId: { - ...includeByNodeTypeId, - [nodeTypeId]: event.target.checked, - }, - }); - }} - checked={includeByNodeTypeId[nodeTypeId]} - sx={{ mr: 1.5, "& .MuiSvgIcon-root": { fontSize: 18 } }} - /> - } - label={`${nodeTypeLabel} (${count})`} - slotProps={{ typography: { fontSize: 14 } }} - sx={{ - borderRadius: 1, - marginRight: 0, - marginLeft: 0.3, - px: 1, - py: 0.5, - "&:hover": { - background: ({ palette }) => palette.gray[20], - }, - }} - /> - { - setFilters({ - ...filters, - includeByNodeTypeId: { - /** - * We may have saved filters for types that aren't in this graph, - * and we want to preserve whatever visibility setting they had. - */ - ...includeByNodeTypeId, - /** - * Disable visibility for all types currently in the graph. - */ - ...typesInData.reduce>( - (acc, type) => { - acc[type.nodeTypeId] = false; - return acc; - }, - {}, - ), - [nodeTypeId]: true, - }, - }); - }} - sx={filterButtonSx} - > - Only - - - `1px solid ${palette.gray[20]}`, - height: 24, - width: 24, - }} - > - { - const newColorSettings = { - ...colorByNodeTypeId, - [nodeTypeId]: event.target.value, - }; - - setFilters({ - ...filters, - colorByNodeTypeId: newColorSettings, - }); - - setGraphState("colorByNodeTypeId", newColorSettings); - - sigma.refresh({ skipIndexation: true }); - }} - style={{ visibility: "hidden", width: 0 }} - value={colorByNodeTypeId?.[nodeTypeId]} - /> - - - ); - })} - - ); -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/full-screen-context.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/full-screen-context.tsx deleted file mode 100644 index 0f8ecfdc019..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/full-screen-context.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { createContext, useCallback, useContext, useMemo } from "react"; -import { FullScreen, useFullScreenHandle } from "react-full-screen"; - -import { useSlideStack } from "../../../slide-stack"; - -import type { PropsWithChildren, RefObject } from "react"; - -export type FullScreenContextType = { - isFullScreen: boolean; - toggleFullScreen: () => void; -}; - -export const FullScreenContext = createContext( - null, -); - -export const FullScreenContextProvider = ({ - children, - fullScreenMode = "element", - graphContainerRef, -}: PropsWithChildren<{ - fullScreenMode?: "document" | "element"; - graphContainerRef: RefObject; -}>) => { - const handle = useFullScreenHandle(); - - const { setSlideContainerRef } = useSlideStack(); - - const toggleFullScreen = useCallback(async () => { - if (fullScreenMode === "document") { - if (document.fullscreenElement) { - await document.exitFullscreen(); - } else { - await document.documentElement.requestFullscreen(); - } - return; - } - - if (handle.active) { - await handle.exit(); - setSlideContainerRef(null); - } else { - await handle.enter(); - setSlideContainerRef(graphContainerRef); - } - }, [fullScreenMode, handle, graphContainerRef, setSlideContainerRef]); - - const value = useMemo( - () => ({ - isFullScreen: - fullScreenMode === "document" - ? !!document.fullscreenElement - : handle.active, - toggleFullScreen, - }), - [handle.active, fullScreenMode, toggleFullScreen], - ); - - return ( - - - {children} - - - ); -}; - -export const useFullScreen = () => { - const context = useContext(FullScreenContext); - - if (!context) { - throw new Error("no FullScreenContext value has been provided"); - } - - return context; -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/graph-context.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/graph-context.tsx deleted file mode 100644 index 289ba8a1ca2..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/graph-context.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { useLocalStorage } from "@mantine/hooks"; -import { - createContext, - useCallback, - useContext, - useMemo, - useRef, - useState, -} from "react"; - -import { useEventHandlers } from "./use-event-handlers"; -import { useSetDrawSettings } from "./use-set-draw-settings"; - -import type { - DynamicNodeSizing, - GraphVizConfig, - StaticNodeSizing, -} from "./config-control"; -import type { GraphVizFilters } from "./filter-control"; -import type { GraphState } from "./state"; -import type { RegisterEventsArgs } from "./use-event-handlers"; -import type { PropsWithChildren, RefObject } from "react"; - -export type GraphContextType< - NodeSizing extends DynamicNodeSizing | StaticNodeSizing, -> = { - config: GraphVizConfig; - configPanelOpen: boolean; - filters: GraphVizFilters; - filterPanelOpen: boolean; - graphContainerRef: RefObject; - graphState: GraphState; - pathFinderPanelOpen: boolean; - refreshGraphHighlights: () => void; - searchPanelOpen: boolean; - setConfig: (config: GraphVizConfig) => void; - setConfigPanelOpen: (open: boolean) => void; - setFilters: (filters: GraphVizFilters) => void; - setFilterPanelOpen: (open: boolean) => void; - setGraphState: ( - key: K, - value: GraphState[K], - ) => void; - setPathFinderPanelOpen: (open: boolean) => void; - setSearchPanelOpen: (open: boolean) => void; -}; - -const GraphContext = createContext | null>(null); - -export type GraphContextProviderProps< - NodeSizing extends DynamicNodeSizing | StaticNodeSizing, -> = { - defaultConfig: GraphVizConfig; - defaultFilters?: GraphVizFilters; - graphContainerRef: RefObject; -} & Pick; - -export const GraphContextProvider = < - NodeSizing extends DynamicNodeSizing | StaticNodeSizing, ->({ - children, - defaultConfig, - defaultFilters, - graphContainerRef, - onEdgeClick, - onNodeSecondClick, - onRender, -}: PropsWithChildren>) => { - const [config, setConfig] = useLocalStorage>({ - key: `graph-viz-config~${defaultConfig.graphKey}`, - defaultValue: defaultConfig, - getInitialValueInEffect: false, - }); - - const [filters, setFilters] = useLocalStorage({ - key: `graph-viz-filters~${defaultConfig.graphKey}`, - defaultValue: defaultFilters ?? {}, - getInitialValueInEffect: false, - }); - - const [configPanelOpen, setConfigPanelOpen] = useState(false); - const [filterPanelOpen, setFilterPanelOpen] = useState(false); - const [searchPanelOpen, setSearchPanelOpen] = useState(false); - const [pathFinderPanelOpen, setPathFinderPanelOpen] = useState(false); - - /** - * State to track interactions with the graph. - * It's drawn in canvas so doesn't need to be in React state - * – redrawing the graph is done via sigma.refresh. - */ - const graphState = useRef({ - /** - * We store colorByNodeId in the graph state so that we can refresh the graph when it changes, - * without having to reload all the graph data or recreate the node reducers - * (which we'd have to do if we made them dependent on React state) - */ - colorByNodeTypeId: filters.colorByNodeTypeId, - hoveredEdgeId: null, - hoveredNodeId: null, - highlightedEdgePath: null, - neighborsByDepth: null, - selectedNodeId: null, - }); - - const setGraphState: GraphContextType["setGraphState"] = - useCallback((key, value) => { - graphState.current[key] = value; - }, []); - - useSetDrawSettings(graphState.current, config); - - const { refreshGraphHighlights } = useEventHandlers({ - config, - graphContainerRef, - graphState: graphState.current, - onEdgeClick, - onNodeSecondClick, - onRender, - pathFinderPanelOpen, - setConfigPanelOpen, - setFilterPanelOpen, - setPathFinderPanelOpen, - setSearchPanelOpen, - setGraphState, - }); - - const value = useMemo>( - () => ({ - config, - configPanelOpen, - filters, - filterPanelOpen, - graphContainerRef, - graphState: graphState.current, - pathFinderPanelOpen, - refreshGraphHighlights, - searchPanelOpen, - setConfig, - setConfigPanelOpen, - setFilters, - setFilterPanelOpen, - setGraphState, - setPathFinderPanelOpen, - setSearchPanelOpen, - }), - [ - config, - configPanelOpen, - filters, - filterPanelOpen, - graphContainerRef, - pathFinderPanelOpen, - refreshGraphHighlights, - searchPanelOpen, - setConfig, - setFilters, - setGraphState, - ], - ); - - return ( - - } - > - {children} - - ); -}; - -export const useGraphContext = () => { - const context = useContext(GraphContext); - - if (!context) { - throw new Error("no GraphContext value has been provided"); - } - - return context; -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/graph-viz-tooltip.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/graph-viz-tooltip.tsx deleted file mode 100644 index f87362d1377..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/graph-viz-tooltip.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Tooltip as MuiTooltip } from "@mui/material"; - -import { useGraphContext } from "./graph-context"; - -import type { TooltipProps } from "@mui/material"; - -export const GraphVizTooltip = ({ children, ...props }: TooltipProps) => { - const { graphContainerRef } = useGraphContext(); - - return ( - - {children} - - ); -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/integer-input.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/integer-input.tsx deleted file mode 100644 index e2468d386db..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/integer-input.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Box } from "@mui/material"; - -export const IntegerInput = ({ - min = 1, - max, - value, - setValue, - width, -}: { - min?: number; - max?: number; - value: number; - setValue: (value: number) => void; - width?: number; -}) => { - return ( - setValue(parseInt(event.target.value, 10))} - sx={({ palette }) => ({ - border: `1px solid ${palette.gray[30]}`, - borderRadius: 1, - fontSize: 14, - py: 1.2, - px: 1.5, - mt: 0.5, - width, - })} - /> - ); -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/simple-autocomplete.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/simple-autocomplete.tsx deleted file mode 100644 index 2ecde976c97..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/simple-autocomplete.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { Box, outlinedInputClasses } from "@mui/material"; -import { createContext, forwardRef, useContext, useMemo } from "react"; -import { VariableSizeList } from "react-window"; - -import { Autocomplete } from "@hashintel/design-system"; - -import { MenuItem } from "../../../../../shared/ui/menu-item"; -import { useGraphContext } from "./graph-context"; -import { GraphVizTooltip } from "./graph-viz-tooltip"; - -import type { CSSProperties, ReactElement, ReactNode, RefObject } from "react"; - -const Row = ({ - data, - index, - style, -}: { - data: ReactNode[]; - index: number; - style: CSSProperties; -}) => { - const elem = data[index]; - return {elem}; -}; - -const OuterElementContext = createContext({}); -const OuterElementType = forwardRef((props, ref) => { - const outerProps = useContext(OuterElementContext); - return
; -}); - -export const VirtualizedList = forwardRef< - HTMLDivElement, - { children: ReactElement[]; rowheight: number } ->((props, ref) => { - const itemCount = props.children.length; - - const outerProps = useMemo(() => { - const { children: _children, ...rest } = props; - return rest; - }, [props]); - - return ( -
- - props.rowheight} - overscanCount={5} - itemData={{ ...props.children }} - width={800} - > - {Row} - - -
- ); -}); - -const VirtualizedListComp = forwardRef< - HTMLDivElement, - { children: ReactElement[] } ->((defaultProps, ref) => { - return ; -}); - -export const SimpleAutocomplete = < - T extends { - disabled?: boolean; - label: string; - valueForSelector: string; - } & { [key: string]: string | number | boolean | string[] }, ->({ - autoFocus, - disabled, - endAdornment, - inputRef, - placeholder, - options, - setValue, - sortAlphabetically = true, - suffixKey, - value, -}: { - autoFocus?: boolean; - disabled?: boolean; - endAdornment?: ReactNode; - inputRef?: RefObject; - placeholder: string; - options: T[]; - sortAlphabetically?: boolean; - suffixKey?: keyof T; - setValue: (value: T | null) => void; - value: T | null; -}) => { - const listComponent = options.length > 200 ? VirtualizedListComp : undefined; - - const { graphContainerRef } = useGraphContext(); - - return ( - - autoFocus={!!autoFocus} - componentsProps={{ - paper: { - sx: { - p: 0, - maxWidth: "90vw", - minWidth: "100%", - width: "fit-content", - }, - }, - popper: { - container: graphContainerRef.current, - sx: { - "& > div:first-of-type": { - boxShadow: "none", - }, - }, - }, - }} - disabled={!!disabled || options.length === 0} - getOptionDisabled={(option) => !!option.disabled} - inputHeight="auto" - inputProps={{ - endAdornment: endAdornment ??
, - placeholder, - sx: () => ({ - height: "auto", - [`&.${outlinedInputClasses.root}`]: { - py: 0.3, - px: "8px !important", - - input: { - fontSize: 14, - }, - }, - }), - }} - inputRef={inputRef} - isOptionEqualToValue={(option, selectedValue) => - option.valueForSelector === selectedValue.valueForSelector - } - // @ts-expect-error -- mismatch with expected children. - ListboxComponent={listComponent} - ListboxProps={{ - sx: { - maxHeight: 240, - }, - }} - onChange={(_event, option) => { - setValue(option); - }} - options={ - sortAlphabetically - ? options.sort((a, b) => a.label.localeCompare(b.label)) - : options - } - renderOption={( - { - /** - * Don't spread the key as part of props into the MenuItem (which causes a Reacet error) - */ - key: _, - ...props - }, - option, - ) => { - const label = - option.label + - (suffixKey && option[suffixKey] ? ` ${option[suffixKey]}` : ""); - - const regex = /(\[[0-9]+]|\([0-9]+\)|\(None\)|\[None]|[^[\]()]+)/g; - - const parts = label.match(regex); - - const structuredParts = (parts ?? [label]).map((part) => { - if ( - (part.startsWith("[") && part.endsWith("]")) || - (part.startsWith("(") && part.endsWith(")")) - ) { - return { - part, - isCount: true, - }; - } - return { - part, - isCount: false, - }; - }); - - return ( - - - {structuredParts.map(({ part, isCount }, index) => ( - - isCount ? palette.gray[50] : palette.common.black, - mr: isCount && index === 0 ? 0.5 : 0, - ml: isCount ? 0.5 : 0, - }} - > - {part} - - ))} - - - ); - }} - value={value} - /> - ); -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/state.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/state.ts deleted file mode 100644 index a94ea24603c..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/state.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { GraphVizFilters } from "./filter-control"; - -export type GraphState = { - colorByNodeTypeId: GraphVizFilters["colorByNodeTypeId"]; - /** - * A path to highlight in the graph, represented by a list of edge ids. - * Takes precedence over any other highlighting. - */ - highlightedEdgePath: string[] | null; - hoveredEdgeId: string | null; - /** - * Which node is hovered - */ - hoveredNodeId: string | null; - /** - * When a node is hovered or selected, the connected nodes (which and to what depth determined by config) - */ - neighborsByDepth: Set[] | null; - /** - * Which node has been selected by clicking on it - */ - selectedNodeId: string | null; -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/styles.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/styles.ts deleted file mode 100644 index a9a0b27f176..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/styles.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { outlinedInputClasses } from "@mui/material"; - -import type { SxProps, Theme } from "@mui/material"; - -export const selectSx: SxProps = { - [`.${outlinedInputClasses.root} .${outlinedInputClasses.input}`]: { - fontSize: 14, - px: 1.5, - py: 1, - }, - [`.${outlinedInputClasses.root}`]: { - boxShadow: "none", - }, - width: "100%", - mt: 0.3, -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/types.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/types.ts deleted file mode 100644 index ce610224800..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type GraphVizNode = { - borderColor?: string; - color: string; - icon?: string; - nodeId: string; - nodeTypeId?: string; - nodeTypeLabel?: string; - label: string; - size: number; -}; - -export type GraphVizEdge = { - edgeId: string; - edgeTypeId?: string; - label?: string; - size: number; - source: string; - target: string; -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/use-event-handlers.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/use-event-handlers.ts deleted file mode 100644 index 1b51170ce3e..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/use-event-handlers.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { useRegisterEvents, useSigma } from "@react-sigma/core"; -import { useCallback, useEffect } from "react"; - -import { useFullScreen } from "./full-screen-context"; - -import type { - DynamicNodeSizing, - GraphVizConfig, - StaticNodeSizing, -} from "./config-control"; -import type { GraphState } from "./state"; -import type { GraphVizEdge } from "./types"; -import type { RefObject } from "react"; - -export type RegisterEventsArgs = { - config: GraphVizConfig; - graphContainerRef: RefObject; - graphState: GraphState; - onEdgeClick?: (params: { edgeData: GraphVizEdge }) => void; - onRender?: () => void; - onNodeSecondClick?: (params: { nodeId: string }) => void; - pathFinderPanelOpen: boolean; - setConfigPanelOpen: (open: boolean) => void; - setFilterPanelOpen: (open: boolean) => void; - setPathFinderPanelOpen: (open: boolean) => void; - setSearchPanelOpen: (open: boolean) => void; - setGraphState: ( - key: K, - value: GraphState[K], - ) => void; -}; - -/** - * Register event handlers for the graph. - */ -export const useEventHandlers = ({ - config, - graphContainerRef, - graphState, - onEdgeClick, - onRender, - onNodeSecondClick, - pathFinderPanelOpen, - setConfigPanelOpen, - setFilterPanelOpen, - setPathFinderPanelOpen, - setSearchPanelOpen, - setGraphState, -}: RegisterEventsArgs) => { - const sigma = useSigma(); - - const registerEvents = useRegisterEvents(); - - const { isFullScreen } = useFullScreen(); - - /** - * Highlight the hovered or selected node and its neighbors up to the configured depth. - */ - const refreshGraphHighlights = useCallback(() => { - const highlightedNode = - graphState.selectedNodeId ?? graphState.hoveredNodeId; - - if (!highlightedNode) { - return; - } - - const getNeighbors = ( - nodeId: string, - neighborIds: NonNullable = [], - depth = 1, - ) => { - if (depth > config.nodeHighlighting.depth) { - return neighborIds; - } - - let directNeighbors: string[]; - switch (config.nodeHighlighting.direction) { - case "All": - directNeighbors = sigma.getGraph().neighbors(nodeId); - break; - case "In": - directNeighbors = sigma.getGraph().inNeighbors(nodeId); - break; - case "Out": - directNeighbors = sigma.getGraph().outNeighbors(nodeId); - break; - default: - throw new Error( - `Unhandled direction: ${config.nodeHighlighting.direction}`, - ); - } - - for (const neighbor of directNeighbors) { - const zeroBasedDepth = depth - 1; - - // eslint-disable-next-line no-param-reassign - neighborIds[zeroBasedDepth] ??= new Set(); - neighborIds[zeroBasedDepth].add(neighbor); - getNeighbors(neighbor, neighborIds, depth + 1); - } - - return neighborIds; - }; - - setGraphState("neighborsByDepth", getNeighbors(highlightedNode)); - - /** - * We haven't touched the graph data, so don't need to re-index. - * An additional optimization would be to supply partialGraph here and only redraw the affected nodes, - * but since the nodes whose appearance changes are the NON-highlighted nodes (they disappear), it's probably - * not worth it - * – they are likely to be the majority anyway, and we'd have to generate an array of them. - */ - sigma.refresh({ skipIndexation: true }); - }, [config.nodeHighlighting, graphState, setGraphState, sigma]); - - useEffect(() => { - refreshGraphHighlights(); - }, [refreshGraphHighlights]); - - useEffect(() => { - const removeHighlights = () => { - setGraphState("hoveredNodeId", null); - setGraphState("neighborsByDepth", null); - - sigma.refresh({ skipIndexation: true }); - }; - - registerEvents({ - afterRender: () => { - onRender?.(); - }, - clickEdge: (event) => { - if (onEdgeClick) { - const edgeData = sigma - .getGraph() - .getEdgeAttributes(event.edge) as GraphVizEdge; - - onEdgeClick({ - edgeData, - }); - } - }, - clickNode: (event) => { - if (graphState.selectedNodeId === event.node) { - /** - * Only activate the externally-provided onClick when the node is already selected, - * so that the first click performs the graph highlighting functions. - * - * If we want clicks to be registered externally on the first click at some point - * we can provide a prop which configures this behavior (e.g. nodeClickBehavior) - */ - onNodeSecondClick?.({ - nodeId: event.node, - }); - return; - } - - setGraphState("hoveredNodeId", event.node); - setGraphState("selectedNodeId", event.node); - refreshGraphHighlights(); - }, - clickStage: () => { - /** - * If we click on the background (the 'stage'), close any open panels. - */ - const pathFinderWasOpen = pathFinderPanelOpen; - - setConfigPanelOpen(false); - setFilterPanelOpen(false); - setPathFinderPanelOpen(false); - setSearchPanelOpen(false); - - /** - * Closing the path finder shouldn't also clear the highlighted path — - * that takes a second background click, once the panel is already closed. - */ - if (pathFinderWasOpen) { - return; - } - - if (graphState.selectedNodeId ?? graphState.highlightedEdgePath) { - setGraphState("selectedNodeId", null); - setGraphState("highlightedEdgePath", null); - removeHighlights(); - } - }, - enterNode: (event) => { - setGraphState("hoveredNodeId", event.node); - refreshGraphHighlights(); - }, - leaveNode: (event) => { - if (graphState.selectedNodeId) { - /** - * If there's a selected node (has been clicked on), we don't want to remove all highlights when leaving a node. - * The user can click the background or another node to deselect it. - * We do still need to set the hoveredNodeId to null and rerender the node that the mouse has left, - * because node labels change when hovered even if another node is selected. - */ - setGraphState("hoveredNodeId", null); - sigma.refresh({ - skipIndexation: true, - partialGraph: { nodes: [event.node] }, - }); - return; - } - removeHighlights(); - }, - enterEdge: (event) => { - setGraphState("hoveredEdgeId", event.edge); - const source = sigma.getGraph().source(event.edge); - const target = sigma.getGraph().target(event.edge); - - sigma.refresh({ - partialGraph: { - edges: [event.edge], - nodes: [source, target], - }, - skipIndexation: true, - }); - }, - leaveEdge: (event) => { - setGraphState("hoveredEdgeId", null); - - const source = sigma.getGraph().source(event.edge); - const target = sigma.getGraph().target(event.edge); - - sigma.refresh({ - partialGraph: { - edges: [event.edge], - nodes: [source, target], - }, - skipIndexation: true, - }); - }, - }); - }, [ - config, - graphContainerRef, - graphState.highlightedEdgePath, - graphState.selectedNodeId, - isFullScreen, - onEdgeClick, - onNodeSecondClick, - onRender, - pathFinderPanelOpen, - refreshGraphHighlights, - registerEvents, - setConfigPanelOpen, - setFilterPanelOpen, - setPathFinderPanelOpen, - setSearchPanelOpen, - setGraphState, - sigma, - ]); - - return { refreshGraphHighlights }; -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/use-set-draw-settings.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/use-set-draw-settings.ts deleted file mode 100644 index 8c99d20e8bd..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/use-set-draw-settings.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { useTheme } from "@mui/material"; -import { useSigma } from "@react-sigma/core"; -import { useEffect } from "react"; - -import { drawRoundRect } from "../../../../../components/grid/utils/draw-round-rect"; -import { useFullScreen } from "./full-screen-context"; - -import type { - DynamicNodeSizing, - GraphVizConfig, - StaticNodeSizing, -} from "./config-control"; -import type { GraphState } from "./state"; - -export const labelRenderedSizeThreshold = { - fullScreen: 12, - normal: 14, -}; - -const maxLabelWidth = 150; - -const getCanvasLines = (ctx: CanvasRenderingContext2D, text: string) => { - const words = text.split(" "); - const lines = []; - let currentLine = words[0]!; - let maxLineWidth = 0; - - for (let i = 1; i < words.length; i++) { - const word = words[i]!; - const width = ctx.measureText(`${currentLine} ${word}`).width; - if (width < maxLabelWidth) { - currentLine += ` ${word}`; - } else { - maxLineWidth = Math.max(maxLineWidth, ctx.measureText(currentLine).width); - lines.push(currentLine); - currentLine = word; - } - } - - maxLineWidth = Math.max(maxLineWidth, ctx.measureText(currentLine).width); - lines.push(currentLine); - - return { lines, maxLineWidth }; -}; - -const getRgb = (cssString: string): [number, number, number] | null => { - // Check if it's in the hex format (#RRGGBB or shorthand #RGB) - let hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(cssString); - if (hexMatch) { - return [ - parseInt(hexMatch[1]!, 16), - parseInt(hexMatch[2]!, 16), - parseInt(hexMatch[3]!, 16), - ]; - } - - // Check if it's in the shorthand hex format (#RGB) - hexMatch = /^#?([a-f\d])([a-f\d])([a-f\d])$/i.exec(cssString); - if (hexMatch) { - return [ - parseInt(hexMatch[1]! + hexMatch[1]!, 16), - parseInt(hexMatch[2]! + hexMatch[2]!, 16), - parseInt(hexMatch[3]! + hexMatch[3]!, 16), - ]; - } - - // Check if it's in the rgb format (rgb(0, 0, 0)) - const rgbMatch = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/; - const match = rgbMatch.exec(cssString); - if (match) { - return [ - parseInt(match[1]!, 10), - parseInt(match[2]!, 10), - parseInt(match[3]!, 10), - ]; - } - - return null; -}; - -const lightenColor = (color: string) => { - const rgb = getRgb(color); - - if (!rgb) { - return color; - } - - const lightened = rgb.map((value) => Math.floor(value + (255 - value) * 0.6)); - - const lightenedColorString = `rgba(${lightened.join(", ")}, 1)`; - - return lightenedColorString; -}; - -/** - * See also {@link GraphContainer} for additional settings which aren't expected to change in the graph's lifetime - */ -export const useSetDrawSettings = < - NodeSizing extends StaticNodeSizing | DynamicNodeSizing, ->( - graphState: GraphState, - config: GraphVizConfig, -) => { - const { palette } = useTheme(); - const sigma = useSigma(); - - const { isFullScreen } = useFullScreen(); - - useEffect(() => { - /** - * Controls what labels will be shown at which zoom levels. - */ - sigma.setSetting( - "labelRenderedSizeThreshold", - labelRenderedSizeThreshold[isFullScreen ? "fullScreen" : "normal"], - ); - - /** - * Provide a custom renderer for node labels. - */ - sigma.setSetting( - "defaultDrawNodeLabel", - (context: CanvasRenderingContext2D, data, settings) => { - if (!data.label) { - return; - } - - if (graphState.hoveredNodeId === data.nodeId) { - /** - * There's an inbuilt hover label renderer which we don't want to override - */ - return; - } - - const labelFontSize = settings.labelSize; - const font = settings.labelFont; - const weight = settings.labelWeight; - - context.font = `${weight} ${labelFontSize}px ${font}`; - - const nodeSize = data.size; - - const paddingX = 4; - const paddingY = 2; - - const labelBottomPadding = 3; - - const { maxLineWidth, lines } = getCanvasLines(context, data.label); - const width = maxLineWidth + paddingX * 2; - - const backgroundHeight = - labelFontSize * lines.length + - labelBottomPadding * lines.length + - 1 + - paddingY * 2; - - const backgroundStartY = data.y - backgroundHeight / 2; - - const xYWidthHeight = [ - data.x + nodeSize, - backgroundStartY, - width, - backgroundHeight, - ] as const; - - /** - * Draw the background for the label - */ - context.fillStyle = "#ffffffcc"; - context.beginPath(); - drawRoundRect(context, ...xYWidthHeight, 5); - context.fill(); - - /** - * Draw a border on the background - */ - context.beginPath(); - drawRoundRect(context, ...xYWidthHeight, 5); - context.strokeStyle = palette.gray[20]; - context.lineWidth = 1; - context.stroke(); - - context.fillStyle = palette.gray[90]; - for (let i = 0; i < lines.length; i++) { - /** - * Draw the label text - */ - context.fillText( - lines[i]!, - data.x + nodeSize + paddingX, - backgroundStartY + - paddingY + - labelFontSize + - (labelFontSize + labelBottomPadding) * i, - ); - } - }, - ); - - /** - * A 'reducer' in sigma terms – a function to dynamically draw a node. - * We use it to take account of the graph state (e.g. highlighted nodes). - */ - sigma.setSetting("nodeReducer", (node, data) => { - const nodeData = { ...data }; - - nodeData.color = - graphState.colorByNodeTypeId?.[nodeData.nodeTypeId] ?? nodeData.color; - - if ( - !graphState.selectedNodeId && - !graphState.hoveredNodeId && - !graphState.highlightedEdgePath - ) { - if (graphState.hoveredEdgeId) { - const graph = sigma.getGraph(); - const source = graph.source(graphState.hoveredEdgeId); - const target = graph.target(graphState.hoveredEdgeId); - if (source === node || target === node) { - nodeData.zIndex = 5; - nodeData.forceLabel = true; - } - } - - return nodeData; - } - - let allHighlightedNodes: Set; - if (graphState.highlightedEdgePath) { - const graph = sigma.getGraph(); - try { - allHighlightedNodes = new Set( - graphState.highlightedEdgePath.flatMap((edgeId) => { - const source = graph.source(edgeId); - const target = graph.target(edgeId); - return [source, target]; - }), - ); - } catch { - /** - * Setting the edge size/scale while there's a highlightedEdgePath causes a crash due to source/target of - * edges not being found - * @todo fix it so that this doesn't happen - */ - // eslint-disable-next-line no-param-reassign - graphState.highlightedEdgePath = null; - return nodeData; - } - } else { - allHighlightedNodes = new Set([ - graphState.selectedNodeId, - graphState.hoveredNodeId, - ...(graphState.neighborsByDepth ?? []).flatMap((set) => [...set]), - ]); - } - - if (!allHighlightedNodes.has(node)) { - if (!graphState.selectedNodeId && !graphState.highlightedEdgePath) { - /** - * Nodes are always drawn over edges by the library, so anything other than hiding non-highlighted nodes - * means that they can obscure the highlighted edges, as is the case here. - * - * If they are hidden, it is much more jarring when _hovering_ over nodes because of the rest of the graph - * fully disappears, so having the 'non-highlighted' nodes remain like this is a UX compromise. - */ - nodeData.color = "rgba(170, 170, 170, 0.7)"; - nodeData.borderColor = "rgba(170, 170, 170, 0.7)"; - nodeData.label = ""; - } else { - /** - * If the user has clicked on a node or highlighted a path, we hide everything else. - */ - nodeData.hidden = true; - } - } else { - nodeData.forceLabel = true; - } - - if ( - graphState.selectedNodeId === node || - graphState.hoveredNodeId === node - ) { - nodeData.zIndex = 3; - if (config.nodeSizing.mode !== "static") { - nodeData.label = `${nodeData.label} (${nodeData.significance})`; - } - } - - return nodeData; - }); - - sigma.setSetting("edgeReducer", (edge, data) => { - const edgeData = { ...data }; - - const graph = sigma.getGraph(); - const source = graph.source(edge); - const sourceData = graph.getNodeAttributes(source); - - const sourceColor = - graphState.colorByNodeTypeId?.[sourceData.nodeTypeId] ?? - sourceData.color; - - edgeData.color = lightenColor(sourceColor); - - if (edge === graphState.hoveredEdgeId) { - /** - * Set a minimum size on hover so it's easier to distinguish and click on - */ - edgeData.size = Math.max(edgeData.size, 8); - } - - if (edge === graphState.hoveredEdgeId || graphState.highlightedEdgePath) { - /** - * Show the edge's significance if it is hovered or if it is part of a highlighted path - */ - edgeData.forceLabel = true; - edgeData.label = `(${edgeData.significance})`; - } - - const selectedNode = - graphState.selectedNodeId ?? graphState.hoveredNodeId; - - if (!selectedNode && !graphState.highlightedEdgePath) { - if ( - edgeData.size < config.edgeSizing.nonHighlightedVisibleSizeThreshold - ) { - /** - * If we don't have any node hovered, clicked or any edge highlighted, - * hide the edge if it's below the threshold size - */ - edgeData.hidden = true; - return edgeData; - } - - return edgeData; - } - - const target = graph.target(edge); - - let showEdge: boolean = false; - if (graphState.highlightedEdgePath) { - if (graphState.highlightedEdgePath.includes(edge)) { - showEdge = true; - } - } else { - /** - * If we have highlighted nodes, we only draw the edge if both the source and target are highlighted. - */ - const allHighlightedNodes = new Set([ - selectedNode, - ...(graphState.neighborsByDepth ?? []).flatMap((set) => [...set]), - ]); - - let targetIsShown = false; - let sourceIsShown = false; - - for (const nodeId of allHighlightedNodes) { - if (source === nodeId) { - sourceIsShown = true; - } - if (target === nodeId) { - targetIsShown = true; - } - - if (sourceIsShown && targetIsShown) { - break; - } - } - - /** - * We don't want to highlight edges between nodes which happen to be connected but via an edge - * that would not have been followed for the configured traversal depth. - * In practice this means ignoring edges from or to the nodes where the traversal ended (depending on traversal - * direction). - */ - const nodesTraversalPassesThrough = new Set([ - selectedNode, - ...(graphState.neighborsByDepth ?? []) - /** - * neighborsByDepth is zero-based, e.g. the neighbors at depth 1 are at array position 0 - */ - .slice(0, config.nodeHighlighting.depth - 1) - .flatMap((set) => [...set]), - ]); - - let edgeWasFollowedInTraversal = false; - switch (config.nodeHighlighting.direction) { - case "All": { - edgeWasFollowedInTraversal = - nodesTraversalPassesThrough.has(source) || - nodesTraversalPassesThrough.has(target); - break; - } - case "In": { - edgeWasFollowedInTraversal = - nodesTraversalPassesThrough.has(target); - break; - } - case "Out": { - edgeWasFollowedInTraversal = - nodesTraversalPassesThrough.has(source); - break; - } - } - - showEdge = sourceIsShown && targetIsShown && edgeWasFollowedInTraversal; - } - - if (showEdge) { - edgeData.zIndex = Math.max(edgeData.size, 4); - } else { - edgeData.hidden = true; - } - - return edgeData; - }); - }, [ - config.nodeSizing.mode, - config.nodeHighlighting, - config.edgeSizing.nonHighlightedVisibleSizeThreshold, - isFullScreen, - palette, - sigma, - graphState, - ]); -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/use-layout.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/use-layout.tsx deleted file mode 100644 index cf96d0a9b98..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/use-layout.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useSigma } from "@react-sigma/core"; -import forceAtlas2 from "graphology-layout-forceatlas2"; -import FA2Layout from "graphology-layout-forceatlas2/worker"; -import { useCallback } from "react"; - -export const useLayout = () => { - const sigma = useSigma(); - - const layout = useCallback(() => { - const graph = sigma.getGraph(); - - /** - * Generate some default settings based on the graph - */ - const settings = forceAtlas2.inferSettings(graph); - - const forceAtlasLayout = new FA2Layout(graph, { - /** - * @see https://graphology.github.io/standard-library/layout-forceatlas2.html - * @see https://observablehq.com/@mef/forceatlas2-layout-settings-visualized - */ - settings: { - ...settings, - outboundAttractionDistribution: true, - gravity: 1, - scalingRatio: 10, - }, - }); - - forceAtlasLayout.start(); - - setInterval(() => { - /** - * The layout process will stop automatically if the algorithm has converged, but this is not guaranteed to happen. - * A few seconds seems sufficient for graphs of ~10k items (nodes and edges). - */ - forceAtlasLayout.stop(); - }, 2_000); - - return () => { - forceAtlasLayout.kill(); - }; - }, [sigma]); - - return layout; -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/zoom-control.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/zoom-control.tsx deleted file mode 100644 index a29ee70afba..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/zoom-control.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useSigma } from "@react-sigma/core"; - -import { - MagnifyingGlassMinusLightIcon, - MagnifyingGlassPlusLightIcon, -} from "@hashintel/design-system"; - -import { GrayToBlueIconButton } from "../../gray-to-blue-icon-button"; - -export const ZoomControl = () => { - const sigma = useSigma(); - - const camera = sigma.getCamera(); - - return ( - <> - camera.animatedUnzoom(1.5)}> - - - camera.animatedZoom(1.5)}> - - - - ); -}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-visualizer.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-visualizer.tsx new file mode 100644 index 00000000000..cf70d9c9553 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-visualizer.tsx @@ -0,0 +1,206 @@ +/** + * Mounts the Deck.gl {@link Scene} in a container, shows a loading overlay until + * the first structure frame, and wires zoom/fit controls. + * + * Generic over node identity: the entity bridge mounts it on a + * `WorkerHandle` (`NodeId = EntityId`), the type bridge on a + * `TypeWorkerHandle` (`NodeId = VersionedUrl`). See {@link "./render/scene/handle"}. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { GraphControls } from "./components/graph-controls"; +import { GRAPH_CAMERA_ZOOM_STEP } from "./interactivity/graph-camera-commands"; +import { Scene } from "./render/scene/scene"; + +import type { SceneHandle } from "./render/scene/handle"; +import type { + ClusterHover, + FlatEdgeHover, + HighwayHover, + LabelPolicy, + NodeHover, + NodeLabel, + NodeSelection, + SceneOptions, +} from "./render/scene/scene"; +import type { EntityId } from "@blockprotocol/type-system"; +import type { ReactElement } from "react"; + +interface GraphVisualizerProps< + NodeId extends string, + NodeIndex extends number, + EdgeIndex extends number, +> { + readonly handle: SceneHandle; + readonly loadingComponent: ReactElement; + /** Accessible name for the graph region; defaults to the entity flavour. */ + readonly ariaLabel?: string; + readonly onNodeHover?: (hover: NodeHover | null) => void; + readonly onHighwayHover?: (hover: HighwayHover | null) => void; + readonly onNodeSelect?: (selection: NodeSelection | null) => void; + /** Open the underlying link entities of an aggregated "highway" edge in a table. */ + readonly onOpenLinkTable?: (linkNodeIds: readonly NodeId[]) => void; + /** Report a hovered wholly-frontier cluster bubble (to offer loading its entities), or null on leave. */ + readonly onClusterHover?: (hover: ClusterHover | null) => void; + /** Report a hovered non-node flat edge (a type graph's link-type edge), or null on leave. */ + readonly onEdgeHover?: (hover: FlatEdgeHover | null) => void; + /** + * Resolve a node's display label (its name) for always-on graph labels. + * Invoked when the label set is rebuilt (zoom / structure change), never per frame. + */ + readonly resolveNodeLabel?: (nodeId: NodeId) => string | undefined; + /** + * Resolve a node's type icon to an atlas key (emoji or image URL), or null for none. + * Invoked when the flat-tier icon set is rebuilt (structure change), never per frame. + */ + readonly resolveNodeIcon?: (nodeId: NodeId) => string | null; + /** Receive the always-on hub labels (with current on-screen positions) to overlay as HTML. */ + readonly onNodeLabels?: (labels: readonly NodeLabel[]) => void; + /** Which dots get always-on labels; omit for the entity-hub defaults. */ + readonly labelPolicy?: Partial; + /** + * Surface the live {@link Scene} to debug consumers (the dev harness render + * benchmark drives captures and camera sweeps through it). Called with null + * on unmount. + */ + readonly onSceneReady?: ( + scene: Scene | null, + ) => void; +} + +export const GraphVisualizer = < + NodeId extends string = EntityId, + NodeIndex extends number = number, + EdgeIndex extends number = number, +>({ + handle, + loadingComponent, + ariaLabel = "Entity relationship graph", + onNodeHover, + onHighwayHover, + onNodeSelect, + onOpenLinkTable, + onClusterHover, + onEdgeHover, + resolveNodeLabel, + resolveNodeIcon, + onNodeLabels, + labelPolicy, + onSceneReady, +}: GraphVisualizerProps): ReactElement => { + const containerRef = useRef(null); + const sceneRef = useRef | null>(null); + const [hasStructure, setHasStructure] = useState(false); + const handleFirstStructure = useCallback(() => setHasStructure(true), []); + + // The scene reads the policy once at mount; a mid-session policy change is + // not a supported flow (hosts pass a constant). + const sceneOptions = useMemo( + () => ({ labelPolicy }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return undefined; + } + + // A new handle means a fresh worker with no committed structure yet: show + // the loading overlay again until its first structure frame arrives. + setHasStructure(false); + + const scene = new Scene( + container, + handle, + { + onNodeHover, + onHighwayHover, + onNodeSelect, + onOpenLinkTable, + onClusterHover, + onEdgeHover, + resolveNodeLabel, + resolveNodeIcon, + onNodeLabels, + onFirstStructure: handleFirstStructure, + }, + sceneOptions, + ); + sceneRef.current = scene; + onSceneReady?.(scene); + + return () => { + onSceneReady?.(null); + scene.dispose(); + sceneRef.current = null; + }; + // Only `handle` re-mounts the scene; callbacks are kept current below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handle]); + + // Keep the scene's interaction callbacks current without re-mounting Deck. + useEffect(() => { + sceneRef.current?.setCallbacks({ + onNodeHover, + onHighwayHover, + onNodeSelect, + onOpenLinkTable, + onClusterHover, + onEdgeHover, + resolveNodeLabel, + resolveNodeIcon, + onNodeLabels, + onFirstStructure: handleFirstStructure, + }); + }, [ + onNodeHover, + onHighwayHover, + onNodeSelect, + onOpenLinkTable, + onClusterHover, + onEdgeHover, + resolveNodeLabel, + resolveNodeIcon, + onNodeLabels, + handleFirstStructure, + ]); + + return ( +
+
+ {!hasStructure && ( +
+ {loadingComponent} +
+ )} + sceneRef.current?.zoomBy(GRAPH_CAMERA_ZOOM_STEP)} + onZoomOut={() => sceneRef.current?.zoomBy(-GRAPH_CAMERA_ZOOM_STEP)} + onFitView={() => sceneRef.current?.fitToContent()} + /> +
+ ); +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/ids.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/ids.ts new file mode 100644 index 00000000000..0ef47bd876e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/ids.ts @@ -0,0 +1,78 @@ +import { Branded as make } from "./brand"; + +/** + * Branded primitive types and domain unions specific to the visualization. + * + * Types that already exist in the ecosystem (EntityId, VersionedUrl, LinkData) + * are imported from @blockprotocol/type-system. This file defines only the + * concepts that are unique to the graph visualizer. + */ +import type { Branded } from "./brand"; + +export type EntityIndex = Branded; +export const EntityIndex = make(); + +/** + * Force-layout node ids are stringified {@link EntityIndex} values (the layout + * engines key nodes by string). These two helpers are the only sanctioned + * conversion in each direction; keep them inverse of one another. + */ +export const nodeIdForEntityIndex = (entityIdx: EntityIndex): string => + String(entityIdx); + +export const entityIndexFromNodeId = (nodeId: string): EntityIndex => + // nodeId is always String(entityIdx) from nodeIdForEntityIndex; Number round-trips + // safely for layout-sized indices. + Number(nodeId) as EntityIndex; + +export type LinkId = Branded; +export const LinkId = make(); + +export type TypeId = Branded; +export const TypeId = make(); + +/** + * Type-graph force-layout node ids are stringified {@link TypeId} values, + * mirroring the entity pair above. These two helpers are the only sanctioned + * conversion in each direction; keep them inverse of one another. + */ +export const nodeIdForTypeId = (typeId: TypeId): string => String(typeId); + +export const typeIdFromNodeId = (nodeId: string): TypeId => + // nodeId is always String(typeId) from nodeIdForTypeId; Number round-trips + // safely for registry-sized ids. + Number(nodeId) as TypeId; + +/** Sorted, comma-joined TypeIdx values. Canonical grouping key. */ +export type TypeSetKey = Branded; +export const TypeSetKey = make(); + +export type TypeSetId = Branded; +export const TypeSetId = make(); + +export type LabelId = Branded; +export const LabelId = make(); + +export type ClusterId = Branded; +export const ClusterId = make(); + +/** Canonical key for a pair of connected clusters (lexicographic order). */ +export type PairKey = Branded; +export const PairKey = make(); + +/** Stable semantic identity for a visual edge (survives LOD transitions). */ +export type VisualEdgeKey = Branded; +export const VisualEdgeKey = make(); + +export type VizMode = "flat-force" | "community-force" | "hierarchical-lod"; + +export type LodMode = "cluster" | "children" | "entities-pending" | "entities"; + +export type ClusterKind = + | "root" + | "family" + | "type-set" + | "other" + | "community" + | "embedding" + | "entity-bucket"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/interactivity/frontier-expansion.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/interactivity/frontier-expansion.ts new file mode 100644 index 00000000000..687009fcd85 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/interactivity/frontier-expansion.ts @@ -0,0 +1,45 @@ +/** + * Batching and dedup helpers for expanding a frontier: splitting a large id set into + * GraphQL-sized requests, and filtering out ids already resolved or already in flight. + */ +import type { EntityId } from "@blockprotocol/type-system"; + +/** + * Max entity ids per GraphQL expansion request. + * + * @defaultValue 50. Raise for fewer round trips; lower to reduce payload size and + * timeout risk. + */ +export const FRONTIER_EXPANSION_BATCH_SIZE = 50; + +/** + * Splits a frontier id set into consecutive chunks of at most + * {@link FRONTIER_EXPANSION_BATCH_SIZE}, preserving input order. + */ +export function frontierExpansionBatches( + entityIds: readonly EntityId[], +): EntityId[][] { + const batches: EntityId[][] = []; + for ( + let start = 0; + start < entityIds.length; + start += FRONTIER_EXPANSION_BATCH_SIZE + ) { + batches.push(entityIds.slice(start, start + FRONTIER_EXPANSION_BATCH_SIZE)); + } + return batches; +} + +/** + * Filters `entityIds` down to those neither already expanded nor already being + * fetched, so a repeated expansion request does not re-fetch or double-fetch a node. + */ +export function freshFrontierIds( + entityIds: readonly EntityId[], + expanded: ReadonlySet, + inFlight: ReadonlySet, +): EntityId[] { + return entityIds.filter( + (entityId) => !expanded.has(entityId) && !inFlight.has(entityId), + ); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/interactivity/graph-camera-commands.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/interactivity/graph-camera-commands.ts new file mode 100644 index 00000000000..94d7ec9e396 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/interactivity/graph-camera-commands.ts @@ -0,0 +1,7 @@ +/** + * Multiplicative zoom step applied per zoom-in/zoom-out button click. + * + * @defaultValue 0.7. Larger values jump zoom levels faster per click, at the cost of + * coarser control near a target zoom level. + */ +export const GRAPH_CAMERA_ZOOM_STEP = 0.7; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/interactivity/use-graph-guidance-dismissal.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/interactivity/use-graph-guidance-dismissal.ts new file mode 100644 index 00000000000..c6805347b77 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/interactivity/use-graph-guidance-dismissal.ts @@ -0,0 +1,32 @@ +/** First-visit guidance card persistence via localStorage. */ +import { useCallback, useEffect, useState } from "react"; + +const GRAPH_GUIDANCE_DISMISSED_KEY = + "hash.graph-visualizer-v2.guidance-dismissed"; + +/** + * Tracks whether the first-visit guidance card should show, persisting dismissal + * across sessions under the `GRAPH_GUIDANCE_DISMISSED_KEY` localStorage key. + * + * `shouldShowGuidance` starts `false` and flips after the initial effect reads + * localStorage, so the card never flashes on for a returning user during hydration. + */ +export function useGraphGuidanceDismissal(): { + readonly shouldShowGuidance: boolean; + readonly dismissGuidance: () => void; +} { + const [shouldShowGuidance, setShouldShowGuidance] = useState(false); + + useEffect(() => { + setShouldShowGuidance( + window.localStorage.getItem(GRAPH_GUIDANCE_DISMISSED_KEY) !== "true", + ); + }, []); + + const dismissGuidance = useCallback(() => { + window.localStorage.setItem(GRAPH_GUIDANCE_DISMISSED_KEY, "true"); + setShouldShowGuidance(false); + }, []); + + return { shouldShowGuidance, dismissGuidance }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/interactivity/use-simulation-pause.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/interactivity/use-simulation-pause.ts new file mode 100644 index 00000000000..7471bb7771c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/interactivity/use-simulation-pause.ts @@ -0,0 +1,99 @@ +/** + * Decides when a visualizer instance's simulation should be frozen because + * the user cannot see it, and keeps the worker in sync with that decision. + * + * Three independent "not visible" signals are OR-ed together: + * + * 1. `occluded`: the caller's knowledge of UI stacked over the visualizer + * (a slide covering the page, a slide covered by a later slide). Only the + * caller can know this; z-order occlusion is invisible to the browser + * APIs below. + * 2. Document visibility: the tab is hidden. Crucial because the worker's + * MessageChannel tick loop is not throttled in background tabs the way + * rAF is, so an unsettled layout would otherwise burn CPU indefinitely. + * 3. Viewport intersection: the container is scrolled out of view or + * `display: none` (an IntersectionObserver reports both as + * non-intersecting). + * + * The worker keeps ingesting and committing while paused; only the tick + * scheduler idles, and layouts resume from the same positions (the + * `SET_SIMULATION_PAUSED` contract in `worker/protocol.ts`). + */ +import { useCallback, useEffect, useState } from "react"; + +import type { FrameHandle } from "../render/frame-connection"; + +interface UseSimulationPauseOptions { + /** Either lifecycle's connection: pausing is lifecycle-neutral. */ + readonly handle: FrameHandle | undefined; + /** Only send once the worker exists (INIT processed); earlier posts would be dropped. */ + readonly ready: boolean; + /** Caller-known occlusion: UI layered over an otherwise-visible visualizer. */ + readonly occluded: boolean; +} + +/** + * Returns the ref to attach to the visualizer's root element. It is a + * callback ref because the element mounts conditionally after the worker + * exists; a static ref filled in later would never re-trigger observation. + */ +export function useSimulationPause({ + handle, + ready, + occluded, +}: UseSimulationPauseOptions): (element: HTMLElement | null) => void { + const [container, setContainer] = useState(null); + const [documentHidden, setDocumentHidden] = useState(false); + const [offscreen, setOffscreen] = useState(false); + + useEffect(() => { + const update = () => { + setDocumentHidden(document.visibilityState === "hidden"); + }; + + update(); + document.addEventListener("visibilitychange", update); + + return () => { + document.removeEventListener("visibilitychange", update); + }; + }, []); + + useEffect(() => { + if (!container) { + // No container mounted (loading/error branches): the viewport signal + // simply reports visible; the other signals still apply. + setOffscreen(false); + return; + } + + const observer = new IntersectionObserver( + (observations) => { + const latest = observations.at(-1); + if (latest) { + setOffscreen(!latest.isIntersecting); + } + }, + // Any visible sliver counts as on-screen: pausing is for instances the + // user has scrolled away from entirely, not partially. + { threshold: 0 }, + ); + + observer.observe(container); + return () => { + observer.disconnect(); + }; + }, [container]); + + const paused = occluded || documentHidden || offscreen; + + useEffect(() => { + if (handle && ready) { + handle.setSimulationPaused(paused); + } + }, [handle, ready, paused]); + + return useCallback((element: HTMLElement | null) => { + setContainer(element); + }, []); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/layout-fixture-capture.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/layout-fixture-capture.ts new file mode 100644 index 00000000000..b4345783775 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/layout-fixture-capture.ts @@ -0,0 +1,96 @@ +/** + * Debug affordance: dump the live flat-tier layout as a replayable JSON + * fixture (positions, radii, deduped edges, Louvain labels) from ANY surface + * that renders the visualizer -- including production pages -- without UI. + * + * In DevTools, run: + * + * __hashGraphCaptureLayoutFixture() + * + * to download `graph-fixture-n-e.json`, the exact shape + * `forceGraphFromCapturedFixture` (bench-fixtures.ts) replays in benches and + * tests. With several visualizers mounted (a slide stacked over the entities + * page), the most recently mounted one is captured; unmounting restores the + * one below. The dev harness's Capture button shares + * {@link downloadLayoutFixture}. + */ +import { useEffect } from "react"; + +import type { EntityWorkerHandle } from "./render/entity-worker-connection"; +import type { CapturedLayoutFixture } from "./worker/protocol"; + +/** Trigger a browser download of the fixture; returns the filename. */ +export function downloadLayoutFixture(fixture: CapturedLayoutFixture): string { + const json = JSON.stringify(fixture); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `graph-fixture-${fixture.nodes.length}n-${fixture.edges.length}e.json`; + anchor.click(); + URL.revokeObjectURL(url); + return anchor.download; +} + +type CaptureFn = () => Promise; + +interface CaptureHookWindow { + __hashGraphCaptureLayoutFixture?: CaptureFn; +} + +/** + * Mounted visualizers' capture functions, oldest first. The window hook + * always points at the newest live one, so stacked surfaces (entity slide + * over the entities page) capture the topmost graph and fall back to the + * one below when it closes. + */ +const liveCaptures: CaptureFn[] = []; + +function syncWindowHook(): void { + const hookWindow = window as unknown as CaptureHookWindow; + const newest = liveCaptures.at(-1); + if (newest) { + hookWindow.__hashGraphCaptureLayoutFixture = newest; + } else { + delete hookWindow.__hashGraphCaptureLayoutFixture; + } +} + +/** + * Registers the `__hashGraphCaptureLayoutFixture()` console hook for the + * lifetime of `handle`. Calling it captures the live flat-tier layout from + * the worker and downloads it as JSON (null + a console warning when no + * flat layout is live, e.g. hierarchical mode). + */ +export function useLayoutFixtureCaptureHook( + handle: EntityWorkerHandle | undefined, +): void { + useEffect(() => { + if (!handle) { + return undefined; + } + const capture: CaptureFn = async () => { + const fixture = await handle.captureLayoutFixture(); + if (!fixture) { + // eslint-disable-next-line no-console -- debug console hook + console.warn( + "__hashGraphCaptureLayoutFixture: no flat-tier layout live (hierarchical mode or empty graph)", + ); + return null; + } + const filename = downloadLayoutFixture(fixture); + // eslint-disable-next-line no-console -- debug console hook + console.info(`__hashGraphCaptureLayoutFixture: downloaded ${filename}`); + return fixture; + }; + liveCaptures.push(capture); + syncWindowHook(); + return () => { + const index = liveCaptures.indexOf(capture); + if (index >= 0) { + liveCaptures.splice(index, 1); + } + syncWindowHook(); + }; + }, [handle]); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/math/color.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/math/color.ts new file mode 100644 index 00000000000..4452afcfc11 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/math/color.ts @@ -0,0 +1,94 @@ +/* eslint-disable id-length */ +/** Color space conversions. */ + +// OKLab conversion coefficients from the OKLab spec (Bjorn Ottosson). + +/** sRGB gamma encoding (linear to sRGB transfer function). */ +function gammaEncode(linear: number): number { + if (linear >= 0.0031308) { + return 1.055 * linear ** (1 / 2.4) - 0.055; + } + return 12.92 * linear; +} + +function clampByte(value: number): number { + return Math.round(Math.max(0, Math.min(1, value)) * 255); +} + +/** + * Convert an OKLCH color to sRGB. + * + * `lightness` in [0, 1], `chroma` in [0, ~0.4] (sRGB gamut), `hue` in + * [0, 360). Returns [r, g, b] each in [0, 255], clamped to sRGB. + * + * Steps: OKLCH -> OKLab (polar to cartesian) -> LMS (cube roots) -> linear sRGB + * (OKLab spec coefficients) -> gamma-encoded, clamped sRGB bytes. + */ +export function oklchToRgb( + lightness: number, + chroma: number, + hue: number, +): [number, number, number] { + const hRad = (hue * Math.PI) / 180; + const a = chroma * Math.cos(hRad); + const b = chroma * Math.sin(hRad); + + const l_ = lightness + 0.3963377774 * a + 0.2158037573 * b; + const m_ = lightness - 0.1055613458 * a - 0.0638541728 * b; + const s_ = lightness - 0.0894841775 * a - 1.291485548 * b; + + const l = l_ * l_ * l_; + const m = m_ * m_ * m_; + const s = s_ * s_ * s_; + + const rLin = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + const gLin = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + const bLin = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s; + + return [ + clampByte(gammaEncode(rLin)), + clampByte(gammaEncode(gLin)), + clampByte(gammaEncode(bLin)), + ]; +} + +/** HSL to sRGB. `hue` in [0, 360), `saturation` and `lightness` in [0, 1]. */ +export function hslToRgb( + hue: number, + saturation: number, + lightness: number, +): [number, number, number] { + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const sextant = hue / 60; + const second = chroma * (1 - Math.abs((sextant % 2) - 1)); + const match = lightness - chroma / 2; + + let red = 0; + let green = 0; + let blue = 0; + if (sextant < 1) { + red = chroma; + green = second; + } else if (sextant < 2) { + red = second; + green = chroma; + } else if (sextant < 3) { + green = chroma; + blue = second; + } else if (sextant < 4) { + green = second; + blue = chroma; + } else if (sextant < 5) { + red = second; + blue = chroma; + } else { + red = chroma; + blue = second; + } + + return [ + Math.round((red + match) * 255), + Math.round((green + match) * 255), + Math.round((blue + match) * 255), + ]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/math/hash.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/math/hash.ts new file mode 100644 index 00000000000..709b4cf92d9 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/math/hash.ts @@ -0,0 +1,98 @@ +/* eslint-disable no-bitwise, id-length, no-param-reassign */ + +function add32(a: number, b: number): number { + return (a + b) >>> 0; +} + +function mul32(a: number, b: number): number { + return Math.imul(a, b) >>> 0; +} + +function rotl32(x: number, r: number): number { + return ((x << r) | (x >>> (32 - r))) >>> 0; +} + +function getBlock32(key: Uint8Array, i: number): number { + const offset = i * 4; + + return ( + (key[offset]! | + (key[offset + 1]! << 8) | + (key[offset + 2]! << 16) | + (key[offset + 3]! << 24)) >>> + 0 + ); +} + +/** + * MurmurHash3's 32-bit finalizer: a full-avalanche mix, so consecutive or + * otherwise structured inputs map to well-spread words. Use it whenever + * integer keys need decorrelating before they are combined (sums, xors, + * bucket indices). + */ +export function fmix32(k: number): number { + k ^= k >>> 16; + k = mul32(k, 0x85ebca6b); + k ^= k >>> 13; + k = mul32(k, 0xc2b2ae35); + k ^= k >>> 16; + return k; +} + +/** MurmurHash3 x86 32-bit (vendored from timepp/murmurhash). */ +export function murmur3(key: Uint8Array, seed: number = 0): number { + let h1 = seed >>> 0; + + const length = key.length; + const blocks = Math.floor(length / 4); + + const c1 = 0xcc9e2d51; + const c2 = 0x1b873593; + + for (let i = 0; i < blocks; i++) { + let k1 = getBlock32(key, i); + k1 = mul32(k1, c1); + k1 = rotl32(k1, 15); + k1 = mul32(k1, c2); + h1 ^= k1; + h1 = rotl32(h1, 13); + h1 = add32(mul32(h1, 5), 0xe6546b64); + } + + const tail = key.slice(blocks * 4); + + let k1 = 0; + + if (tail.length >= 3) { + k1 ^= tail[2]! << 16; + } + if (tail.length >= 2) { + k1 ^= tail[1]! << 8; + } + if (tail.length >= 1) { + k1 ^= tail[0]! << 0; + k1 = mul32(k1, c1); + k1 = rotl32(k1, 15); + k1 = mul32(k1, c2); + h1 ^= k1; + } + + h1 ^= length; + h1 = fmix32(h1); + + return h1; +} + +export function murmur3String(key: string, seed: number = 0): number { + return murmur3(new TextEncoder().encode(key), seed); +} + +/** + * Map a string to the unit interval [0, 1) via MurmurHash3. + * + * Deterministic: the same string always produces the same value, + * regardless of call order or session. + */ +export function murmur3StringUnit(value: string): number { + return murmur3String(value) / 0x100000000; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/math/random.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/math/random.test.ts new file mode 100644 index 00000000000..f3a97ac7832 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/math/random.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { mulberry32, parkMillerRng } from "./random"; + +const draw = (next: () => number, count: number) => + Array.from({ length: count }, () => next()); + +describe("mulberry32", () => { + it("reproduces the same sequence for the same seed", () => { + expect(draw(mulberry32(12345), 8)).toEqual(draw(mulberry32(12345), 8)); + }); + + it("returns values in [0, 1)", () => { + const next = mulberry32(7); + for (let i = 0; i < 1000; i++) { + const value = next(); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + } + }); + + it("diverges for different seeds", () => { + expect(draw(mulberry32(1), 4)).not.toEqual(draw(mulberry32(2), 4)); + }); +}); + +describe("parkMillerRng", () => { + it("reproduces the same sequence for the same seed", () => { + expect(draw(parkMillerRng(42), 8)).toEqual(draw(parkMillerRng(42), 8)); + }); + + it("returns values in (0, 1)", () => { + const next = parkMillerRng(99); + for (let i = 0; i < 1000; i++) { + const value = next(); + expect(value).toBeGreaterThan(0); + expect(value).toBeLessThan(1); + } + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/math/random.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/math/random.ts new file mode 100644 index 00000000000..999dcc56c41 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/math/random.ts @@ -0,0 +1,61 @@ +/* eslint-disable id-length, no-bitwise */ +/** Deterministic seeded PRNGs for reproducible layouts. */ + +/** + * mulberry32: a 32-bit PRNG. Returns a float in [0, 1). + * + * A given seed always produces the same sequence. + */ +export function mulberry32(seed: number): () => number { + let state = seed >>> 0; + + return () => { + state = (state + 0x6d2b79f5) | 0; + let t = Math.imul(state ^ (state >>> 15), 1 | state); + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Park-Miller minimal-standard PRNG. Returns a float in (0, 1); + * never returns exactly 0 or 1. + * + * Full-period: cycles through all 2^31 - 2 values before repeating. + */ +export function parkMillerRng(seed: number): () => number { + let state = seed % 2147483647; + if (state <= 0) { + state += 2147483646; + } + + return () => { + state = (state * 48271) % 2147483647; + return state / 2147483647; + }; +} + +/** + * Fisher-Yates shuffle driven by an xorshift PRNG. + * Returns a new array; the input is not mutated. + */ +export function deterministicShuffle( + indices: number[], + seed: number, +): number[] { + const result = [...indices]; + let state = seed * 0x9e3779b9; + + for (let idx = result.length - 1; idx > 0; idx--) { + state = (state ^ (state << 13)) | 0; + state = (state ^ (state >>> 17)) | 0; + state = (state ^ (state << 5)) | 0; + const target = (state >>> 0) % (idx + 1); + // idx and target are in [0, result.length) from the Fisher-Yates loop bounds. + const temp = result[idx]!; + result[idx] = result[target]!; + result[target] = temp; + } + + return result; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-corridors.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-corridors.test.ts new file mode 100644 index 00000000000..35c555df2cb --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-corridors.test.ts @@ -0,0 +1,569 @@ +/** + * Corridor planner tests: the community-bubble CONNECTIVITY guarantee. + * + * The contract under test: for every rendered community, the metaball field + * (point kernels + planned capsule corridors, as the shader sums it) has ONE + * connected above-threshold region containing all members no matter how they + * clump spatially, while corridors stay clear of (or narrow over) + * foreign nodes, deterministically. + */ +import { describe, expect, it } from "vitest"; + +import { + BUBBLE_ISO_THRESHOLD, + CORRIDOR_ANCHOR_CAP, + CORRIDOR_FIELD_RADIUS, + CORRIDOR_NARROW_FACTOR, + evaluateBubbleField, + pairMergeDistance, + planBubbleCorridors, +} from "./bubble-corridors"; + +import type { Position } from "../geometry"; +import type { CorridorPlan } from "./bubble-corridors"; + +/** Field radius matching the production base default (50). */ +const FIELD_RADIUS = 50; +const ISO_THRESHOLD = BUBBLE_ISO_THRESHOLD; + +/** SAB record layout matching `FLAT_HEADER_BYTES` / `FLAT_RECORD_BYTES` (header 8 B, record 20 B). */ +const HEADER_FLOATS = 2; +const RECORD_FLOATS = 5; + +interface WorldNode extends Position { + readonly radius?: number; + /** −1 = no community. */ + readonly community: number; +} + +interface World { + readonly plan: CorridorPlan; + readonly segmentsOf: (ci: number) => { + readonly aSlot: number; + readonly bSlot: number; + readonly ax: number; + readonly ay: number; + readonly bx: number; + readonly by: number; + readonly radius: number; + }[]; + readonly membersOf: (ci: number) => readonly (readonly [number, number])[]; +} + +/** + * Builds a {@link World} whose {@link CorridorPlan} has SAB-shaped floats, + * kept communities in first-seen order, members in node-index order, and + * stride-4 point texels. + */ +function makeWorld( + nodes: readonly WorldNode[], + keptCommunityIds: readonly number[], + fieldRadius = FIELD_RADIUS, +): World { + const floats = new Float32Array(HEADER_FLOATS + nodes.length * RECORD_FLOATS); + const membership = new Int32Array(nodes.length); + for (const [idx, node] of nodes.entries()) { + floats[HEADER_FLOATS + idx * RECORD_FLOATS] = node.x; + floats[HEADER_FLOATS + idx * RECORD_FLOATS + 1] = node.y; + floats[HEADER_FLOATS + idx * RECORD_FLOATS + 2] = node.radius ?? 8; + membership[idx] = node.community; + } + + const memberSlots: number[][] = keptCommunityIds.map((community) => + nodes.flatMap((node, idx) => (node.community === community ? [idx] : [])), + ); + const totalMembers = memberSlots.reduce((sum, list) => sum + list.length, 0); + const ranges = new Float32Array(keptCommunityIds.length * 2); + const segmentStorageOffsets = new Int32Array(keptCommunityIds.length); + const pointTexels = new Float32Array(totalMembers * 4); + let offset = 0; + let segmentStorage = 0; + const segmentCapacity = memberSlots.reduce( + (sum, list) => sum + Math.max(0, list.length - 1) * 2, + 0, + ); + for (const [ci, list] of memberSlots.entries()) { + ranges[ci * 2] = offset; + ranges[ci * 2 + 1] = list.length; + segmentStorageOffsets[ci] = segmentStorage; + segmentStorage += Math.max(0, list.length - 1) * 2; + for (const idx of list) { + pointTexels[offset * 4] = nodes[idx]!.x; + pointTexels[offset * 4 + 1] = nodes[idx]!.y; + offset += 1; + } + } + + const plan: CorridorPlan = { + keptCount: keptCommunityIds.length, + ranges, + communityIds: Int32Array.from(keptCommunityIds), + pointTexels, + fieldRadii: new Float32Array(keptCommunityIds.length).fill(fieldRadius), + replan: null, + floats, + headerFloats: HEADER_FLOATS, + recordFloats: RECORD_FLOATS, + membership, + nodeCount: nodes.length, + segmentSlots: new Int32Array(segmentCapacity * 2), + segmentRadius: new Float32Array(segmentCapacity), + segmentCounts: new Int32Array(keptCommunityIds.length), + segmentStorageOffsets, + }; + + return { + plan, + segmentsOf: (ci) => { + const list = []; + const start = plan.segmentStorageOffsets[ci]!; + for (let segment = 0; segment < plan.segmentCounts[ci]!; segment++) { + const aSlot = plan.segmentSlots[(start + segment) * 2]!; + const bSlot = plan.segmentSlots[(start + segment) * 2 + 1]!; + list.push({ + aSlot, + bSlot, + ax: pointTexels[aSlot * 4]!, + ay: pointTexels[aSlot * 4 + 1]!, + bx: pointTexels[bSlot * 4]!, + by: pointTexels[bSlot * 4 + 1]!, + radius: plan.segmentRadius[start + segment]!, + }); + } + return list; + }, + membersOf: (ci) => { + const start = ranges[ci * 2]!; + const count = ranges[ci * 2 + 1]!; + const list: (readonly [number, number])[] = []; + for (let member = 0; member < count; member++) { + list.push([ + pointTexels[(start + member) * 4]!, + pointTexels[(start + member) * 4 + 1]!, + ]); + } + return list; + }, + }; +} + +/** A tight 4-node clump centred on (cx, cy). */ +function clump(cx: number, cy: number, community: number): WorldNode[] { + return [ + { x: cx - 15, y: cy - 12, community }, + { x: cx + 15, y: cy - 12, community }, + { x: cx - 15, y: cy + 12, community }, + { x: cx + 15, y: cy + 12, community }, + ]; +} + +/** + * Count the connected above-threshold components of a community's field that + * contain at least one member (raster flood fill, cell ≪ corridor width). + */ +function fieldComponentsContainingMembers( + world: World, + ci: number, + { + withSegments = true, + fieldRadius = FIELD_RADIUS, + }: { withSegments?: boolean; fieldRadius?: number } = {}, +): number { + const members = world.membersOf(ci); + const segments = withSegments + ? world.segmentsOf(ci).map((segment) => ({ + ax: segment.ax, + ay: segment.ay, + bx: segment.bx, + by: segment.by, + radius: segment.radius, + })) + : []; + const pad = fieldRadius; + const minX = Math.min(...members.map(([mx]) => mx)) - pad; + const maxX = Math.max(...members.map(([mx]) => mx)) + pad; + const minY = Math.min(...members.map(([, my]) => my)) - pad; + const maxY = Math.max(...members.map(([, my]) => my)) + pad; + // Narrow corridors are ~0.49·narrowRadius wide at the threshold; sample at + // a quarter of that so the raster cannot pinch a genuinely connected ribbon. + const cell = (CORRIDOR_FIELD_RADIUS * CORRIDOR_NARROW_FACTOR * 0.49) / 4; + const cols = Math.ceil((maxX - minX) / cell) + 1; + const rows = Math.ceil((maxY - minY) / cell) + 1; + + const inside = new Uint8Array(cols * rows); + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const field = evaluateBubbleField( + minX + col * cell, + minY + row * cell, + members, + segments, + fieldRadius, + ); + inside[row * cols + col] = field >= ISO_THRESHOLD ? 1 : 0; + } + } + + const componentOf = new Int32Array(cols * rows).fill(-1); + let componentCount = 0; + const stack: number[] = []; + for (const [memberX, memberY] of members) { + const col = Math.round((memberX - minX) / cell); + const row = Math.round((memberY - minY) / cell); + const seed = row * cols + col; + if (inside[seed] !== 1) { + throw new Error("member cell below threshold (fixture broken)"); + } + if (componentOf[seed] !== -1) { + continue; + } + const component = componentCount; + componentCount += 1; + stack.push(seed); + componentOf[seed] = component; + while (stack.length > 0) { + const cellIdx = stack.pop()!; + const cellRow = Math.floor(cellIdx / cols); + const cellCol = cellIdx % cols; + for (const [dCol, dRow] of [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + ] as const) { + const nCol = cellCol + dCol; + const nRow = cellRow + dRow; + if (nCol < 0 || nCol >= cols || nRow < 0 || nRow >= rows) { + continue; + } + const neighbour = nRow * cols + nCol; + if (inside[neighbour] === 1 && componentOf[neighbour] === -1) { + componentOf[neighbour] = component; + stack.push(neighbour); + } + } + } + } + return componentCount; +} + +/** Union-find spanning check: do the segments connect every member slot? */ +function segmentsSpanAllMembers(world: World, ci: number): boolean { + const start = world.plan.ranges[ci * 2]!; + const count = world.plan.ranges[ci * 2 + 1]!; + const parent = Array.from({ length: count }, (_, member) => member); + const find = (member: number): number => { + let root = member; + while (parent[root] !== root) { + root = parent[root]!; + } + return root; + }; + for (const segment of world.segmentsOf(ci)) { + parent[find(segment.aSlot - start)] = find(segment.bSlot - start); + } + const root = find(0); + for (let member = 1; member < count; member++) { + if (find(member) !== root) { + return false; + } + } + return true; +} + +function segmentDistanceTo( + segment: { ax: number; ay: number; bx: number; by: number }, + px: number, + py: number, +): number { + const abX = segment.bx - segment.ax; + const abY = segment.by - segment.ay; + const lenSq = abX * abX + abY * abY; + const raw = + lenSq > 0 ? ((px - segment.ax) * abX + (py - segment.ay) * abY) / lenSq : 0; + const along = Math.max(0, Math.min(1, raw)); + return Math.hypot( + px - (segment.ax + abX * along), + py - (segment.ay + abY * along), + ); +} + +describe("bubble corridors, connectivity guarantee", () => { + it("three spread clumps: MST spans all members and the field is ONE component", () => { + const world = makeWorld( + [...clump(0, 0, 7), ...clump(600, 0, 7), ...clump(300, 500, 7)], + [7], + ); + planBubbleCorridors(world.plan); + + expect(segmentsSpanAllMembers(world, 0)).toBe(true); + // Baseline: point kernels alone yield >1 component; corridors must + // reduce to exactly 1. + expect( + fieldComponentsContainingMembers(world, 0, { withSegments: false }), + ).toBeGreaterThan(1); + expect(fieldComponentsContainingMembers(world, 0)).toBe(1); + // Nothing foreign anywhere: every corridor is full width. + for (const segment of world.segmentsOf(0)) { + expect(segment.radius).toBe(CORRIDOR_FIELD_RADIUS); + } + }); + + it("reroutes a corridor around a foreign node via an intermediate member", () => { + // A(0,0) and B(600,0) clumps whose direct MST edge passes over a foreign + // node at (300,0). The lone member M(0,300) is farther from B than the + // direct edge (so the MST still picks A-B) but offers a clear one-hop + // detour within the 2× cap: |A-M| + |M-B| ≈ 290 + 660 < 2 × 570. + const world = makeWorld( + [ + ...clump(0, 0, 3), + ...clump(600, 0, 3), + { x: 0, y: 300, community: 3 }, + { x: 300, y: 0, community: -1 }, + ], + [3], + ); + planBubbleCorridors(world.plan); + + expect(segmentsSpanAllMembers(world, 0)).toBe(true); + expect(fieldComponentsContainingMembers(world, 0)).toBe(1); + // No full-width corridor may brush the foreign node. + for (const segment of world.segmentsOf(0)) { + if (segment.radius === CORRIDOR_FIELD_RADIUS) { + expect(segmentDistanceTo(segment, 300, 0)).toBeGreaterThan( + CORRIDOR_FIELD_RADIUS, + ); + } + } + // The reroute splits one MST edge into two full-width segments (no + // narrow fallback). + const memberCount = world.plan.ranges[1]!; + expect(world.segmentsOf(0).length).toBeGreaterThan(memberCount - 1); + for (const segment of world.segmentsOf(0)) { + expect(segment.radius).toBe(CORRIDOR_FIELD_RADIUS); + } + }); + + it("falls back to a NARROW corridor when no detour clears a foreign barrier", () => { + const barrier: WorldNode[] = []; + for (let wallY = -400; wallY <= 400; wallY += 50) { + barrier.push({ x: 300, y: wallY, community: -1 }); + } + const world = makeWorld( + [...clump(0, 0, 5), ...clump(600, 0, 5), ...barrier], + [5], + ); + planBubbleCorridors(world.plan); + + expect(segmentsSpanAllMembers(world, 0)).toBe(true); + // Still connected via a thin thread, not a fat blob over the barrier. + expect(fieldComponentsContainingMembers(world, 0)).toBe(1); + // (float32 storage: compare against full width, not exact product) + const narrow = world + .segmentsOf(0) + .filter((segment) => segment.radius < CORRIDOR_FIELD_RADIUS); + expect(narrow.length).toBeGreaterThan(0); + for (const segment of narrow) { + expect(segment.radius).toBeCloseTo( + CORRIDOR_FIELD_RADIUS * CORRIDOR_NARROW_FACTOR, + 3, + ); + } + }); + + it("degenerate communities: singleton member and coincident members", () => { + const world = makeWorld( + [ + { x: 0, y: 0, community: 1 }, + // All four members share one world position (degenerate MST). + { x: 900, y: 900, community: 2 }, + { x: 900, y: 900, community: 2 }, + { x: 900, y: 900, community: 2 }, + { x: 900, y: 900, community: 2 }, + ], + [1, 2], + ); + expect(() => planBubbleCorridors(world.plan)).not.toThrow(); + expect(world.plan.segmentCounts[0]).toBe(0); + expect(world.plan.segmentCounts[1]).toBe(3); + expect(fieldComponentsContainingMembers(world, 1)).toBe(1); + }); + + it("pairMergeDistance: two kernels at exactly that spacing meet the iso threshold at their midpoint", () => { + const distance = pairMergeDistance(FIELD_RADIUS); + const midpointField = evaluateBubbleField( + distance / 2, + 0, + [ + [0, 0], + [distance, 0], + ], + [], + FIELD_RADIUS, + ); + expect(midpointField).toBeCloseTo(ISO_THRESHOLD, 9); + }); + + it("oversized community: corridors span merge-scale anchors and the field is ONE component", () => { + // 270 members (> CORRIDOR_ANCHOR_CAP) in three dense clumps spread past + // the kernel reach. The planner must reduce to anchors (one per + // merge-scale cell), span them, and still connect every clump. + const denseClump = (cx: number, cy: number): WorldNode[] => { + const members: WorldNode[] = []; + for (let row = 0; row < 9; row++) { + for (let col = 0; col < 10; col++) { + members.push({ x: cx + col * 6, y: cy + row * 6, community: 11 }); + } + } + return members; + }; + const build = () => { + const world = makeWorld( + [...denseClump(0, 0), ...denseClump(280, 0), ...denseClump(140, 220)], + [11], + ); + planBubbleCorridors(world.plan); + return world; + }; + const world = build(); + const memberCount = world.plan.ranges[1]!; + expect(memberCount).toBeGreaterThan(CORRIDOR_ANCHOR_CAP); + + const segments = world.segmentsOf(0); + expect(segments.length).toBeGreaterThan(0); + // Anchor MST: strictly fewer corridor nodes than members, within capacity. + expect(segments.length).toBeLessThanOrEqual((CORRIDOR_ANCHOR_CAP - 1) * 2); + for (const segment of segments) { + expect(segment.aSlot).toBeGreaterThanOrEqual(0); + expect(segment.aSlot).toBeLessThan(memberCount); + expect(segment.bSlot).toBeGreaterThanOrEqual(0); + expect(segment.bSlot).toBeLessThan(memberCount); + } + + // The one-contour contract at anchor granularity: every member fuses + // with its cell anchor (cell edge ≤ merge distance / √2), anchors are + // MST-connected, so the whole community is one component. + expect( + fieldComponentsContainingMembers(world, 0, { withSegments: false }), + ).toBeGreaterThan(1); + expect(fieldComponentsContainingMembers(world, 0)).toBe(1); + + // Anchor selection and the MST are deterministic. + const second = build(); + expect([...second.plan.segmentCounts]).toEqual([ + ...world.plan.segmentCounts, + ]); + expect([...second.plan.segmentSlots]).toEqual([...world.plan.segmentSlots]); + }); + + it("is deterministic: identical inputs produce identical plans", () => { + const build = () => { + const world = makeWorld( + [ + ...clump(0, 0, 4), + ...clump(500, 40, 4), + ...clump(250, 420, 4), + { x: 250, y: 20, community: -1 }, + { x: 260, y: 180, community: 9 }, + ], + [4], + ); + planBubbleCorridors(world.plan); + return world; + }; + const first = build(); + const second = build(); + expect([...second.plan.segmentCounts]).toEqual([ + ...first.plan.segmentCounts, + ]); + expect([...second.plan.segmentSlots]).toEqual([...first.plan.segmentSlots]); + expect([...second.plan.segmentRadius]).toEqual([ + ...first.plan.segmentRadius, + ]); + }); +}); + +describe("bubble corridors, planning cost", () => { + it("plans a large realistic frame well inside a frame budget", () => { + // 10 spread communities × 100 members + 1000 loose foreign nodes: a + // harsher obstacle field than the production graphs seen so far. + const nodes: WorldNode[] = []; + let seed = 42; + const rand = () => { + // LCG in plain float math (exact below 2^53; deterministic). + seed = (seed * 1664525 + 1013904223) % 4294967296; + return seed / 4294967296; + }; + for (let community = 0; community < 10; community++) { + const baseX = (community % 5) * 900; + const baseY = Math.floor(community / 5) * 900; + for (let member = 0; member < 100; member++) { + const clumpIdx = member % 4; + nodes.push({ + x: baseX + (clumpIdx % 2) * 420 + rand() * 90, + y: baseY + Math.floor(clumpIdx / 2) * 420 + rand() * 90, + community, + }); + } + } + for (let loose = 0; loose < 1000; loose++) { + nodes.push({ x: rand() * 4500, y: rand() * 1800, community: -1 }); + } + const world = makeWorld( + nodes, + Array.from({ length: 10 }, (_, community) => community), + ); + + const startedAt = performance.now(); + planBubbleCorridors(world.plan); + const elapsedMs = performance.now() - startedAt; + + for (let ci = 0; ci < 10; ci++) { + expect(segmentsSpanAllMembers(world, ci)).toBe(true); + } + // Movement-gated replans happen a handful of times per settle; even at + // per-frame cadence this must stay far below a 60 fps frame. + expect(elapsedMs).toBeLessThan(50); + process.stdout.write( + `[bubble-corridors] full plan: 10 communities × 100 members + 1000 obstacles = ${elapsedMs.toFixed( + 2, + )} ms\n`, + ); + + // Steady-state per-frame cost: segment endpoint texel refresh only (no + // replan). + const { plan } = world; + const totalMembers = 1000; + const segmentTexelBase = totalMembers; + const texels = new Float32Array( + (totalMembers + plan.segmentRadius.length * 2) * 4, + ); + texels.set(plan.pointTexels); + const frames = 1000; + const refillStart = performance.now(); + for (let frame = 0; frame < frames; frame++) { + for (let ci = 0; ci < plan.keptCount; ci++) { + const storageStart = plan.segmentStorageOffsets[ci]!; + const segmentCount = plan.segmentCounts[ci]!; + for (let segment = 0; segment < segmentCount; segment++) { + const storage = storageStart + segment; + const slotA = plan.segmentSlots[storage * 2]!; + const slotB = plan.segmentSlots[storage * 2 + 1]!; + const texel = (segmentTexelBase + storage * 2) * 4; + texels[texel] = texels[slotA * 4]!; + texels[texel + 1] = texels[slotA * 4 + 1]!; + texels[texel + 2] = plan.segmentRadius[storage]!; + texels[texel + 4] = texels[slotB * 4]!; + texels[texel + 5] = texels[slotB * 4 + 1]!; + } + } + } + const perFrameMs = (performance.now() - refillStart) / frames; + expect(perFrameMs).toBeLessThan(1); + process.stdout.write( + `[bubble-corridors] per-frame segment refill (~${ + plan.segmentRadius.length + } segment capacity) = ${(perFrameMs * 1000).toFixed(1)} µs\n`, + ); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-corridors.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-corridors.ts new file mode 100644 index 00000000000..35907436aa2 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-corridors.ts @@ -0,0 +1,523 @@ +/** + * Corridor planning for the community BubbleSets: guarantees each rendered + * community's metaball field is connected. + * + * The metaball kernel has finite support (`fieldRadius`), so a community whose + * members sit in spatial clumps farther apart than the kernel reach renders as + * disconnected bubble islands. The canonical fix is BubbleSets' (Collins et + * al., 2009) virtual edges: add field energy along a spanning structure over + * the member positions so the iso-contour connects. Adapted to this renderer, + * each virtual edge is a capsule (segment) kernel summed by the fragment + * shader alongside the point kernels; along the segment spine the kernel is 1, + * which is above any sane iso-threshold, so the corridor is above-threshold + * end to end and the union {member blobs} ∪ {MST corridors} is connected. + * + * Construction, per rendered community (all deterministic): + * 1. Anchor selection: communities with more than {@link CORRIDOR_ANCHOR_CAP} + * sampled members are reduced to one anchor per merge-scale grid cell + * (cell edge = pairwise kernel-merge distance / √2, doubled until the + * anchor count fits the cap). Any two points in one cell are closer than + * the merge distance, so every non-anchor member's kernel provably fuses + * with its cell anchor's kernel above the iso threshold; connecting the + * anchors therefore connects every member. Communities at or under the + * cap use every member as an anchor (identical to the pre-anchor + * behaviour). The guarantee only softens on the (pathological) doubling + * path: points sharing a doubled cell may sit past the merge distance. + * 2. Euclidean MST over the anchor positions + * (Prim from anchor 0; strict `<` keeps the lowest candidate slot on ties). + * 3. Obstacle pass: an MST edge whose corridor passes within clearance of a + * foreign node (any node not in this community, measured against the + * node's actual dot radius via a uniform grid) is rerouted through the + * best intermediate anchor (min detour ≤ {@link CORRIDOR_DETOUR_CAP} × + * direct, both halves clear, tie-break lowest slot). + * + * When every capped one-hop detour is blocked as well (foreign nodes wall off + * the clumps), the direct segment is kept at × {@link CORRIDOR_NARROW_FACTOR} + * width. The narrowed ribbon can still cross foreign nodes; that is an + * accepted limit, for three reasons. Dropping the segment would split the + * community into separate same-coloured hulls, which misreads as separate + * communities (connectivity is this module's contract). Clipping at the + * obstacle or routing multi-segment detours does not fit the data model: + * segment endpoints are member point-texel slots (that is how corridors + * track animation between replans), so a segment cannot end at an arbitrary + * clip point, and more than two segments per MST edge would overflow the + * capacity that both the segment storage and the shader's loop bound assume. + * And the crossing stays legible: the hull is a low-alpha backdrop drawn + * behind dots and edges, so a crossed foreign dot renders on top at full + * strength over a ribbon about one dot diameter wide, and reads as a dot in + * front of a thread rather than a dot annexed into the community, the same + * way dots read over ordinary straight edges (which do no obstacle avoidance + * at all). + * + * Corridors are deliberately thin ribbons ({@link CORRIDOR_FIELD_RADIUS} ≪ bubble `fieldRadius`): + * connecting a spread community must not visually annex the space between its clumps; + * that would undo the community-region separation the layout engines enforce. + * + * Topology (which slots each segment joins, each segment's radius) is planned + * here on the CPU, once when a grouping is built, then only when members + * drift (movement-gated by the caller). Endpoint positions are refreshed + * from the gathered point texels on every pack frame, so corridors track + * animation between replans without re-running the planner. + */ + +import { BitSet } from "../worker/collections/bitset"; + +/** + * Iso threshold of the bubble field (shared by the SDF layer, the corridor + * merge-distance math, and the tests). Field values above this render inside + * the hull. + */ +export const BUBBLE_ISO_THRESHOLD = 0.58; + +/** Capsule kernel radius (world units) for full-width corridors. Visual ribbon + * half-width at the default iso-threshold ≈ 0.49 × this. */ +export const CORRIDOR_FIELD_RADIUS = 22; +/** Kernel-radius multiplier for corridors kept direct because no reroute + * cleared the obstacles. Sized so the narrowed ribbon is about one dot + * diameter wide at the default iso threshold: a crossed foreign dot reads as + * a dot in front of a thread, not a dot inside the community. Raising this + * toward 1 recreates that annexation misread; lowering it thins blocked + * corridors toward invisibility when zoomed out. */ +export const CORRIDOR_NARROW_FACTOR = 0.45; +/** Extra clearance (world units) beyond corridor radius + foreign dot radius. */ +const CORRIDOR_CLEARANCE_PAD = 4; +/** A reroute may lengthen a corridor by at most this factor of the direct edge. */ +export const CORRIDOR_DETOUR_CAP = 2; +/** Uniform-grid cell size (world units) for the foreign-node obstacle index. */ +const OBSTACLE_GRID_CELL = 64; +/** Numeric grid-key packing (2^25 / 2^26; matches the worker grid-key convention). */ +const CELL_OFFSET = 33554432; +const CELL_STRIDE = 67108864; + +/** Per-node record layout inside the flat SAB (float indices within a record). */ +const RECORD_X = 0; +const RECORD_Y = 1; +const RECORD_RADIUS = 2; + +/** + * Upper bound on corridor MST nodes per community. Prim is O(anchors²), so + * this caps planning cost regardless of how many members the hull samples + * (up to `MAX_NODES_PER_COMMUNITY`, 1024). It also keeps the worst-case + * segment count (2 · (anchors − 1) with reroute splits) inside the shader's + * `MAX_SEGMENTS_PER_COMMUNITY` loop bound of 512. + */ +export const CORRIDOR_ANCHOR_CAP = 256; + +/** Prim scratch, sized to {@link CORRIDOR_ANCHOR_CAP}. */ +const primDist = new Float64Array(CORRIDOR_ANCHOR_CAP); +const primParent = new Int32Array(CORRIDOR_ANCHOR_CAP); +const primInTree = BitSet.empty(CORRIDOR_ANCHOR_CAP); +/** Anchor member indices for the community currently being planned. */ +const anchorMembers = new Int32Array(CORRIDOR_ANCHOR_CAP); + +/** + * Centre-to-centre distance below which two point kernels of radius + * `fieldRadius` merge into one above-threshold region: the field at their + * midpoint, `2 · (1 − (d / 2r)²)²`, meets {@link BUBBLE_ISO_THRESHOLD}. + * ≈ 1.36 × the radius at the default threshold. + */ +export function pairMergeDistance(fieldRadius: number): number { + return 2 * fieldRadius * Math.sqrt(1 - Math.sqrt(BUBBLE_ISO_THRESHOLD / 2)); +} + +/** + * Fills {@link anchorMembers} with ≤ {@link CORRIDOR_ANCHOR_CAP} member + * indices for one community and returns the count. At or under the cap every + * member is an anchor; above it, members are binned into merge-scale grid + * cells (edge = merge distance / √2 so same-cell points always fuse) and the + * first member of each occupied cell (member order — deterministic) is kept, + * doubling the cell edge until the anchors fit the cap. + */ +function selectAnchors( + pointTexels: Float32Array, + pointOffset: number, + memberCount: number, + fieldRadius: number, +): number { + if (memberCount <= CORRIDOR_ANCHOR_CAP) { + for (let member = 0; member < memberCount; member++) { + anchorMembers[member] = member; + } + return memberCount; + } + + // Plans are movement-gated, not per-frame; a transient Map per attempt is + // fine here (same policy as ObstacleGrid). + let cellEdge = pairMergeDistance(fieldRadius) / Math.SQRT2; + for (;;) { + const firstMemberOfCell = new Map(); + for (let member = 0; member < memberCount; member++) { + const slot = (pointOffset + member) * 4; + const cellX = Math.floor(pointTexels[slot]! / cellEdge); + const cellY = Math.floor(pointTexels[slot + 1]! / cellEdge); + const key = (cellX + CELL_OFFSET) * CELL_STRIDE + (cellY + CELL_OFFSET); + if (!firstMemberOfCell.has(key)) { + firstMemberOfCell.set(key, member); + } + } + if (firstMemberOfCell.size <= CORRIDOR_ANCHOR_CAP) { + let anchorCount = 0; + // Map iteration follows insertion order == member order: deterministic. + for (const member of firstMemberOfCell.values()) { + anchorMembers[anchorCount] = member; + anchorCount += 1; + } + return anchorCount; + } + cellEdge *= 2; + } +} + +export interface CorridorPlan { + /** Number of rendered (kept) communities. */ + readonly keptCount: number; + /** Per kept community `[pointTexelOffset, memberCount]` (gather order). */ + readonly ranges: Float32Array; + /** Per kept community: its Louvain community id (for the foreign test). */ + readonly communityIds: Int32Array; + /** Gathered member positions, texel stride 4 (`[x, y, _, _]` per texel). */ + readonly pointTexels: Float32Array; + /** Per kept community: point-kernel field radius (world units); sets the + * anchor merge scale for oversampled communities. */ + readonly fieldRadii: Float32Array; + /** Which kept communities to (re)plan; `null` plans all of them. */ + readonly replan: BitSet | null; + + /** Flat SAB float view + record layout, for foreign node positions/radii. */ + readonly floats: Float32Array; + readonly headerFloats: number; + readonly recordFloats: number; + /** Louvain membership per SAB node index (-1 / missing = no community). */ + readonly membership: Int32Array; + /** Live SAB node count (obstacle candidates are `0..nodeCount-1`). */ + readonly nodeCount: number; + + /** Outputs, pre-allocated by the caller (capacities fixed per grouping): */ + /** Per segment `[slotA, slotB]`, absolute point-texel slots. */ + readonly segmentSlots: Int32Array; + /** Per segment: capsule kernel radius (world units). */ + readonly segmentRadius: Float32Array; + /** Per kept community: live segment count (≤ its capacity). */ + readonly segmentCounts: Int32Array; + /** Per kept community: first segment-storage index (segment units, fixed). */ + readonly segmentStorageOffsets: Int32Array; +} + +/** + * Returns the shortest distance from `(px, py)` to the closed segment + * `(ax, ay)`-`(bx, by)`, clamping the projection to the segment ends. + */ +function distanceToSegment( + px: number, + py: number, + ax: number, + ay: number, + bx: number, + by: number, +): number { + const abX = bx - ax; + const abY = by - ay; + const lenSq = abX * abX + abY * abY; + const raw = lenSq > 0 ? ((px - ax) * abX + (py - ay) * abY) / lenSq : 0; + const along = raw < 0 ? 0 : raw > 1 ? 1 : raw; + const dx = px - (ax + abX * along); + const dy = py - (ay + abY * along); + return Math.hypot(dx, dy); +} + +/** + * Foreign-node obstacle index: uniform grid over all live SAB nodes. Built + * once per plan call (plans are movement-gated, not per-frame, so the small + * Map allocation here never rides the steady-state render loop). + */ +class ObstacleGrid { + readonly #cells = new Map(); + readonly #floats: Float32Array; + readonly #headerFloats: number; + readonly #recordFloats: number; + readonly #membership: Int32Array; + + constructor(plan: CorridorPlan) { + this.#floats = plan.floats; + this.#headerFloats = plan.headerFloats; + this.#recordFloats = plan.recordFloats; + this.#membership = plan.membership; + + for (let idx = 0; idx < plan.nodeCount; idx++) { + const base = plan.headerFloats + idx * plan.recordFloats; + const cellX = Math.floor( + (plan.floats[base + RECORD_X] ?? 0) / OBSTACLE_GRID_CELL, + ); + + const cellY = Math.floor( + (plan.floats[base + RECORD_Y] ?? 0) / OBSTACLE_GRID_CELL, + ); + + const key = (cellX + CELL_OFFSET) * CELL_STRIDE + (cellY + CELL_OFFSET); + const bucket = this.#cells.get(key); + + if (bucket) { + bucket.push(idx); + } else { + this.#cells.set(key, [idx]); + } + } + } + + /** + * True when no node outside `communityId` sits within + * `clearance + itsDotRadius` of segment (ax,ay)→(bx,by). + */ + segmentClear( + ax: number, + ay: number, + bx: number, + by: number, + communityId: number, + clearance: number, + ): boolean { + // Obstacle dot radii are small vs the grid cell; one cell of slack on the + // inflated AABB covers radius + clearance overhang. + const pad = clearance + OBSTACLE_GRID_CELL; + const minCellX = Math.floor((Math.min(ax, bx) - pad) / OBSTACLE_GRID_CELL); + const maxCellX = Math.floor((Math.max(ax, bx) + pad) / OBSTACLE_GRID_CELL); + const minCellY = Math.floor((Math.min(ay, by) - pad) / OBSTACLE_GRID_CELL); + const maxCellY = Math.floor((Math.max(ay, by) + pad) / OBSTACLE_GRID_CELL); + for (let cellX = minCellX; cellX <= maxCellX; cellX++) { + for (let cellY = minCellY; cellY <= maxCellY; cellY++) { + const key = (cellX + CELL_OFFSET) * CELL_STRIDE + (cellY + CELL_OFFSET); + const bucket = this.#cells.get(key); + if (!bucket) { + continue; + } + for (const idx of bucket) { + if ((this.#membership[idx] ?? -1) === communityId) { + continue; + } + const base = this.#headerFloats + idx * this.#recordFloats; + const nodeX = this.#floats[base + RECORD_X] ?? 0; + const nodeY = this.#floats[base + RECORD_Y] ?? 0; + const nodeRadius = this.#floats[base + RECORD_RADIUS] ?? 0; + if ( + distanceToSegment(nodeX, nodeY, ax, ay, bx, by) < + clearance + nodeRadius + ) { + return false; + } + } + } + } + return true; + } +} + +/** + * (Re)plan corridor segments for the requested communities. Untouched + * communities keep their previous plan (their storage regions are disjoint). + */ +export function planBubbleCorridors(plan: CorridorPlan): void { + const { + keptCount, + ranges, + pointTexels, + replan, + segmentSlots, + segmentRadius, + segmentCounts, + segmentStorageOffsets, + communityIds, + } = plan; + + if (replan !== null && replan.cardinality === 0) { + return; + } + + const grid = new ObstacleGrid(plan); + + for (let ci = 0; ci < keptCount; ci++) { + if (replan !== null && !replan.has(ci)) { + continue; + } + const pointOffset = ranges[ci * 2]!; + const memberCount = ranges[ci * 2 + 1]!; + const communityId = communityIds[ci]!; + const storageStart = segmentStorageOffsets[ci]!; + let segmentCount = 0; + + if (memberCount >= 2) { + const anchorCount = selectAnchors( + pointTexels, + pointOffset, + memberCount, + plan.fieldRadii[ci]!, + ); + + const coordAt = (anchor: number): readonly [number, number] => { + const slot = (pointOffset + anchorMembers[anchor]!) * 4; + return [pointTexels[slot]!, pointTexels[slot + 1]!]; + }; + + primInTree.clear(); + primDist.fill(Number.POSITIVE_INFINITY, 0, anchorCount); + primParent.fill(-1, 0, anchorCount); + primDist[0] = 0; + + for (let step = 0; step < anchorCount; step++) { + let next = -1; + let nextDist = Number.POSITIVE_INFINITY; + + for (let anchor = 0; anchor < anchorCount; anchor++) { + if (!primInTree.has(anchor) && primDist[anchor]! < nextDist) { + nextDist = primDist[anchor]!; + next = anchor; + } + } + + if (next < 0) { + break; + } + + primInTree.add(next); + + const [nextX, nextY] = coordAt(next); + for (let anchor = 0; anchor < anchorCount; anchor++) { + if (primInTree.has(anchor)) { + continue; + } + + const [anchorX, anchorY] = coordAt(anchor); + const dx = anchorX - nextX; + const dy = anchorY - nextY; + const distSq = dx * dx + dy * dy; + + if (distSq < primDist[anchor]!) { + primDist[anchor] = distSq; + primParent[anchor] = next; + } + } + } + + // Obstacle pass per MST edge: clear edges use full radius; reroutes use + // two full segments; blocked edges narrow. Capacity is 2 · (anchorCount - 1): + // every edge emits at most two segments. + const emit = (anchorA: number, anchorB: number, radius: number): void => { + const storage = storageStart + segmentCount; + segmentSlots[storage * 2] = pointOffset + anchorMembers[anchorA]!; + segmentSlots[storage * 2 + 1] = pointOffset + anchorMembers[anchorB]!; + segmentRadius[storage] = radius; + segmentCount += 1; + }; + const clearance = CORRIDOR_FIELD_RADIUS + CORRIDOR_CLEARANCE_PAD; + + for (let anchor = 1; anchor < anchorCount; anchor++) { + const parent = primParent[anchor]!; + if (parent < 0) { + continue; // Unreachable anchor (coincident degenerate); skip. + } + + const [ax, ay] = coordAt(parent); + const [bx, by] = coordAt(anchor); + + const directLen = Math.hypot(bx - ax, by - ay); + if ( + directLen < 1e-3 || + grid.segmentClear(ax, ay, bx, by, communityId, clearance) + ) { + emit(parent, anchor, CORRIDOR_FIELD_RADIUS); + continue; + } + + // Blocked: cheapest clear one-hop detour via another anchor. + let bestVia = -1; + let bestDetour = Number.POSITIVE_INFINITY; + for (let via = 0; via < anchorCount; via++) { + if (via === parent || via === anchor) { + continue; + } + + const [viaX, viaY] = coordAt(via); + const detour = + Math.hypot(viaX - ax, viaY - ay) + Math.hypot(bx - viaX, by - viaY); + + if ( + detour >= bestDetour || + detour > directLen * CORRIDOR_DETOUR_CAP + ) { + continue; + } + + if ( + grid.segmentClear(ax, ay, viaX, viaY, communityId, clearance) && + grid.segmentClear(viaX, viaY, bx, by, communityId, clearance) + ) { + bestDetour = detour; + bestVia = via; + } + } + + if (bestVia >= 0) { + emit(parent, bestVia, CORRIDOR_FIELD_RADIUS); + emit(bestVia, anchor, CORRIDOR_FIELD_RADIUS); + } else { + // No clear detour: emit the direct segment narrowed. It can still + // cross foreign nodes; the header explains why that beats + // disconnecting the hull. + emit(parent, anchor, CORRIDOR_FIELD_RADIUS * CORRIDOR_NARROW_FACTOR); + } + } + } + + segmentCounts[ci] = segmentCount; + } +} + +/** + * CPU mirror of the shader's field function (point kernels + capsule + * kernels, Wyvill falloff), for tests of the connectivity guarantee. + */ +export function evaluateBubbleField( + px: number, + py: number, + points: ReadonlyArray, + segments: ReadonlyArray<{ + readonly ax: number; + readonly ay: number; + readonly bx: number; + readonly by: number; + readonly radius: number; + }>, + fieldRadius: number, +): number { + let field = 0; + for (const [pointX, pointY] of points) { + const norm = Math.hypot(px - pointX, py - pointY) / fieldRadius; + + if (norm < 1) { + const falloff = 1 - norm * norm; + field += falloff * falloff; + } + } + + for (const segment of segments) { + if (segment.radius <= 0) { + continue; + } + + const norm = + distanceToSegment( + px, + py, + segment.ax, + segment.ay, + segment.bx, + segment.by, + ) / segment.radius; + + if (norm < 1) { + const falloff = 1 - norm * norm; + field += falloff * falloff; + } + } + + return field; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-grid-traversal.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-grid-traversal.ts new file mode 100644 index 00000000000..02ab9340112 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-grid-traversal.ts @@ -0,0 +1,169 @@ +/** + * Grid traversal for the bubble-grid packer: enumerates every cell a capsule + * kernel (corridor segment dilated by its radius) can influence. + * + * The spine is walked with an exact Amanatides-Woo traversal, so no crossed + * cell can be missed by sampling gaps. At each crossed cell the spine's + * clipped sub-segment is compared against the cell edges; neighbours within + * the capsule radius of it are enumerated too. Because corridor radii are + * ≪ the cell size (cells are 2 × the point-kernel field radius, capsules + * less than half that), the ring-1 neighbours of crossed cells always cover + * the full support. The packer clamps the radius to the cell size to keep + * that invariant even if the constants drift. + */ + +export interface CapsuleCellQuery { + /** Spine endpoints (world units). */ + readonly ax: number; + readonly ay: number; + readonly bx: number; + readonly by: number; + /** Capsule kernel radius (world units); must be ≤ cellSize. */ + readonly radius: number; + /** Grid frame. */ + readonly originX: number; + readonly originY: number; + readonly cellSize: number; + readonly cols: number; + readonly rows: number; +} + +/** + * Mark the crossed cell plus every neighbour the capsule can bleed into: the + * clipped sub-segment's extent against the cell edges decides which sides + * (and corners) the support crosses. + */ +function markCellWithNeighbours( + { + ax, + ay, + bx, + by, + originX, + originY, + cellSize, + cols, + rows, + radius, + }: CapsuleCellQuery, + mark: (col: number, row: number) => void, + col: number, + row: number, + tEnter: number, + tExit: number, +): void { + const dirX = bx - ax; + const dirY = by - ay; + + const enterX = ax + dirX * tEnter; + const enterY = ay + dirY * tEnter; + + const exitX = ax + dirX * tExit; + const exitY = ay + dirY * tExit; + + const markClamped = (candidateCol: number, candidateRow: number): void => { + if ( + candidateCol >= 0 && + candidateCol < cols && + candidateRow >= 0 && + candidateRow < rows + ) { + mark(candidateCol, candidateRow); + } + }; + + markClamped(col, row); + + const cellMinX = originX + col * cellSize; + const cellMinY = originY + row * cellSize; + + const nearLeft = Math.min(enterX, exitX) < cellMinX + radius; + const nearRight = Math.max(enterX, exitX) > cellMinX + cellSize - radius; + const nearBottom = Math.min(enterY, exitY) < cellMinY + radius; + const nearTop = Math.max(enterY, exitY) > cellMinY + cellSize - radius; + + if (nearLeft) { + markClamped(col - 1, row); + } + if (nearRight) { + markClamped(col + 1, row); + } + if (nearBottom) { + markClamped(col, row - 1); + } + if (nearTop) { + markClamped(col, row + 1); + } + if (nearLeft && nearBottom) { + markClamped(col - 1, row - 1); + } + if (nearLeft && nearTop) { + markClamped(col - 1, row + 1); + } + if (nearRight && nearBottom) { + markClamped(col + 1, row - 1); + } + if (nearRight && nearTop) { + markClamped(col + 1, row + 1); + } +} + +/** + * Invoke `mark` for every in-bounds cell the capsule's support can touch. + * Cells may be reported more than once (straight walks revisit neighbours); + * the caller dedupes. + */ +export function forEachCapsuleCell( + query: CapsuleCellQuery, + mark: (col: number, row: number) => void, +): void { + const { ax, ay, bx, by, originX, originY, cellSize, cols, rows } = query; + + const clampCol = (value: number) => + Math.min(cols - 1, Math.max(0, Math.floor((value - originX) / cellSize))); + const clampRow = (value: number) => + Math.min(rows - 1, Math.max(0, Math.floor((value - originY) / cellSize))); + + let col = clampCol(ax); + let row = clampRow(ay); + + const endCol = clampCol(bx); + const endRow = clampRow(by); + + const dirX = bx - ax; + const dirY = by - ay; + + const stepCol = dirX > 0 ? 1 : -1; + const stepRow = dirY > 0 ? 1 : -1; + + const tDeltaX = dirX !== 0 ? Math.abs(cellSize / dirX) : Infinity; + const tDeltaY = dirY !== 0 ? Math.abs(cellSize / dirY) : Infinity; + + const nextBoundaryX = originX + (col + (stepCol > 0 ? 1 : 0)) * cellSize; + const nextBoundaryY = originY + (row + (stepRow > 0 ? 1 : 0)) * cellSize; + + let tMaxX = dirX !== 0 ? (nextBoundaryX - ax) / dirX : Infinity; + let tMaxY = dirY !== 0 ? (nextBoundaryY - ay) / dirY : Infinity; + let tEnter = 0; + + // The walk normally ends at B's cell; the guard bound (a straight walk can + // cross at most cols + rows cells) is a hard stop against float edge cases + // on the boundary comparisons. + for (let guard = 0; guard <= cols + rows + 2; guard++) { + const tExit = Math.min(1, Math.min(tMaxX, tMaxY)); + markCellWithNeighbours(query, mark, col, row, tEnter, tExit); + + if ((col === endCol && row === endRow) || tExit >= 1) { + break; + } + + tEnter = tExit; + if (tMaxX < tMaxY) { + col += stepCol; + tMaxX += tDeltaX; + } else { + row += stepRow; + tMaxY += tDeltaY; + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-grid.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-grid.test.ts new file mode 100644 index 00000000000..d5f78b7a869 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-grid.test.ts @@ -0,0 +1,415 @@ +/** + * BubbleCellPacker tests: the R9 binning must be invisible. The field a + * fragment computes from its cell's kernel copies has to equal the field + * computed from the whole community, everywhere a fragment can run. + * + * The contract, per pixel: + * - pixel inside an emitted cell → per-cell field == full-community field + * (`evaluateBubbleField` is the oracle, mirroring the shader); + * - pixel outside every emitted cell → the full field is zero there (so + * never rendering those pixels changes nothing). + */ +import { describe, expect, it } from "vitest"; + +import { evaluateBubbleField } from "./bubble-corridors"; +import { BUBBLE_TEX_WIDTH, BubbleCellPacker } from "./bubble-grid"; +import { + MAX_NODES_PER_COMMUNITY, + MAX_SEGMENTS_PER_COMMUNITY, +} from "./gpu/bubble-set-sdf-layer"; + +import type { BubbleCellPack, BubbleCellPackResult } from "./bubble-grid"; + +const FIELD_RADIUS = 50; + +interface Segment { + readonly memberA: number; + readonly memberB: number; + readonly radius: number; +} + +interface Community { + readonly members: readonly (readonly [number, number])[]; + readonly segments: readonly Segment[]; + readonly color?: readonly [number, number, number, number]; + /** Point-kernel radius override (world units); defaults to {@link FIELD_RADIUS}. */ + readonly fieldRadius?: number; +} + +/** Build a {@link BubbleCellPack} mirroring production layout: ranges, segment slots, and point texels in gather order. */ +function makePack(communities: readonly Community[]): BubbleCellPack { + const totalMembers = communities.reduce( + (sum, community) => sum + community.members.length, + 0, + ); + const totalSegments = communities.reduce( + (sum, community) => sum + community.segments.length, + 0, + ); + + const ranges = new Float32Array(communities.length * 2); + const colors = new Uint8Array(communities.length * 4); + const fieldRadii = new Float32Array(communities.length); + const pointTexels = new Float32Array(totalMembers * 4); + const segmentSlots = new Int32Array(totalSegments * 2); + const segmentRadius = new Float32Array(totalSegments); + const segmentCounts = new Int32Array(communities.length); + const segmentStorageOffsets = new Int32Array(communities.length); + + let pointOffset = 0; + let segmentOffset = 0; + for (const [ci, community] of communities.entries()) { + ranges[ci * 2] = pointOffset; + ranges[ci * 2 + 1] = community.members.length; + + const [red, green, blue, alpha] = community.color ?? [10, 20, 30, 40]; + colors[ci * 4] = red; + colors[ci * 4 + 1] = green; + colors[ci * 4 + 2] = blue; + colors[ci * 4 + 3] = alpha; + + fieldRadii[ci] = community.fieldRadius ?? FIELD_RADIUS; + for (const [member, [x, y]] of community.members.entries()) { + pointTexels[(pointOffset + member) * 4] = x; + pointTexels[(pointOffset + member) * 4 + 1] = y; + } + + segmentStorageOffsets[ci] = segmentOffset; + segmentCounts[ci] = community.segments.length; + for (const [segmentIndex, segment] of community.segments.entries()) { + segmentSlots[(segmentOffset + segmentIndex) * 2] = + pointOffset + segment.memberA; + segmentSlots[(segmentOffset + segmentIndex) * 2 + 1] = + pointOffset + segment.memberB; + segmentRadius[segmentOffset + segmentIndex] = segment.radius; + } + + pointOffset += community.members.length; + segmentOffset += community.segments.length; + } + + return { + keptCount: communities.length, + ranges, + pointTexels, + colors, + segmentSlots, + segmentRadius, + segmentCounts, + segmentStorageOffsets, + fieldRadii, + }; +} + +/** Decode one packed cell back into oracle-shaped kernels. */ +function cellKernels(packed: BubbleCellPackResult, cell: number) { + const pointBase = packed.nodeRanges[cell * 2]!; + const pointCount = packed.nodeRanges[cell * 2 + 1]!; + const points: [number, number][] = []; + for (let point = 0; point < pointCount; point++) { + const texel = (pointBase + point) * 4; + points.push([packed.texels[texel]!, packed.texels[texel + 1]!]); + } + const segmentBase = packed.segmentRanges[cell * 2]!; + const segmentCount = packed.segmentRanges[cell * 2 + 1]!; + const segments = []; + for (let segment = 0; segment < segmentCount; segment++) { + const texel = (segmentBase + segment * 2) * 4; + segments.push({ + ax: packed.texels[texel]!, + ay: packed.texels[texel + 1]!, + radius: packed.texels[texel + 2]!, + bx: packed.texels[texel + 4]!, + by: packed.texels[texel + 5]!, + }); + } + return { points, segments }; +} + +/** Full-community kernels for the oracle, read back from the SAME float32 inputs. */ +function communityKernels(pack: BubbleCellPack, ci: number) { + const pointOffset = pack.ranges[ci * 2]!; + const memberCount = pack.ranges[ci * 2 + 1]!; + const points: [number, number][] = []; + for (let member = 0; member < memberCount; member++) { + const texel = (pointOffset + member) * 4; + points.push([pack.pointTexels[texel]!, pack.pointTexels[texel + 1]!]); + } + const segments = []; + const storageStart = pack.segmentStorageOffsets[ci]!; + for (let segment = 0; segment < pack.segmentCounts[ci]!; segment++) { + const storage = storageStart + segment; + const slotA = pack.segmentSlots[storage * 2]!; + const slotB = pack.segmentSlots[storage * 2 + 1]!; + segments.push({ + ax: pack.pointTexels[slotA * 4]!, + ay: pack.pointTexels[slotA * 4 + 1]!, + bx: pack.pointTexels[slotB * 4]!, + by: pack.pointTexels[slotB * 4 + 1]!, + radius: pack.segmentRadius[storage]!, + }); + } + return { points, segments }; +} + +/** + * The invisibility proof, brute-forced: rasterise each community's reachable + * area; at every sample the per-cell field (what the shader would compute) + * must equal the full field, and samples outside all cells must be zero. + */ +function expectFieldEquivalence( + pack: BubbleCellPack, + packed: BubbleCellPackResult, + step = 7, +) { + for (let ci = 0; ci < pack.keptCount; ci++) { + const full = communityKernels(pack, ci); + if (full.points.length === 0) { + continue; + } + const fieldRadius = pack.fieldRadii[ci]!; + const xs = full.points.map(([x]) => x); + const ys = full.points.map(([, y]) => y); + // Sample past the kernel reach so the "outside every cell" case is hit. + const pad = fieldRadius * 2; + const minX = Math.min(...xs) - pad; + const maxX = Math.max(...xs) + pad; + const minY = Math.min(...ys) - pad; + const maxY = Math.max(...ys) + pad; + + // Identify packed cells for this community via matching alpha channel. + const cells: number[] = []; + for (let cell = 0; cell < packed.cellCount; cell++) { + if (packed.colors[cell * 4 + 3] === pack.colors[ci * 4 + 3]) { + cells.push(cell); + } + } + + let insideSamples = 0; + let outsideNonZero = 0; + for (let y = minY; y <= maxY; y += step) { + for (let x = minX; x <= maxX; x += step) { + const fullField = evaluateBubbleField( + x, + y, + full.points, + full.segments, + fieldRadius, + ); + const cell = cells.find((candidate) => { + const bounds = packed.bounds.subarray( + candidate * 4, + candidate * 4 + 4, + ); + return ( + x >= bounds[0]! && + x < bounds[2]! && + y >= bounds[1]! && + y < bounds[3]! + ); + }); + if (cell === undefined) { + if (fullField !== 0) { + outsideNonZero += 1; + } + continue; + } + insideSamples += 1; + const local = cellKernels(packed, cell); + const cellField = evaluateBubbleField( + x, + y, + local.points, + local.segments, + fieldRadius, + ); + expect(Math.abs(cellField - fullField)).toBeLessThan(1e-9); + } + } + expect(insideSamples).toBeGreaterThan(0); + expect(outsideNonZero).toBe(0); + } +} + +/** A tight 4-node clump centred on (cx, cy). */ +function clump(cx: number, cy: number): (readonly [number, number])[] { + return [ + [cx - 15, cy - 12], + [cx + 15, cy - 12], + [cx - 15, cy + 12], + [cx + 15, cy + 12], + ]; +} + +describe("bubble grid packing, field equivalence", () => { + it("single clump community: per-cell field matches the full field everywhere", () => { + const pack = makePack([{ members: clump(0, 0), segments: [] }]); + const packed = new BubbleCellPacker().pack(pack); + + expect(packed.cellCount).toBeGreaterThan(0); + expectFieldEquivalence(pack, packed); + }); + + it("spread clumps with corridors (long diagonal capsules) stay exact", () => { + const members = [...clump(0, 0), ...clump(620, 40), ...clump(300, 540)]; + const pack = makePack([ + { + members, + segments: [ + // Corridors like the planner emits: full width + one narrowed. + { memberA: 1, memberB: 4, radius: 22 }, + { memberA: 2, memberB: 8, radius: 22 }, + { memberA: 5, memberB: 9, radius: 9.9 }, + ], + }, + ]); + const packed = new BubbleCellPacker().pack(pack); + + expectFieldEquivalence(pack, packed); + }); + + it("multiple communities pack disjoint instance/texel regions", () => { + const pack = makePack([ + { + members: clump(0, 0), + segments: [{ memberA: 0, memberB: 3, radius: 22 }], + color: [1, 2, 3, 100], + }, + { + members: clump(900, -300), + segments: [{ memberA: 0, memberB: 2, radius: 22 }], + color: [4, 5, 6, 200], + }, + ]); + const packed = new BubbleCellPacker().pack(pack); + + expectFieldEquivalence(pack, packed); + + // cellCount partitions cleanly by community colour. + let first = 0; + let second = 0; + for (let cell = 0; cell < packed.cellCount; cell++) { + const alpha = packed.colors[cell * 4 + 3]; + if (alpha === 100) { + first += 1; + } else if (alpha === 200) { + second += 1; + } + } + expect(first).toBeGreaterThan(0); + expect(second).toBeGreaterThan(0); + expect(first + second).toBe(packed.cellCount); + }); + + it("mixed per-community kernel radii stay exact and tag every cell", () => { + // An oversampled community carries a larger adaptive radius; its grid is + // coarser but the per-cell field must still match the full field, and + // each cell must carry its own community's radius for the shader. + const pack = makePack([ + { + members: clump(0, 0), + segments: [], + color: [1, 2, 3, 100], + fieldRadius: 130, + }, + { + members: clump(700, 100), + segments: [{ memberA: 0, memberB: 3, radius: 22 }], + color: [4, 5, 6, 200], + }, + ]); + const packed = new BubbleCellPacker().pack(pack); + + expectFieldEquivalence(pack, packed); + + for (let cell = 0; cell < packed.cellCount; cell++) { + const alpha = packed.colors[cell * 4 + 3]; + expect(packed.fieldRadii[cell]).toBe(alpha === 100 ? 130 : FIELD_RADIUS); + } + }); + + it("cells stay within the shader loop bounds", () => { + // Worst plausible density: one community, the full sample cap in one spot. + const members: (readonly [number, number])[] = []; + for (let member = 0; member < MAX_NODES_PER_COMMUNITY; member++) { + members.push([(member % 32) * 6, Math.floor(member / 32) * 6]); + } + const segments: Segment[] = []; + for (let segment = 0; segment < 255; segment++) { + segments.push({ + memberA: segment * 4, + memberB: segment * 4 + 4, + radius: 22, + }); + } + const pack = makePack([{ members, segments }]); + const packed = new BubbleCellPacker().pack(pack); + + for (let cell = 0; cell < packed.cellCount; cell++) { + expect(packed.nodeRanges[cell * 2 + 1]!).toBeLessThanOrEqual( + MAX_NODES_PER_COMMUNITY, + ); + expect(packed.segmentRanges[cell * 2 + 1]!).toBeLessThanOrEqual( + MAX_SEGMENTS_PER_COMMUNITY, + ); + } + expectFieldEquivalence(pack, packed, 11); + }); +}); + +describe("bubble grid packing, reuse across frames", () => { + it("is deterministic and reuses buffers on identical repacks", () => { + const pack = makePack([ + { + members: [...clump(0, 0), ...clump(500, 80)], + segments: [{ memberA: 1, memberB: 4, radius: 22 }], + }, + ]); + const packer = new BubbleCellPacker(); + const first = packer.pack(pack); + const firstSnapshot = { + cellCount: first.cellCount, + texHeight: first.texHeight, + bounds: [...first.bounds.subarray(0, first.cellCount * 4)], + nodeRanges: [...first.nodeRanges.subarray(0, first.cellCount * 2)], + segmentRanges: [...first.segmentRanges.subarray(0, first.cellCount * 2)], + texels: [...first.texels], + }; + + const second = packer.pack(pack); + + expect(second.cellCount).toBe(firstSnapshot.cellCount); + expect(second.texHeight).toBe(firstSnapshot.texHeight); + expect([...second.bounds.subarray(0, second.cellCount * 4)]).toEqual( + firstSnapshot.bounds, + ); + expect([...second.nodeRanges.subarray(0, second.cellCount * 2)]).toEqual( + firstSnapshot.nodeRanges, + ); + expect([...second.segmentRanges.subarray(0, second.cellCount * 2)]).toEqual( + firstSnapshot.segmentRanges, + ); + expect([...second.texels]).toEqual(firstSnapshot.texels); + }); + + it("texture height is monotone so occupancy jitter cannot thrash the texture", () => { + const packer = new BubbleCellPacker(); + const big = packer.pack( + makePack([ + { + members: Array.from({ length: 120 }, (_, member) => [ + (member % 12) * 90, + Math.floor(member / 12) * 90, + ]), + segments: [], + }, + ]), + ); + const small = packer.pack( + makePack([{ members: clump(0, 0), segments: [] }]), + ); + + expect(small.texHeight).toBe(big.texHeight); + expect(small.texels.length).toBe(BUBBLE_TEX_WIDTH * small.texHeight * 4); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-grid.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-grid.ts new file mode 100644 index 00000000000..372f1df931d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/bubble-grid.ts @@ -0,0 +1,550 @@ +/** + * Spatial binning for the community BubbleSets (the R9 fix): turns each + * community's single bbox-covering quad into one instance per occupied grid + * cell, so a fragment only ever sums the kernels that can actually reach it. + * + * Why: the metaball field is thresholded, but a fragment can only prove + * "below threshold" by summing every kernel; with one quad per community + * that made exterior/contour pixels (the dominant population on a zoomed-out + * view full of overlapping communities) pay O(members + segments) texel + * fetches each. Kernels have finite support, so the field at a pixel only + * depends on points within `fieldRadius` and capsules within their radius. + * Binning kernels into cells of `2 * fieldRadius` bounds every fragment's + * work to a handful of local kernels, and skips empty regions entirely + * (no instance -> no fragments at all). + * + * The packing preserves the field exactly: a cell's kernel list contains + * every point whose support disc intersects the cell rect and every capsule + * whose support (spine dilated by its radius) does, so the per-cell sum + * equals the full-community sum at every pixel inside the cell + * (`evaluateBubbleField` in `bubble-corridors.ts` is the test oracle). + * Contours are therefore pixel-identical to the unbinned rendering. + * + * Texel layout (rgba32float, {@link BUBBLE_TEX_WIDTH} wide, rows wrap), + * appended community by community: + * + * [cell point copies ...][cell capsule endpoint-pair copies ...] + * + * Point texel: `(x, y, _, _)`. Capsule pair: `(ax, ay, radius, _)` then + * `(bx, by, _, _)`; the same conventions the bubble shader already reads, + * so the shader is unchanged; only what an "instance" means changed. Texel + * channels the shader never samples (`.ba` of points, the pair's second + * `.ba`) are left unwritten between frames. + * + * Storage reuses grow-only {@link Column} buffers across frames; steady-state + * packing allocates only per-pack subarray views. Hot loops go through + * `Column.raw`, so steady-state packing allocates nothing but the five + * exact-length result views per pack. The reported texture height is + * monotone (grow-only) so the layer's texture is not re-created every time + * occupancy jitters by a row. + */ +import { Column } from "../worker/collections/column"; +import { forEachCapsuleCell } from "./bubble-grid-traversal"; + +/** Width of the positions texture the metaball shader samples (rows wrap). */ +export const BUBBLE_TEX_WIDTH = 256; + +/** + * Cell edge as a multiple of the point-kernel field radius. At 2× the + * support radius, a point's support disc intersects at most a 2×2 cell + * block, and a capsule (corridor radii are ≪ fieldRadius) only ever bleeds + * into the ring-1 neighbours of the cells its spine crosses. + */ +const CELL_SIZE_FIELD_RADII = 2; + +/** + * One grouping's kernels, as handed to {@link BubbleCellPacker.pack}. + * + * Fields are raw typed-array views into `CommunityGrouping`'s storage + * (community.ts), NOT {@link Column}s, deliberately: that storage has a + * fixed layout frozen when the grouping is built — fixed sizes, disjoint + * per-community segment regions at fixed offsets — and is rewritten in + * place by the gather loop and the corridor planner, which share these + * exact arrays. Nothing there grows or windows, so a `Column` wrapper + * would only imply resizability (which would corrupt the fixed offsets) + * and add unwrapping at every consumer. The packer's own state — which + * DOES grow and window per frame — is where `Column` earns its keep. + */ +export interface BubbleCellPack { + /** Number of rendered (kept) communities. */ + readonly keptCount: number; + /** Per kept community `[pointSlotOffset, memberCount]` (gather order). */ + readonly ranges: Float32Array; + /** Canonical member positions, texel stride 4 (`[x, y, _, _]` per slot). */ + readonly pointTexels: Float32Array; + /** Per kept community RGBA. */ + readonly colors: Uint8Array; + /** Per corridor segment `[slotA, slotB]`; absolute point slots. */ + readonly segmentSlots: Int32Array; + /** Per corridor segment: capsule kernel radius (world units). */ + readonly segmentRadius: Float32Array; + /** Per kept community: live segment count. */ + readonly segmentCounts: Int32Array; + /** Per kept community: first segment-storage index. */ + readonly segmentStorageOffsets: Int32Array; + /** Per kept community: point-kernel field radius (world units). Oversampled + * communities carry a larger radius (see `communityFieldRadius`), so the + * cell grid — sized from the radius — is coarser for them too. */ + readonly fieldRadii: Float32Array; +} + +/** Views over the packer's columns; valid until the next `pack()`. */ +export interface BubbleCellPackResult { + /** Occupied cells across all communities == instance count. */ + readonly cellCount: number; + /** Exactly `BUBBLE_TEX_WIDTH * texHeight` texels (*4 floats). */ + readonly texels: Float32Array; + readonly texHeight: number; + /** Per cell `[minX, minY, maxX, maxY]` world rect. */ + readonly bounds: Float32Array; + /** Per cell RGBA (its community's colour). */ + readonly colors: Uint8Array; + /** Per cell `[firstPointTexel, pointCount]`. */ + readonly nodeRanges: Float32Array; + /** Per cell `[firstEndpointPairTexel, segmentCount]`. */ + readonly segmentRanges: Float32Array; + /** Per cell: its community's point-kernel field radius (world units). */ + readonly fieldRadii: Float32Array; +} + +/** GPU-bound and scratch storage never leaves this thread. */ +const plain = { backing: "plain" } as const; + +/** + * Reusable packer: bins one grouping's kernels into per-cell instances every + * frame. Owns all storage; see the module header for the layout. + */ +export class BubbleCellPacker { + /** Point copies per cell; the instance pass turns counts into write cursors. */ + #cellPointCounts = new Column(Int32Array, 256, plain); + + /** Capsule copies per cell; the instance pass turns counts into write cursors. */ + #cellSegmentCounts = new Column(Int32Array, 256, plain); + + /** Cell → dense instance index, or -1 for empty cells. */ + #cellInstance = new Column(Int32Array, 256, plain); + + /** + * Per-segment visit stamp, so one segment marks each cell at most once. + * Contents deliberately PERSIST across packs (a stale stamp can never + * equal a fresh {@link #stampCounter} value, and `resize` re-exposes old + * slots as-is), so it is never zeroed in the steady state. A `BitSet` + * would do the same job but needs an O(cells) clear per segment; bumping + * the stamp is a free reset. + */ + #cellStamp = new Column(Int32Array, 256, plain); + + #stampCounter = 0; + + /** Collected `(cell, pointSlot)` incidences for the current community. */ + #pointPairs = new Column(Int32Array, 512, plain); + + /** Collected `(cell, segmentStorageIndex)` incidences. */ + #segmentPairs = new Column(Int32Array, 512, plain); + + /** Grid shape of the community currently being packed. */ + #cols = 0; + + #rows = 0; + + #texels = new Column(Float32Array, BUBBLE_TEX_WIDTH * 4, plain); + + #bounds = new Column(Float32Array, 256, plain); + + #colors = new Column(Uint8Array, 256, plain); + + #nodeRanges = new Column(Float32Array, 128, plain); + + #segmentRanges = new Column(Float32Array, 128, plain); + + #fieldRadii = new Column(Float32Array, 128, plain); + + /** Monotone texture height: keeps occupancy jitter from re-creating the GPU texture. */ + #texHeight = 1; + + /** Segment being traversed by {@link #markSegmentCell} (avoids a closure per segment). */ + #currentSegmentStorage = 0; + + /** Stamp/count views for the community being traversed. */ + #stampView = new Int32Array(0); + + #segmentCountsView = new Int32Array(0); + + /** Record one deduped `(cell, segment)` incidence during a capsule walk. */ + #markSegmentCell = (col: number, row: number): void => { + const cell = row * this.#cols + col; + if (this.#stampView[cell] === this.#stampCounter) { + return; + } + this.#stampView[cell] = this.#stampCounter; + this.#segmentCountsView[cell] = this.#segmentCountsView[cell]! + 1; + this.#segmentPairs.push(cell); + this.#segmentPairs.push(this.#currentSegmentStorage); + }; + + pack(pack: BubbleCellPack): BubbleCellPackResult { + const { keptCount, ranges, pointTexels } = pack; + + // Stamps stay valid across packs because the counter only grows; reset + // both before it can wrap (hours of continuous packing) so a stale stamp + // can never alias a fresh one. + if (this.#stampCounter > 0x40000000) { + this.#stampCounter = 0; + this.#cellStamp.fill(0); + } + + this.#bounds.clear(); + this.#colors.clear(); + this.#nodeRanges.clear(); + this.#segmentRanges.clear(); + this.#fieldRadii.clear(); + this.#texels.clear(); + + let cellTotal = 0; + let texelCursor = 0; + + for (let ci = 0; ci < keptCount; ci++) { + const pointOffset = ranges[ci * 2]!; + const memberCount = ranges[ci * 2 + 1]!; + if (memberCount === 0) { + continue; + } + + const fieldRadius = pack.fieldRadii[ci]!; + const cellSize = fieldRadius * CELL_SIZE_FIELD_RADII; + + // Grid over the community bbox EXPANDED by the field radius: kernels + // reach that far past the outermost member, and every pixel with a + // non-zero field must land in some cell. + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (let member = 0; member < memberCount; member++) { + const slot = (pointOffset + member) * 4; + const x = pointTexels[slot]!; + const y = pointTexels[slot + 1]!; + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + + const originX = minX - fieldRadius; + const originY = minY - fieldRadius; + + const cols = Math.max( + 1, + Math.ceil((maxX + fieldRadius - originX) / cellSize), + ); + const rows = Math.max( + 1, + Math.ceil((maxY + fieldRadius - originY) / cellSize), + ); + + this.#cols = cols; + this.#rows = rows; + const cellCount = cols * rows; + + this.#cellPointCounts.resize(cellCount); + this.#cellPointCounts.fill(0); + this.#cellSegmentCounts.resize(cellCount); + this.#cellSegmentCounts.fill(0); + this.#cellInstance.resize(cellCount); + this.#cellStamp.resize(cellCount); + + this.#collectPointPairs( + pack, + ci, + fieldRadius, + originX, + originY, + cellSize, + ); + this.#collectSegmentPairs(pack, ci, originX, originY, cellSize); + + // Occupied cells to dense instances, then prefix-sum both count + // columns into write cursors for the scatter passes. `raw` views: + // these loops are the packer's hot path, all indices are window- + // bounded above, and none of the columns grow until re-read. + const pointCounts = this.#cellPointCounts.raw; + const segmentCounts = this.#cellSegmentCounts.raw; + const cellInstance = this.#cellInstance.raw; + const instanceBase = cellTotal; + + let localInstances = 0; + let pointCopyTotal = 0; + let segmentCopyTotal = 0; + + for (let cell = 0; cell < cellCount; cell++) { + const pointCopies = pointCounts[cell]!; + const segmentCopies = segmentCounts[cell]!; + if (pointCopies === 0 && segmentCopies === 0) { + cellInstance[cell] = -1; + continue; + } + + cellInstance[cell] = instanceBase + localInstances; + localInstances += 1; + pointCopyTotal += pointCopies; + segmentCopyTotal += segmentCopies; + } + + const instanceEnd = instanceBase + localInstances; + this.#bounds.resize(instanceEnd * 4); + this.#colors.resize(instanceEnd * 4); + this.#nodeRanges.resize(instanceEnd * 2); + this.#segmentRanges.resize(instanceEnd * 2); + this.#fieldRadii.resize(instanceEnd); + + const pointRegionBase = texelCursor; + const segmentRegionBase = pointRegionBase + pointCopyTotal; + texelCursor = segmentRegionBase + segmentCopyTotal * 2; + this.#texels.resize(texelCursor * 4); + + const bounds = this.#bounds.raw; + const colors = this.#colors.raw; + const nodeRanges = this.#nodeRanges.raw; + const segmentRanges = this.#segmentRanges.raw; + const fieldRadii = this.#fieldRadii.raw; + + let pointCursor = pointRegionBase; + let segmentPairCursor = 0; + + const red = pack.colors[ci * 4]!; + const green = pack.colors[ci * 4 + 1]!; + const blue = pack.colors[ci * 4 + 2]!; + const alpha = pack.colors[ci * 4 + 3]!; + + for (let cell = 0; cell < cellCount; cell++) { + const instance = cellInstance[cell]!; + if (instance < 0) { + continue; + } + + const col = cell % cols; + const row = (cell - col) / cols; + + bounds[instance * 4] = originX + col * cellSize; + bounds[instance * 4 + 1] = originY + row * cellSize; + bounds[instance * 4 + 2] = originX + (col + 1) * cellSize; + bounds[instance * 4 + 3] = originY + (row + 1) * cellSize; + + colors[instance * 4] = red; + colors[instance * 4 + 1] = green; + colors[instance * 4 + 2] = blue; + colors[instance * 4 + 3] = alpha; + + fieldRadii[instance] = fieldRadius; + + nodeRanges[instance * 2] = pointCursor; + nodeRanges[instance * 2 + 1] = pointCounts[cell]!; + + segmentRanges[instance * 2] = segmentRegionBase + segmentPairCursor * 2; + segmentRanges[instance * 2 + 1] = segmentCounts[cell]!; + + // Prefix sums are complete; overwrite counts with absolute texel + // write cursors for the scatter passes. + const pointStart = pointCursor; + pointCursor += pointCounts[cell]!; + pointCounts[cell] = pointStart; + + const segmentStart = segmentPairCursor; + segmentPairCursor += segmentCounts[cell]!; + segmentCounts[cell] = segmentRegionBase + segmentStart * 2; + } + + this.#scatterPoints(pointTexels); + this.#scatterSegments(pack); + + cellTotal = instanceEnd; + } + + const neededHeight = Math.max(1, Math.ceil(texelCursor / BUBBLE_TEX_WIDTH)); + this.#texHeight = Math.max(this.#texHeight, neededHeight); + this.#texels.resize(BUBBLE_TEX_WIDTH * this.#texHeight * 4); + + // Exact-length views (not `raw`): deck sizes uploads by view length and + // the luma texture write must match texWidth × texHeight. + return { + cellCount: cellTotal, + texels: this.#texels.subarray().view, + texHeight: this.#texHeight, + bounds: this.#bounds.subarray().view, + colors: this.#colors.subarray().view, + nodeRanges: this.#nodeRanges.subarray().view, + segmentRanges: this.#segmentRanges.subarray().view, + fieldRadii: this.#fieldRadii.subarray().view, + }; + } + + /** + * Every `(cell, pointSlot)` incidence for community `ci`: cells whose rect + * intersects the point's support disc. Cell size is 2 × the support + * radius, so the candidate block is at most 2×2; the exact rect–disc test + * trims corner overreach. + */ + #collectPointPairs( + pack: BubbleCellPack, + ci: number, + fieldRadius: number, + originX: number, + originY: number, + cellSize: number, + ): void { + const { ranges, pointTexels } = pack; + const cols = this.#cols; + const rows = this.#rows; + const pointCounts = this.#cellPointCounts.raw; + const pointOffset = ranges[ci * 2]!; + const memberCount = ranges[ci * 2 + 1]!; + const radiusSq = fieldRadius * fieldRadius; + this.#pointPairs.clear(); + + for (let member = 0; member < memberCount; member++) { + const slot = pointOffset + member; + const x = pointTexels[slot * 4]!; + const y = pointTexels[slot * 4 + 1]!; + const colMin = Math.max( + 0, + Math.floor((x - fieldRadius - originX) / cellSize), + ); + const colMax = Math.min( + cols - 1, + Math.floor((x + fieldRadius - originX) / cellSize), + ); + + const rowMin = Math.max( + 0, + Math.floor((y - fieldRadius - originY) / cellSize), + ); + const rowMax = Math.min( + rows - 1, + Math.floor((y + fieldRadius - originY) / cellSize), + ); + + for (let row = rowMin; row <= rowMax; row++) { + for (let col = colMin; col <= colMax; col++) { + const cellMinX = originX + col * cellSize; + const cellMinY = originY + row * cellSize; + + const nearestX = Math.min(Math.max(x, cellMinX), cellMinX + cellSize); + const nearestY = Math.min(Math.max(y, cellMinY), cellMinY + cellSize); + + const dx = x - nearestX; + const dy = y - nearestY; + + if (dx * dx + dy * dy >= radiusSq) { + continue; + } + + const cell = row * cols + col; + pointCounts[cell] = pointCounts[cell]! + 1; + this.#pointPairs.push(cell); + this.#pointPairs.push(slot); + } + } + } + } + + /** + * Every `(cell, segment)` incidence for community `ci`: cells the capsule + * support touches ({@link forEachCapsuleCell} enumerates them; the stamp + * dedupes, since a straight walk revisits neighbours). + */ + #collectSegmentPairs( + pack: BubbleCellPack, + ci: number, + originX: number, + originY: number, + cellSize: number, + ): void { + const { + pointTexels, + segmentSlots, + segmentRadius, + segmentCounts, + segmentStorageOffsets, + } = pack; + + const storageStart = segmentStorageOffsets[ci]!; + const segmentCount = segmentCounts[ci]!; + + this.#segmentPairs.clear(); + this.#stampView = this.#cellStamp.raw; + this.#segmentCountsView = this.#cellSegmentCounts.raw; + + for (let segment = 0; segment < segmentCount; segment++) { + const storage = storageStart + segment; + const radius = Math.min(segmentRadius[storage]!, cellSize); + if (radius <= 0) { + continue; + } + + this.#stampCounter += 1; + this.#currentSegmentStorage = storage; + + forEachCapsuleCell( + { + ax: pointTexels[segmentSlots[storage * 2]! * 4]!, + ay: pointTexels[segmentSlots[storage * 2]! * 4 + 1]!, + bx: pointTexels[segmentSlots[storage * 2 + 1]! * 4]!, + by: pointTexels[segmentSlots[storage * 2 + 1]! * 4 + 1]!, + radius, + originX, + originY, + cellSize, + cols: this.#cols, + rows: this.#rows, + }, + this.#markSegmentCell, + ); + } + } + + /** Scatter collected point incidences into their cells' texel blocks. */ + #scatterPoints(pointTexels: Float32Array): void { + // `raw` spans capacity, so bound the walk by the columns' window lengths. + const pairCount = this.#pointPairs.length; + const pairs = this.#pointPairs.raw; + const cursors = this.#cellPointCounts.raw; + const texels = this.#texels.raw; + + for (let pair = 0; pair < pairCount; pair += 2) { + const cell = pairs[pair]!; + const slot = pairs[pair + 1]!; + const texel = cursors[cell]!; + + cursors[cell] = texel + 1; + texels[texel * 4] = pointTexels[slot * 4]!; + texels[texel * 4 + 1] = pointTexels[slot * 4 + 1]!; + } + } + + /** Scatter collected capsule incidences as endpoint-pair texels. */ + #scatterSegments(pack: BubbleCellPack): void { + const { pointTexels, segmentSlots, segmentRadius } = pack; + const pairCount = this.#segmentPairs.length; + const pairs = this.#segmentPairs.raw; + const cursors = this.#cellSegmentCounts.raw; + const texels = this.#texels.raw; + + for (let pair = 0; pair < pairCount; pair += 2) { + const cell = pairs[pair]!; + const storage = pairs[pair + 1]!; + const texel = cursors[cell]!; + + cursors[cell] = texel + 2; + + const slotA = segmentSlots[storage * 2]!; + const slotB = segmentSlots[storage * 2 + 1]!; + + texels[texel * 4] = pointTexels[slotA * 4]!; + texels[texel * 4 + 1] = pointTexels[slotA * 4 + 1]!; + texels[texel * 4 + 2] = segmentRadius[storage]!; + texels[(texel + 1) * 4] = pointTexels[slotB * 4]!; + texels[(texel + 1) * 4 + 1] = pointTexels[slotB * 4 + 1]!; + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/clusters.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/clusters.ts new file mode 100644 index 00000000000..d0d528a931d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/clusters.ts @@ -0,0 +1,326 @@ +/** + * Hierarchical-LOD render: cluster bubbles (positions mutated in place, + * radius/colour stable) and per-leaf entity dots + edges from the leaf SAB. + */ +import { LineLayer, ScatterplotLayer } from "@deck.gl/layers"; + +import { dimColor } from "../dim-color"; +import { graphColors } from "../visual-style"; +import { + leafColorAttribute, + leafNodeX, + leafNodeY, + leafPositionAttribute, +} from "../worker/buffers/position-buffer"; + +import type { PositionsFrame, RenderCluster, StructureFrame } from "../frames"; +import type { ClusterId } from "../ids"; +import type { ClusterReference } from "./frame-connection"; +import type { Layer } from "@deck.gl/core"; + +/** A cluster bubble with its current world position (mutated in place across frames). */ +export interface PlacedCluster { + readonly cluster: RenderCluster; + /** Index into `structure.clusters` / `positions.clusterPositions`. */ + readonly index: number; + x: number; + y: number; +} + +function containerFillColor( + cluster: RenderCluster, +): [number, number, number, number] { + // A wholly-frontier bubble (every member fetched-but-unexpanded) reads in the frontier + // grey, matching the greyed-out frontier dots; otherwise it carries its own type colour. + const allFrontier = + cluster.count > 0 && cluster.frontierCount === cluster.count; + const [red, green, blue, alpha] = allFrontier + ? graphColors.frontier + : cluster.color; + // Opened containers read as faint halos; leaf bubbles stay solid. + const out = + cluster.depth > 0 ? Math.max(20, Math.round(alpha * 0.22)) : alpha; + return [red, green, blue, out]; +} + +/** Build placed bubbles from a structure frame, deepest-container-first. */ +export function buildPlaced( + structure: StructureFrame, + positions: PositionsFrame, +): PlacedCluster[] { + const clusterPositions = positions.clusterPositions; + const placed = structure.clusters.map((cluster, index) => ({ + cluster, + index, + x: clusterPositions[index * 2] ?? 0, + y: clusterPositions[index * 2 + 1] ?? 0, + })); + placed.sort((lhs, rhs) => rhs.cluster.depth - lhs.cluster.depth); + return placed; +} + +/** Mutate placed positions in place (array identity preserved for updateTrigger). */ +export function updatePlaced( + placed: PlacedCluster[], + positions: PositionsFrame, +): void { + const clusterPositions = positions.clusterPositions; + for (const entry of placed) { + entry.x = clusterPositions[entry.index * 2] ?? 0; + entry.y = clusterPositions[entry.index * 2 + 1] ?? 0; + } +} + +/** Renders hierarchical cluster bubbles as pickable scatterplot instances with depth-based fill and highlight dimming. */ +export function clusterBubbleLayer( + placed: PlacedCluster[], + positionTick: number, + /** Clusters to keep at full colour during a highlight; null when no selection. */ + keepFull: ReadonlySet | null, + highlightTick: number, +): Layer { + return new ScatterplotLayer({ + id: "clusters", + data: placed, + getPosition: (datum) => [datum.x, datum.y], + getRadius: (datum) => datum.cluster.radius, + getFillColor: (datum) => { + const base = containerFillColor(datum.cluster); + // A leaf-level bubble (depth 0) holding nothing highlighted recedes. Open containers + // (depth > 0) are faint halos on the path to the selection, so they stay as they are. + if ( + keepFull === null || + datum.cluster.depth > 0 || + keepFull.has(datum.cluster.id) + ) { + return base; + } + return dimColor(base); + }, + radiusUnits: "common", + stroked: true, + getLineColor: graphColors.clusterStroke, + lineWidthUnits: "pixels", + getLineWidth: 1, + pickable: true, + updateTriggers: { + getPosition: positionTick, + getFillColor: highlightTick, + }, + }); +} + +/** Per open leaf: entity-incident edges and entity dots. */ +export function clusterEntityLayers(config: { + readonly structure: StructureFrame; + readonly positions: PositionsFrame; + readonly clusters: Map; + /** Drives the dots' getPosition updateTrigger, so the SAB re-uploads only on a tick. */ + readonly positionTick: number; + /** Highlighted entities' join keys (selection + ego); a leaf line whose endpoints aren't all + * in here dims, in step with the dots. Empty = no selection, every line full. */ + readonly highlightedEntities: ReadonlySet; +}): Layer[] { + const { structure, positions, clusters, positionTick, highlightedEntities } = + config; + const dimActive = highlightedEntities.size > 0; + // A leaf-local node is highlighted if its entityIdx (its nodeIds entry) is in the set -- the + // same lookup the dots' colours use, so a line dims exactly when its endpoints' dots do. + const nodeHighlighted = (ref: ClusterReference, local: number): boolean => { + const id = ref.nodeIds[local]; + // nodeIds store entityIdx as decimal strings allocated by the worker; + // Number() is exact below 2^53. + return id !== undefined && highlightedEntities.has(Number(id)); + }; + const clusterPositions = positions.clusterPositions; + // Fan-out geometry lives on PositionsFrame, not StructureFrame; index by + // layoutId once. + const fanOutByLeaf = new Map(); + for (const entry of positions.entityFanOut) { + fanOutByLeaf.set(entry.layoutId, entry.fanOut); + } + + const result: Layer[] = []; + for (const layer of structure.entityLayers) { + const cluster = clusters.get(layer.layoutId); + if (!cluster) { + continue; + } + + const originX = clusterPositions[layer.leafClusterIndex * 2] ?? 0; + const originY = clusterPositions[layer.leafClusterIndex * 2 + 1] ?? 0; + + const fanOut = fanOutByLeaf.get(layer.layoutId); + + const fanCount = fanOut ? Math.floor(fanOut.length / 3) : 0; + if (fanOut && fanCount > 0) { + const src = new Float32Array(fanCount * 2); + const dst = new Float32Array(fanCount * 2); + const colors = dimActive ? new Uint8Array(fanCount * 4) : undefined; + + for (let edge = 0; edge < fanCount; edge++) { + const entity = fanOut[edge * 3]!; + src[edge * 2] = leafNodeX(cluster.positions, entity); + src[edge * 2 + 1] = leafNodeY(cluster.positions, entity); + dst[edge * 2] = fanOut[edge * 3 + 1]!; + dst[edge * 2 + 1] = fanOut[edge * 3 + 2]!; + if (colors) { + // A feeder dims with its own dot (the source entity). + colors.set( + nodeHighlighted(cluster, entity) + ? layer.fanOutColor + : dimColor(layer.fanOutColor), + edge * 4, + ); + } + } + + result.push( + new LineLayer({ + id: `fanout:${layer.layoutId}`, + data: { + length: fanCount, + attributes: { + getSourcePosition: { value: src, size: 2 }, + getTargetPosition: { value: dst, size: 2 }, + ...(colors + ? { getColor: { value: colors, size: 4, normalized: true } } + : {}), + }, + }, + // oxfmt-ignore + modelMatrix: [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + originX, + originY, + 0, + 1, + ], + ...(colors ? {} : { getColor: layer.fanOutColor }), + getWidth: 1, + widthUnits: "pixels", + pickable: false, + }), + ); + } + + const internalCount = Math.floor(layer.internalEdges.length / 2); + if (internalCount > 0) { + const src = new Float32Array(internalCount * 2); + const dst = new Float32Array(internalCount * 2); + const colors = dimActive ? new Uint8Array(internalCount * 4) : undefined; + + for (let edge = 0; edge < internalCount; edge++) { + const left = layer.internalEdges[edge * 2]!; + const right = layer.internalEdges[edge * 2 + 1]!; + + src[edge * 2] = leafNodeX(cluster.positions, left); + src[edge * 2 + 1] = leafNodeY(cluster.positions, left); + dst[edge * 2] = leafNodeX(cluster.positions, right); + dst[edge * 2 + 1] = leafNodeY(cluster.positions, right); + if (colors) { + // Full only if both endpoints are highlighted (a line bridging in/out of the ego + // recedes with the field), matching the flat tier's per-link rule. + const full = + nodeHighlighted(cluster, left) && nodeHighlighted(cluster, right); + colors.set(full ? layer.color : dimColor(layer.color), edge * 4); + } + } + + result.push( + new LineLayer({ + id: `internal:${layer.layoutId}`, + data: { + length: internalCount, + attributes: { + getSourcePosition: { value: src, size: 2 }, + getTargetPosition: { value: dst, size: 2 }, + ...(colors + ? { getColor: { value: colors, size: 4, normalized: true } } + : {}), + }, + }, + // oxfmt-ignore + modelMatrix: [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + originX, + originY, + 0, + 1, + ], + ...(colors ? {} : { getColor: layer.color }), + getWidth: 1, + opacity: 0.5, + widthUnits: "pixels", + pickable: false, + }), + ); + } + + // Entity dots read straight from the interleaved leaf SAB (no per-frame gather): position + // and per-node colour are binary attributes over the same buffer, the leaf origin is a + // modelMatrix uniform, and the positionTick updateTrigger re-uploads on a tick / recolour. + result.push( + new ScatterplotLayer({ + id: `entities:${layer.layoutId}`, + data: { + length: layer.count, + attributes: { + getPosition: leafPositionAttribute(cluster.versionView.buffer), + getFillColor: leafColorAttribute(cluster.versionView.buffer), + }, + }, + // oxfmt-ignore + modelMatrix: [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + originX, + originY, + 0, + 1, + ], + getRadius: layer.radius, + radiusUnits: "common", + pickable: true, + updateTriggers: { + getPosition: positionTick, + getFillColor: positionTick, + }, + }), + ); + } + + return result; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/community.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/community.ts new file mode 100644 index 00000000000..e3a3937ea63 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/community.ts @@ -0,0 +1,401 @@ +/** + * Community-force metaball isocontours: one per Louvain community, drawn + * behind dots/edges. Gathers each kept community's node centres from the + * SAB, then bins every kernel into per-cell instances (`bubble-grid.ts`) + * whose texel ranges the SDF shader sums and thresholds. + * + * Connectivity: a community's members can sit in clumps farther apart than + * the metaball reach, which would render one community as several bubble + * islands. `bubble-corridors.ts` plans thin capsule corridors along a + * deterministic MST over each community's members (BubbleSets' virtual + * edges); the packer copies their endpoint pairs into the same positions + * texture, so the shader's field (and therefore the iso-contour) is + * connected per community. Corridor topology is replanned only when members + * drift (movement-gated); endpoint positions track the live gather each + * frame via the pack. + * + * The per-community index list is cached by `communities` array identity + * ({@link groupingCache}); a settling frame skips the regroup and only + * re-gathers moved node centres plus re-packs the grid. + */ + +import { communityColorForId } from "../visual-style"; +import { + FLAT_HEADER_BYTES, + FLAT_RECORD_BYTES, +} from "../worker/buffers/position-buffer"; +import { BitSet } from "../worker/collections/bitset"; +import { + BUBBLE_ISO_THRESHOLD, + CORRIDOR_ANCHOR_CAP, + planBubbleCorridors, +} from "./bubble-corridors"; +import { BubbleCellPacker, BUBBLE_TEX_WIDTH } from "./bubble-grid"; +import { + BubbleSetSDFLayer, + MAX_NODES_PER_COMMUNITY, +} from "./gpu/bubble-set-sdf-layer"; + +import type { RenderFlatGraph } from "../frames"; +import type { ClusterId } from "../ids"; +import type { ClusterReference } from "./frame-connection"; +import type { Layer } from "@deck.gl/core"; + +/** + * Base metaball field radius (world units). + * + * @defaultValue 50. Neighbouring nodes merge into one blob; lower values + * tighten hulls and increase disconnected-clump risk before corridors + * engage; higher values thicken overlaps and raise per-fragment kernel cost. + */ +const FLAT_BUBBLE_FIELD_RADIUS = 50; + +/** + * Effective kernel radius for one community: the base radius scaled by the + * sample-density deficit. A community larger than the shader's node cap is + * downsampled to fit, which stretches the spacing between rendered kernels + * by √(true / sampled); without compensation the kernels stop merging and + * the hull disintegrates into a maze of blobs and corridor worms (observed + * on a 2.9k-member star community: sampled spacing ≈ kernel radius). + * Scaling the radius by the same √ factor restores the merge behaviour of + * the fully-sampled community at its true density. + */ +function communityFieldRadius(trueMemberCount: number): number { + return ( + FLAT_BUBBLE_FIELD_RADIUS * + Math.max(1, Math.sqrt(trueMemberCount / MAX_NODES_PER_COMMUNITY)) + ); +} + +/** Promote only non-trivial communities; a pair/singleton needs no hull, and + * bubbling every one (most of a mostly-disconnected graph) is what turns the + * canvas to mud. Tunable. */ +const MIN_COMMUNITY_SIZE = 4; +/** Replan a community's corridors once any member drifts this far (world + * units) from where its plan was made. Endpoints track live positions every + * frame regardless; this only refreshes the MST/obstacle topology. */ +const CORRIDOR_REPLAN_DISPLACEMENT = 24; + +/** + * Stable grouping for one Louvain result. Changes only when Louvain reruns; + * cached by array identity and reused across position frames while settling. + */ +interface CommunityGrouping { + /** Kept communities' node SAB indices, laid out community-by-community in gather order. */ + readonly memberIndices: Int32Array; + /** Per kept community: `[offset, count]` into {@link memberIndices} / {@link pointTexels}. */ + readonly ranges: Float32Array; + /** Per kept community: its Louvain community id (corridor obstacle tests). */ + readonly communityIds: Int32Array; + /** Per kept community RGBA (community id → colour). Constant for the grouping's life. */ + readonly colors: Uint8Array; + /** Per kept community: adaptive point-kernel radius (world units). */ + readonly fieldRadii: Float32Array; + readonly keptCount: number; + /** CPU-side canonical member positions (texel stride 4), refilled from the + * SAB each frame. The corridor planner reads it; the GPU never sees it; + * the packer copies kernels into the per-cell texture instead. */ + readonly pointTexels: Float32Array; + /** Bins kernels into per-cell instances every frame (owns all scratch). */ + readonly cellPacker: BubbleCellPacker; + + // Corridor topology is planned by {@link planBubbleCorridors} into + // segmentSlots/segmentRadius/segmentCount. + /** Per segment `[slotA, slotB]` point-texel slots; capacity 2 · (k - 1) per community. */ + readonly segmentSlots: Int32Array; + /** Per segment capsule radius (world units). */ + readonly segmentRadius: Float32Array; + /** Per kept community: live segment count. */ + readonly segmentCount: Int32Array; + /** Per kept community: first segment-storage index (fixed disjoint regions). */ + readonly segmentStorageOffsets: Int32Array; + /** Member positions at each community's last corridor plan (2 floats/slot). */ + readonly planSnapshot: Float32Array; + /** Per kept community: replan request scratch (movement-gated). */ + readonly replanScratch: BitSet; + /** False until the first plan has run (plan everything once). */ + planned: boolean; + /** True once the settled-frame replan has run (reset if the layout resumes). */ + settledPlanned: boolean; + /** Bumped on each re-pack to trigger an in-place texture re-upload. */ + version: number; +} + +const groupingCache = new WeakMap(); + +/** Build the stable grouping for a Louvain membership array, or null if no community is big enough. */ +function buildGrouping( + membership: Int32Array, + count: number, +): CommunityGrouping | null { + const byCommunity = new Map(); + for (let idx = 0; idx < count; idx++) { + const community = membership[idx] ?? -1; + if (community < 0) { + continue; + } + + const members = byCommunity.get(community); + if (members) { + members.push(idx); + } else { + byCommunity.set(community, [idx]); + } + } + + const kept = [...byCommunity.entries()] + .filter(([, members]) => members.length >= MIN_COMMUNITY_SIZE) + .map(([community, members]) => { + // The shader sums at most MAX_NODES_PER_COMMUNITY centres per hull. + // Downsample evenly across the member list (not first-N arrival order) + // so an oversized community keeps its overall footprint; the adaptive + // kernel radius ({@link communityFieldRadius}) compensates for the + // thinned interior density. + if (members.length <= MAX_NODES_PER_COMMUNITY) { + return [community, members, members.length] as const; + } + + const step = members.length / MAX_NODES_PER_COMMUNITY; + const sampled: number[] = []; + for (let pick = 0; pick < MAX_NODES_PER_COMMUNITY; pick++) { + sampled.push(members[Math.floor(pick * step)]!); + } + + return [community, sampled, members.length] as const; + }); + + if (kept.length === 0) { + return null; + } + + const totalNodes = kept.reduce((sum, [, members]) => sum + members.length, 0); + + // Corridor capacity: an MST over ≤ CORRIDOR_ANCHOR_CAP anchors has + // anchors - 1 edges, each emitting ≤ 2 segments (reroute split). + const totalSegmentCapacity = kept.reduce( + (sum, [, members]) => + sum + Math.max(0, Math.min(members.length, CORRIDOR_ANCHOR_CAP) - 1) * 2, + 0, + ); + + const memberIndices = new Int32Array(totalNodes); + const ranges = new Float32Array(kept.length * 2); + const communityIds = new Int32Array(kept.length); + const colors = new Uint8Array(kept.length * 4); + const fieldRadii = new Float32Array(kept.length); + const segmentStorageOffsets = new Int32Array(kept.length); + + let offset = 0; + let segmentStorage = 0; + for (let ci = 0; ci < kept.length; ci++) { + const [community, members, trueMemberCount] = kept[ci]!; + ranges[ci * 2] = offset; + ranges[ci * 2 + 1] = members.length; + communityIds[ci] = community; + fieldRadii[ci] = communityFieldRadius(trueMemberCount); + + segmentStorageOffsets[ci] = segmentStorage; + segmentStorage += + Math.max(0, Math.min(members.length, CORRIDOR_ANCHOR_CAP) - 1) * 2; + + const [red, green, blue, alpha] = communityColorForId(community); + colors[ci * 4] = red; + colors[ci * 4 + 1] = green; + colors[ci * 4 + 2] = blue; + colors[ci * 4 + 3] = alpha; + + for (const idx of members) { + memberIndices[offset] = idx; + offset += 1; + } + } + + return { + memberIndices, + ranges, + communityIds, + colors, + fieldRadii, + keptCount: kept.length, + pointTexels: new Float32Array(totalNodes * 4), + cellPacker: new BubbleCellPacker(), + segmentSlots: new Int32Array(totalSegmentCapacity * 2), + segmentRadius: new Float32Array(totalSegmentCapacity), + segmentCount: new Int32Array(kept.length), + segmentStorageOffsets, + planSnapshot: new Float32Array(totalNodes * 2), + replanScratch: BitSet.empty(kept.length), + planned: false, + settledPlanned: false, + version: 0, + }; +} + +/** + * Build the community bubble-set layer for the current frame. The grouping + * topology is cached; only node centres and bounding boxes are refreshed + * (plus a movement-gated corridor replan when members drift). + * + * `settled` (the positions frame's terminal flag) forces one final corridor + * replan from the settled coordinates, so the resting corridor topology is a + * pure function of the deterministic layout, not of which animation frames + * happened to trip the movement gate along the way. + */ +export function communityLayer( + graph: RenderFlatGraph, + clusters: Map, + settled = false, +): Layer[] { + const membership = graph.communities; + if (!membership) { + return []; + } + + const cluster = clusters.get(graph.layoutId); + if (!cluster) { + return []; + } + + let grouping = groupingCache.get(membership); + if (grouping === undefined) { + const built = buildGrouping(membership, graph.count); + if (built === null) { + return []; + } + + grouping = built; + groupingCache.set(membership, grouping); + } + + // Safe to read: this runs on a coalesced position event after the worker + // publishes the buffer (Atomics.notify on versionView). + const floats = new Float32Array(cluster.versionView.buffer); + const headerFloats = FLAT_HEADER_BYTES / 4; + const recordFloats = FLAT_RECORD_BYTES / 4; + const { + memberIndices, + ranges, + keptCount, + pointTexels, + planSnapshot, + replanScratch, + } = grouping; + + // Re-gather node centres in place. The same pass movement-gates the + // corridor replan: any member drifting beyond the threshold from its + // plan-time position marks its community. + const replanDistSq = + CORRIDOR_REPLAN_DISPLACEMENT * CORRIDOR_REPLAN_DISPLACEMENT; + + // The settled frame replans all communities once from the final coordinates + // (deterministic resting topology); a resumed layout re-arms the flag. + const settledReplan = settled && !grouping.settledPlanned; + grouping.settledPlanned = settled; + + let needsPlan = !grouping.planned || settledReplan; + replanScratch.clear(); + + for (let ci = 0; ci < keptCount; ci++) { + const start = ranges[ci * 2]!; + const memberCount = ranges[ci * 2 + 1]!; + let drifted = false; + + for (let member = 0; member < memberCount; member++) { + const slot = start + member; + const idx = memberIndices[slot]!; + const posX = floats[headerFloats + idx * recordFloats] ?? 0; + const posY = floats[headerFloats + idx * recordFloats + 1] ?? 0; + pointTexels[slot * 4] = posX; + pointTexels[slot * 4 + 1] = posY; + + if (!drifted) { + const dx = posX - planSnapshot[slot * 2]!; + const dy = posY - planSnapshot[slot * 2 + 1]!; + drifted = dx * dx + dy * dy > replanDistSq; + } + } + + if (!grouping.planned || settledReplan || drifted) { + replanScratch.add(ci); + } + + needsPlan ||= drifted; + } + + if (needsPlan) { + planBubbleCorridors({ + keptCount, + ranges, + communityIds: grouping.communityIds, + pointTexels, + fieldRadii: grouping.fieldRadii, + replan: grouping.planned ? replanScratch : null, + floats, + headerFloats, + recordFloats, + membership, + nodeCount: graph.count, + segmentSlots: grouping.segmentSlots, + segmentRadius: grouping.segmentRadius, + segmentCounts: grouping.segmentCount, + segmentStorageOffsets: grouping.segmentStorageOffsets, + }); + + for (let ci = 0; ci < keptCount; ci++) { + if (!grouping.planned || replanScratch.has(ci)) { + const start = ranges[ci * 2]!; + const memberCount = ranges[ci * 2 + 1]!; + + for (let member = 0; member < memberCount; member++) { + const slot = start + member; + planSnapshot[slot * 2] = pointTexels[slot * 4]!; + planSnapshot[slot * 2 + 1] = pointTexels[slot * 4 + 1]!; + } + } + } + + grouping.planned = true; + } + + // Bin every kernel (fresh member positions + live corridor endpoints) into + // per-cell instances; corridors track the animation because the pack reads + // endpoint positions straight from the point texels gathered above. + const packed = grouping.cellPacker.pack({ + keptCount, + ranges, + pointTexels, + colors: grouping.colors, + segmentSlots: grouping.segmentSlots, + segmentRadius: grouping.segmentRadius, + segmentCounts: grouping.segmentCount, + segmentStorageOffsets: grouping.segmentStorageOffsets, + fieldRadii: grouping.fieldRadii, + }); + grouping.version += 1; + + return [ + new BubbleSetSDFLayer({ + id: "flat-bubbles", + data: { + length: packed.cellCount, + attributes: { + getBounds: { value: packed.bounds, size: 4 }, + getColor: { value: packed.colors, size: 4 }, + getNodeRange: { value: packed.nodeRanges, size: 2 }, + getSegmentRange: { value: packed.segmentRanges, size: 2 }, + getFieldRadius: { value: packed.fieldRadii, size: 1 }, + }, + }, + positions: packed.texels, + positionsVersion: grouping.version, + texWidth: BUBBLE_TEX_WIDTH, + texHeight: packed.texHeight, + isoThreshold: BUBBLE_ISO_THRESHOLD, + // A backdrop must not write depth, or its bounding quad stamps the depth buffer and the + // coplanar edges drawn after it (the flat tier draws bubbles first) fail the depth test and + // vanish. Set at the layer level so deck's render pass honours it -- the Model parameter + // alone is overridden by the pass defaults. + parameters: { depthWriteEnabled: false, depthCompare: "always" }, + }), + ]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/edge-arrows.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/edge-arrows.ts new file mode 100644 index 00000000000..22abd903a30 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/edge-arrows.ts @@ -0,0 +1,127 @@ +import { TextLayer } from "@deck.gl/layers"; + +import { EndpointVLayer } from "./gpu/endpoint-v-layer"; + +import type { PositionsFrame, RenderEdgeArrow } from "../frames"; +import type { Layer } from "@deck.gl/core"; + +const EDGE_ARROW_MIN_SCREEN_CHORD = 36; +const EDGE_ARROW_FADE_PX = 14; +const EDGE_ARROW_TEXT = "›"; + +interface EdgeArrowSplit { + readonly lanes: RenderEdgeArrow[]; + readonly endpoints: RenderEdgeArrow[]; +} + +function fadeAlpha(screenMetric: number, threshold: number, alpha: number) { + const progress = Math.min( + 1, + Math.max( + 0, + (screenMetric - threshold + EDGE_ARROW_FADE_PX) / EDGE_ARROW_FADE_PX, + ), + ); + return Math.round(alpha * progress); +} + +function arrowAngleDegrees(arrow: RenderEdgeArrow): number { + // TextLayer angles are screen-space degrees; the graph's y axis is projected through + // OrthographicView, so mirror the worker's world-space radians as labels do. + return (-arrow.angle * 180) / Math.PI; +} + +function splitEdgeArrows(arrows: readonly RenderEdgeArrow[]): EdgeArrowSplit { + // edgeArrows is a new array each frame, so partition in place (cheap for + // bounded length). + const lanes: RenderEdgeArrow[] = []; + const endpoints: RenderEdgeArrow[] = []; + for (const arrow of arrows) { + if (arrow.kind === "lane") { + lanes.push(arrow); + } else { + endpoints.push(arrow); + } + } + return { lanes, endpoints }; +} + +export function edgeArrowLayer( + positions: PositionsFrame, + zoom: number, +): Layer[] { + const scale = 2 ** zoom; + const layers: Layer[] = []; + + // Flat tier: packed per-edge arrows straight to the GPU as binary + // attributes; chord fade runs in the shader against deck's project module + // (world→pixel via the live viewport), so 22k+ arrows cost zero per-frame + // CPU here. + const { flatArrows } = positions; + if (flatArrows && flatArrows.count > 0) { + layers.push( + new EndpointVLayer({ + id: "edge-endpoint-arrows", + data: { + length: flatArrows.count, + attributes: { + getPosition: { value: flatArrows.positions, size: 2 }, + getAngle: { value: flatArrows.angles, size: 1 }, + getSize: { value: flatArrows.sizes, size: 1 }, + getChord: { value: flatArrows.chords, size: 1 }, + getColor: { value: flatArrows.colors, size: 4 }, + }, + }, + pickable: false, + parameters: { depthWriteEnabled: false, depthCompare: "always" }, + }), + ); + } + + if (positions.edgeArrows.length === 0) { + return layers; + } + + // Hierarchical tier: bounded object arrows (a few hundred lane marks). + const { lanes, endpoints } = splitEdgeArrows(positions.edgeArrows); + if (endpoints.length > 0) { + layers.push( + new EndpointVLayer({ + id: "edge-endpoint-arrows", + data: endpoints, + getSize: (arrow) => arrow.size, + getColor: (arrow) => arrow.color, + pickable: false, + parameters: { depthWriteEnabled: false, depthCompare: "always" }, + }), + ); + } + + if (lanes.length > 0) { + layers.push( + new TextLayer({ + id: "edge-lane-chevrons", + data: lanes, + getPosition: (arrow) => [arrow.x, arrow.y], + getText: () => EDGE_ARROW_TEXT, + getSize: (arrow) => arrow.size, + sizeUnits: "common", + getAngle: arrowAngleDegrees, + getColor: (arrow) => [ + 255, + 255, + 255, + fadeAlpha(arrow.chord * scale, EDGE_ARROW_MIN_SCREEN_CHORD, 230), + ], + getTextAnchor: "middle", + getAlignmentBaseline: "center", + fontFamily: "Inter, sans-serif", + fontWeight: 700, + characterSet: [EDGE_ARROW_TEXT], + pickable: false, + parameters: { depthWriteEnabled: false, depthCompare: "always" }, + }), + ); + } + return layers; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/edges.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/edges.test.ts new file mode 100644 index 00000000000..17b083ce8dc --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/edges.test.ts @@ -0,0 +1,53 @@ +import { LineLayer } from "@deck.gl/layers"; +import { describe, expect, it } from "vitest"; + +import { BEZIER_NO_LINK } from "../frames"; +import { edgeLayer } from "./edges"; +import { BezierSDFLayer } from "./gpu/bezier-sdf-layer"; + +import type { PositionsFrame, RenderBezierBuffers } from "../frames"; + +function beziers(): RenderBezierBuffers { + return { + positions: new Float32Array([0, 0, 10, 0, 20, 0, 30, 0]), + colors: new Uint8Array([180, 80, 80, 200]), + widths: new Float32Array([1.2]), + clips: new Float32Array(6), + ids: new Uint32Array([BEZIER_NO_LINK]), + segmentCount: 1, + }; +} + +function positionsFrame(): PositionsFrame { + return { + version: 1, + settled: true, + clusterPositions: new Float32Array(), + beziers: beziers(), + edgeLabels: [], + edgeArrows: [], + entityFanOut: [], + }; +} + +describe("edgeLayer", () => { + it("uses a distinct LineLayer id for flat edges", () => { + const layers = edgeLayer(positionsFrame(), true); + + expect(layers).toHaveLength(1); + expect(layers[0]?.id).toBe("flat-edges"); + expect(layers[0]).toBeInstanceOf(LineLayer); + }); + + it("renders hierarchical edges as ONE halo-folded Bezier layer", () => { + const layers = edgeLayer(positionsFrame(), false); + + // Hierarchical edges use one BezierSDFLayer; haloWidthFactor must be > 1 + // for the soft underlay band. + expect(layers.map((layer) => layer.id)).toEqual(["hierarchical-edges"]); + expect(layers[0]).toBeInstanceOf(BezierSDFLayer); + expect((layers[0] as BezierSDFLayer).props.haloWidthFactor).toBeGreaterThan( + 1, + ); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/edges.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/edges.ts new file mode 100644 index 00000000000..c3c9168db3a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/edges.ts @@ -0,0 +1,100 @@ +/** + * Edge rendering: hierarchical edges as GPU SDF beziers, flat edges as + * LineLayer segments (collinear control points). + */ +import { LineLayer } from "@deck.gl/layers"; + +import { graphColors } from "../visual-style"; +import { BezierSDFLayer } from "./gpu/bezier-sdf-layer"; + +import type { PositionsFrame, RenderBezierBuffers } from "../frames"; +import type { Layer } from "@deck.gl/core"; + +/** Halo width as a multiple of the core stroke width. */ +const EDGE_HALO_WIDTH_FACTOR = 1.65; + +function bezierData( + beziers: RenderBezierBuffers, + colors: Uint8Array, +): ConstructorParameters[0]["data"] { + return { + length: beziers.segmentCount, + attributes: { + getP0: { value: beziers.positions, size: 2, stride: 32, offset: 0 }, + getP1: { value: beziers.positions, size: 2, stride: 32, offset: 8 }, + getP2: { value: beziers.positions, size: 2, stride: 32, offset: 16 }, + getP3: { value: beziers.positions, size: 2, stride: 32, offset: 24 }, + getColor: { value: colors, size: 4 }, + getWidth: { value: beziers.widths, size: 1 }, + getClipA: { value: beziers.clips, size: 3, stride: 24, offset: 0 }, + getClipB: { value: beziers.clips, size: 3, stride: 24, offset: 12 }, + }, + }; +} + +function lineData( + beziers: RenderBezierBuffers, + colors: Uint8Array, +): ConstructorParameters[0]["data"] { + return { + length: beziers.segmentCount, + attributes: { + instanceSourcePositions: { + value: beziers.positions, + size: 2, + stride: 32, + offset: 0, + }, + instanceTargetPositions: { + value: beziers.positions, + size: 2, + stride: 32, + offset: 24, + }, + instanceColors: { value: colors, size: 4, type: "unorm8" }, + instanceWidths: { value: beziers.widths, size: 1 }, + }, + }; +} + +export function edgeLayer(positions: PositionsFrame, isFlat: boolean): Layer[] { + const { beziers } = positions; + if (beziers.segmentCount === 0) { + return []; + } + + const widthScale = 1; + const parameters = { + depthWriteEnabled: false, + depthCompare: "always", + } as const; + + if (isFlat) { + return [ + new LineLayer({ + id: "flat-edges", + data: lineData(beziers, beziers.colors), + pickable: true, + widthUnits: "common", + widthScale, + parameters, + }), + ]; + } + + // Halo + core share one SDF evaluation; haloWidthFactor widens the stroke + // band in the same fragment pass instead of drawing a second padded quad. + return [ + new BezierSDFLayer({ + id: "hierarchical-edges", + data: bezierData(beziers, beziers.colors), + pickable: true, + boundsPaddingPixels: 8, + widthUnits: "common", + widthScale, + haloWidthFactor: EDGE_HALO_WIDTH_FACTOR, + haloColor: graphColors.edgeUnderlay, + parameters, + }), + ]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/entity-worker-connection.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/entity-worker-connection.ts new file mode 100644 index 00000000000..cef58f8fe7d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/entity-worker-connection.ts @@ -0,0 +1,279 @@ +/** + * The entity-graph lifecycle's worker connection: boots `INIT_ENTITY` and + * implements {@link SceneHandle} on top of {@link FrameConnection}'s + * frame stream -- EntityIdx <-> EntityId resolution via the id-map SAB, ingest + * and type registration, ego / highway / fixture queries, pin and highlight, + * and the embedding-clustering fetch round-trip. + * + * Node keys are {@link EntityIndex}es: the scene passes them around as plain + * numbers (see {@link "./scene/handle"}); this connection brands them at the + * protocol boundary. + */ +import { ClusterId, type EntityIndex } from "../ids"; +import { decodeEntityId, ID_HEADER_BYTES } from "../worker/entity-id-codec"; +import { + flatRecordJoinKey, + FrameConnection, + PendingRequests, +} from "./frame-connection"; + +import type { + CapturedLayoutFixture, + EgoTarget, + IngestEntity, + PropertySchemaEntry, + TypeSchemaEntry, +} from "../worker/protocol"; +import type { + FrameConnectionConfig, + LifecycleMessage, +} from "./frame-connection"; +import type { FlatEdgePick, NodeEgo, SceneHandle } from "./scene/handle"; +import type { EntityId } from "@blockprotocol/type-system"; + +/** The public surface the entity presentation drives Deck.gl from. */ +export interface EntityWorkerHandle extends SceneHandle< + EntityId, + EntityIndex, + EntityIndex +> { + /** + * capture-live-fixture debug hook: serialize the live flat-tier layout graph + * for replay as a bench/test fixture. Null when no flat layout is live. + */ + captureLayoutFixture(): Promise; + ingestBatch(entities: readonly IngestEntity[]): void; + registerTypes( + typeSchemas: readonly TypeSchemaEntry[], + propertySchemas: readonly PropertySchemaEntry[], + ): void; +} + +export class EntityWorkerConnection + extends FrameConnection + implements EntityWorkerHandle +{ + /** Records-region view of the EntityIdx->EntityId join map SAB. */ + #entityIdMapBytes: Uint8Array | undefined; + + #batchId = 0; + /** Correlates async ego queries (ego-highlight) with their replies. */ + readonly #egoRequests = new PendingRequests(); + /** Correlates async highway-links queries (opening a highway's link table) with replies. */ + readonly #highwayRequests = new PendingRequests(); + /** Correlates capture-live-fixture requests (debug hook) with replies. */ + readonly #fixtureRequests = + new PendingRequests(); + + constructor(connectionConfig: FrameConnectionConfig) { + super(connectionConfig); + this.send({ + type: "INIT_ENTITY", + config: this.withDebugFlag(connectionConfig.config), + typeSchemas: [], + propertySchemas: [], + }); + } + + resolveNodeId( + layoutId: ClusterId, + recordIndex: number, + ): EntityId | undefined { + const entityIdx = this.nodeKeyAt(layoutId, recordIndex); + return entityIdx === undefined ? undefined : this.nodeKeyToId(entityIdx); + } + + nodeKeyToId(nodeKey: EntityIndex): EntityId | undefined { + const mapBytes = this.#entityIdMapBytes; + return mapBytes === undefined + ? undefined + : decodeEntityId(mapBytes, nodeKey); + } + + nodeKeyAt(layoutId: ClusterId, recordIndex: number): EntityIndex | undefined { + const cluster = this.getClusters().get(layoutId); + if (!cluster || recordIndex < 0) { + return undefined; + } + + // Hierarchical leaf: nodeIds[recordIndex] IS the entityIdx (stringified). + // Flat-tier FlatGraphBuffer: records reorder as entities stream, so read + // the current entityIdx join key off the record itself. + if (cluster.flatCapacity === undefined) { + const idx = cluster.nodeIds[recordIndex]; + // nodeIds entries are entityIdx decimal strings from the worker; + // Number() is exact below 2^53. + return idx === undefined ? undefined : (Number(idx) as EntityIndex); + } + + return flatRecordJoinKey(cluster, recordIndex) as EntityIndex | undefined; + } + + /** A flat edge IS a link entity here: its bezier id is the link's EntityIdx. */ + resolveFlatEdge(edgeId: EntityIndex): FlatEdgePick | null { + const nodeId = this.nodeKeyToId(edgeId); + return nodeId === undefined ? null : { kind: "node", nodeId }; + } + + queryEgo(nodeKey: EntityIndex): Promise> { + return new Promise((resolve) => { + this.send({ + type: "QUERY_EGO", + requestId: this.#egoRequests.open((targets) => { + // Split the worker's visible-representative targets into scene + // currency: individually rendered neighbours vs collapsed bubbles. + const nodeKeys: EntityIndex[] = []; + const clusterIds: ClusterId[] = []; + for (const target of targets) { + if (target.kind === "entity") { + nodeKeys.push(target.entityIdx); + } else { + clusterIds.push(target.clusterId); + } + } + resolve({ nodeKeys, clusterIds }); + }), + entityIdx: nodeKey, + }); + }); + } + + queryHighwayLinks(laneId: number): Promise { + return new Promise((resolve) => { + this.send({ + type: "QUERY_HIGHWAY_LINKS", + requestId: this.#highwayRequests.open(resolve), + laneId, + }); + }); + } + + captureLayoutFixture(): Promise { + return new Promise((resolve) => { + this.send({ + type: "CAPTURE_LAYOUT_FIXTURE", + requestId: this.#fixtureRequests.open(resolve), + }); + }); + } + + setPinned(clusterId: ClusterId | null): void { + this.send({ type: "SET_PINNED", clusterId }); + } + + setHighlight(nodeKeys: readonly EntityIndex[]): void { + this.send({ + type: "SET_HIGHLIGHT", + entityIdxs: nodeKeys, + }); + } + + ingestBatch(entities: readonly IngestEntity[]): void { + this.#batchId += 1; + this.send({ + type: "INGEST_BATCH", + batchId: `batch-${this.#batchId}`, + entities, + }); + } + + registerTypes( + typeSchemas: readonly TypeSchemaEntry[], + propertySchemas: readonly PropertySchemaEntry[], + ): void { + this.send({ type: "REGISTER_TYPES", typeSchemas, propertySchemas }); + } + + /** Also resolves in-flight queries so awaiters don't hang after teardown. */ + override dispose(): void { + super.dispose(); + this.#egoRequests.settleAll([]); + this.#highwayRequests.settleAll([]); + this.#fixtureRequests.settleAll(null); + } + + protected handleLifecycleMessage(message: LifecycleMessage): void { + switch (message.type) { + case "ENTITY_ID_MAP": + // A fresh length-tracking view covers any in-place growth that follows. + this.#entityIdMapBytes = new Uint8Array( + message.buffer, + ID_HEADER_BYTES, + ); + break; + case "EMBEDDING_CLUSTERING_NEEDED": + void this.#fetchEmbeddingClusters( + message.clusterId, + message.entityIds as string[], + message.clusterCount, + ); + break; + case "EGO_RESULT": + this.#egoRequests.settle(message.requestId, message.targets); + break; + case "HIGHWAY_LINKS_RESULT": + this.#highwayRequests.settle(message.requestId, message.linkEntityIdxs); + break; + case "LAYOUT_FIXTURE_RESULT": + this.#fixtureRequests.settle(message.requestId, message.fixture); + break; + case "MODE_CHANGED": + // Informational; mode is read from StructureFrame on commit. + break; + default: + // Type-lifecycle replies never arrive on an INIT_ENTITY worker. + break; + } + } + + async #fetchEmbeddingClusters( + clusterId: string, + entityIds: string[], + clusterCount: number, + ): Promise { + try { + const apiOrigin = + process.env.NEXT_PUBLIC_API_ORIGIN ?? "http://localhost:5001"; + const response = await fetch( + `${apiOrigin}/entities/embeddings/clusters`, + { + method: "POST", + // Send the Kratos session cookie so hash-api resolves the authenticated + // actor; without this the request is treated as the public actor and the + // graph filters out every entity the user is allowed to view. + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entityIds, clusterCount, dimension: 256 }), + }, + ); + + if (!response.ok) { + // eslint-disable-next-line no-console + console.warn( + `[embedding] clustering failed for ${clusterId}: ${response.status}`, + ); + return; + } + + const result = (await response.json()) as { + clusters: { + clusterId: number; + entityIds: string[]; + }[]; + missingEmbeddings: string[]; + }; + + this.send({ + type: "EMBEDDING_CLUSTERING_RESULT", + clusterId: ClusterId(clusterId), + clusters: result.clusters, + }); + } catch (err) { + // eslint-disable-next-line no-console + console.warn( + `[embedding] clustering request failed for ${clusterId}:`, + err, + ); + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/flat-dots.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/flat-dots.ts new file mode 100644 index 00000000000..cdb58045f01 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/flat-dots.ts @@ -0,0 +1,62 @@ +/** + * Flat-tier entity dots: each node as a scatterplot dot, read directly from + * the interleaved SAB via stride/offset binary attributes (zero-copy). + */ +import { ScatterplotLayer } from "@deck.gl/layers"; + +import { + FLAT_COLOR_BYTE_OFFSET, + FLAT_HEADER_BYTES, + FLAT_RADIUS_BYTE_OFFSET, + FLAT_RECORD_BYTES, +} from "../worker/buffers/position-buffer"; + +import type { RenderFlatGraph } from "../frames"; +import type { ClusterId } from "../ids"; +import type { ClusterReference } from "./frame-connection"; +import type { Layer } from "@deck.gl/core"; + +export function flatDotsLayer( + graph: RenderFlatGraph, + clusters: Map, +): Layer[] { + const cluster = clusters.get(graph.layoutId); + if (!cluster) { + return []; + } + // Zero-copy binary attributes: one SAB view, per-field stride/offset into interleaved records. + const raw = cluster.versionView.buffer; + const floats = new Float32Array(raw); + const bytes = new Uint8Array(raw); + return [ + new ScatterplotLayer({ + id: "flat-entities", + data: { + length: graph.count, + attributes: { + getPosition: { + value: floats, + size: 2, + stride: FLAT_RECORD_BYTES, + offset: FLAT_HEADER_BYTES, + }, + getRadius: { + value: floats, + size: 1, + stride: FLAT_RECORD_BYTES, + offset: FLAT_HEADER_BYTES + FLAT_RADIUS_BYTE_OFFSET, + }, + getFillColor: { + value: bytes, + size: 4, + stride: FLAT_RECORD_BYTES, + offset: FLAT_HEADER_BYTES + FLAT_COLOR_BYTE_OFFSET, + normalized: true, + }, + }, + }, + radiusUnits: "common", + pickable: true, + }), + ]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/frame-connection.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/frame-connection.ts new file mode 100644 index 00000000000..2f00dd52608 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/frame-connection.ts @@ -0,0 +1,483 @@ +/** + * The lifecycle-neutral half of a graph worker connection: spawns the worker, + * turns its frame stream into a coalesced subscribe stream split by update + * rate, and adopts/watches the shared position buffers: + * - structure events (topology / LOD cut) fire when a new StructureFrame commits; + * - position events (layout positions, edge geometry, SAB notifies) are + * coalesced to at most one per animation frame. + * + * The presentation subscribes and drives Deck.gl imperatively from these events, + * so no React state ever holds per-frame data and the layer set is never rebuilt + * wholesale. + * + * The lifecycle-specific halves ({@link "./entity-worker-connection"}, + * {@link "./type-worker-connection"}) send their own init/ingest/query + * messages and resolve node identity their own way (entity id-map SAB vs. + * type id table); every reply the frame stream does not consume lands in + * their {@link FrameConnection.handleLifecycleMessage}. + */ +import { + FLAT_ENTITYIDX_BYTE_OFFSET, + FLAT_HEADER_BYTES, + FLAT_RECORD_BYTES, +} from "../worker/buffers/position-buffer"; +import { configureEntityStyle } from "../worker/entity-style"; + +import type { VizConfig } from "../config"; +import type { PositionsFrame, StructureFrame } from "../frames"; +import type { ClusterId } from "../ids"; +import type { + MainToWorkerMessage, + WorkerToMainMessage, +} from "../worker/protocol"; +import type { + MainToTypeWorkerMessage, + TypeEgoResultMessage, + TypeIdTableMessage, +} from "../worker/type-graph/protocol"; + +const WORKER_DEBUG_LOGS_KEY = "hashGraphDebugWorkerLogs"; + +export function workerDebugLogsEnabled(): boolean { + if (typeof window === "undefined") { + return false; + } + + return window.localStorage.getItem(WORKER_DEBUG_LOGS_KEY) === "1"; +} + +/** Everything either lifecycle may send to the shared worker entry. */ +type OutboundMessage = MainToWorkerMessage | MainToTypeWorkerMessage; +/** Everything the shared worker entry may post back, across both lifecycles. */ +type InboundMessage = + | WorkerToMainMessage + | TypeIdTableMessage + | TypeEgoResultMessage; + +/** Message types the base frame stream consumes; the rest are lifecycle traffic. */ +type FrameStreamMessageType = + | "READY" + | "STRUCTURE_FRAME" + | "POSITIONS_FRAME" + | "ERROR" + | "LAYOUT_CREATED" + | "BUFFER_REPUBLISHED" + | "LAYOUT_POSITIONS" + | "LAYOUT_DESTROYED"; + +/** What a subclass's {@link FrameConnection.handleLifecycleMessage} receives. */ +export type LifecycleMessage = Exclude< + InboundMessage, + { type: FrameStreamMessageType } +>; + +/** A view into one layout's position SharedArrayBuffer. */ +export interface ClusterReference { + readonly clusterId: ClusterId; + /** Int32 version counter at byte 0; written + Atomics.notify'd by the worker. */ + readonly versionView: Int32Array; + /** `[x0, y0, x1, y1, ...]` in the layout's local frame. Replaced on non-SAB fallback. */ + positions: Float32Array; + readonly nodeIds: readonly string[]; + /** Present for the flat-tier interleaved `FlatGraphBuffer`; absent for leaf SABs. */ + readonly flatCapacity?: number; +} + +export interface ViewportRect { + readonly zoom: number; + readonly center: readonly [number, number]; + readonly width: number; + readonly height: number; +} + +export type WorkerEvent = + | { readonly kind: "structure"; readonly frame: StructureFrame } + | { + readonly kind: "position"; + readonly frame: PositionsFrame; + /** + * Layout ids whose backing buffer was (re)allocated in this flush, so a + * subscriber holding views over the old buffer rebinds them. + */ + readonly replacedBuffers: readonly ClusterId[]; + }; + +export type WorkerListener = (event: WorkerEvent) => void; + +/** The lifecycle-neutral surface both connections expose to the presentation. */ +export interface FrameHandle { + /** Latest topology, or undefined before the first structure frame. */ + getStructure(): StructureFrame | undefined; + /** Latest layout positions + edge geometry. */ + getPositions(): PositionsFrame | undefined; + /** Layout position SABs, keyed by layout (cluster / flat) id. */ + getClusters(): Map; + /** Subscribe to updates. Replays current state immediately; returns unsubscribe. */ + subscribe(listener: WorkerListener): () => void; + sendViewport(viewport: ViewportRect): void; + /** + * Apply a new config live: the worker keeps its ingested state and + * re-lays out under the new tuning (no worker recreation, camera + * untouched). No-op when `config` is the reference already applied. + */ + updateConfig(config: VizConfig): void; + /** + * Freeze or resume the worker's layout simulation. Paused, the worker + * stops ticking (and therefore stops emitting positions frames) but keeps + * all state; layouts resume from the same positions. Send `true` while the + * visualizer is not visible (covered by a slide, hidden tab) so background + * instances cost no CPU. + */ + setSimulationPaused(paused: boolean): void; +} + +/** + * Correlates request/response message pairs by requestId. `settleAll` is for + * dispose: every in-flight promise resolves with a fallback so awaiters never + * hang after teardown. + */ +export class PendingRequests { + #nextId = 0; + readonly #pending = new Map void>(); + + /** Register a resolver and get the requestId to send with the message. */ + open(resolve: (result: Result) => void): number { + this.#nextId += 1; + this.#pending.set(this.#nextId, resolve); + return this.#nextId; + } + + settle(requestId: number, result: Result): void { + const resolve = this.#pending.get(requestId); + if (resolve) { + this.#pending.delete(requestId); + resolve(result); + } + } + + settleAll(result: Result): void { + for (const resolve of this.#pending.values()) { + resolve(result); + } + this.#pending.clear(); + } +} + +/** u32 slot index of a flat record's join key within the buffer's u32 view. */ +const flatJoinKeySlot = (recordIndex: number): number => + (FLAT_HEADER_BYTES + + recordIndex * FLAT_RECORD_BYTES + + FLAT_ENTITYIDX_BYTE_OFFSET) / + 4; + +/** + * A flat-tier record's u32 join key (`entityIdx` in the entity lifecycle, + * `TypeId` in the type lifecycle). Flat records reorder as nodes stream in, + * so the key is read off the live record, never inferred from the index. + */ +export function flatRecordJoinKey( + cluster: ClusterReference, + recordIndex: number, +): number | undefined { + const records = new Uint32Array(cluster.versionView.buffer); + return records[flatJoinKeySlot(recordIndex)]; +} + +export interface FrameConnectionConfig { + readonly config: VizConfig; + readonly onReady: () => void; + readonly onError: (message: string) => void; +} + +export abstract class FrameConnection implements FrameHandle { + readonly #worker: Worker; + readonly #onReady: () => void; + readonly #onError: (message: string) => void; + /** The config last sent to the worker, for the updateConfig no-op check. */ + #appliedConfig: VizConfig; + + #structure: StructureFrame | undefined; + #positions: PositionsFrame | undefined; + /** Held until its paired positions frame lands, so the two commit together. */ + #pendingStructure: StructureFrame | undefined; + readonly #clusters = new Map(); + + readonly #listeners = new Set(); + + #frameId = 0; + #structureDirty = false; + #positionDirty = false; + #replacedBuffers: ClusterId[] = []; + #flushHandle: number | undefined; + + constructor({ config, onReady, onError }: FrameConnectionConfig) { + this.#onReady = onReady; + this.#onError = onError; + this.#appliedConfig = config; + + // The worker installs the same style at init; this covers main-thread + // consumers (hub-label radii). + configureEntityStyle(config.entityStyle); + + this.#worker = new Worker(new URL("../worker/entry.ts", import.meta.url)); + this.#worker.onmessage = ({ data }: MessageEvent) => + this.#handleMessage(data); + this.#worker.onerror = (event) => this.#onError(event.message); + } + + /** + * Every reply the frame stream does not consume: queries, id maps/tables, + * and informational messages belong to the lifecycle subclass. + */ + protected abstract handleLifecycleMessage(message: LifecycleMessage): void; + + /** A config as sent to the worker: the localStorage debug override folded in. */ + protected withDebugFlag(config: VizConfig): VizConfig { + return { ...config, debug: config.debug || workerDebugLogsEnabled() }; + } + + protected send(message: OutboundMessage): void { + this.#worker.postMessage(message); + } + + getStructure(): StructureFrame | undefined { + return this.#structure; + } + + getPositions(): PositionsFrame | undefined { + return this.#positions; + } + + getClusters(): Map { + return this.#clusters; + } + + subscribe(listener: WorkerListener): () => void { + this.#listeners.add(listener); + if (this.#structure) { + listener({ kind: "structure", frame: this.#structure }); + } + if (this.#positions) { + listener({ + kind: "position", + frame: this.#positions, + replacedBuffers: [], + }); + } + return () => { + this.#listeners.delete(listener); + }; + } + + sendViewport({ zoom, center, width, height }: ViewportRect): void { + this.#frameId += 1; + this.send({ + type: "VIEWPORT_CHANGED", + frameId: `vp-${this.#frameId}`, + zoom, + center, + width, + height, + }); + } + + updateConfig(config: VizConfig): void { + // Reference check: callers keep the applied config referentially stable + // and produce a new object per change (React state), so identity is the + // change signal. Avoids a gratuitous re-layout when an effect re-fires + // with the same config (e.g. on the ready flip). + if (config === this.#appliedConfig) { + return; + } + this.#appliedConfig = config; + + // Keep the main thread's copy of the style module state in sync with + // the worker's (hub-label radii read it here). + configureEntityStyle(config.entityStyle); + + this.send({ type: "UPDATE_CONFIG", config: this.withDebugFlag(config) }); + } + + setSimulationPaused(paused: boolean): void { + this.send({ type: "SET_SIMULATION_PAUSED", paused }); + } + + /** + * Terminates the worker, cancels pending flushes, and clears listeners. + * Subclasses override to also settle their in-flight requests. + */ + dispose(): void { + if (this.#flushHandle !== undefined) { + cancelAnimationFrame(this.#flushHandle); + this.#flushHandle = undefined; + } + this.#worker.terminate(); + this.#listeners.clear(); + this.#clusters.clear(); + this.#structure = undefined; + this.#positions = undefined; + } + + #handleMessage(data: InboundMessage): void { + switch (data.type) { + case "READY": + this.#onReady(); + break; + case "STRUCTURE_FRAME": + // Hold; commit with the paired positions frame so the view never sees new + // clusters against stale positions. + this.#pendingStructure = data.frame; + break; + case "POSITIONS_FRAME": + this.#positions = data.frame; + if (this.#pendingStructure) { + this.#structure = this.#pendingStructure; + this.#pendingStructure = undefined; + this.#structureDirty = true; + } + this.#positionDirty = true; + this.#scheduleFlush(); + break; + case "ERROR": + this.#onError(data.message); + break; + case "LAYOUT_CREATED": + this.#adoptClusterBuffer({ + clusterId: data.clusterId, + buffer: data.buffer, + nodeIds: data.nodeIds, + flatCapacity: data.flatCapacity, + }); + break; + case "BUFFER_REPUBLISHED": { + // A held SAB outgrew its in-place ceiling and was re-allocated; the bytes + // were copied across, so keep the prior nodeIds and swap to the new buffer. + const prev = this.#clusters.get(data.target.clusterId); + if (prev) { + this.#adoptClusterBuffer({ + clusterId: data.target.clusterId, + buffer: data.buffer, + nodeIds: prev.nodeIds, + flatCapacity: data.capacity, + }); + } + break; + } + case "LAYOUT_POSITIONS": { + const cluster = this.#clusters.get(data.clusterId); + if (cluster) { + cluster.positions = data.positions; + this.#positionDirty = true; + this.#scheduleFlush(); + } + break; + } + case "LAYOUT_DESTROYED": + this.#clusters.delete(data.clusterId); + this.#positionDirty = true; + this.#scheduleFlush(); + break; + default: + this.handleLifecycleMessage(data); + break; + } + } + + #adoptClusterBuffer({ + clusterId, + buffer, + nodeIds, + flatCapacity, + }: { + readonly clusterId: ClusterId; + readonly buffer: SharedArrayBuffer | ArrayBuffer; + readonly nodeIds: readonly string[]; + readonly flatCapacity: number | undefined; + }): void { + const cluster: ClusterReference = { + clusterId, + versionView: new Int32Array(buffer, 0, 1), + positions: new Float32Array(buffer, 4), + nodeIds, + flatCapacity, + }; + this.#clusters.set(clusterId, cluster); + this.#replacedBuffers.push(clusterId); + this.#positionDirty = true; + this.#scheduleFlush(); + this.#watchClusterBuffer(cluster, buffer); + } + + // Re-arm an async wait on the cluster's version word; on each notify mark a + // position flush. Self-cancels once the cluster is replaced or destroyed. + #watchClusterBuffer( + cluster: ClusterReference, + buffer: SharedArrayBuffer | ArrayBuffer, + ): void { + if ( + typeof SharedArrayBuffer === "undefined" || + !(buffer instanceof SharedArrayBuffer) || + typeof Atomics.waitAsync !== "function" + ) { + return; + } + const arm = (currentVersion: number): void => { + if (this.#clusters.get(cluster.clusterId) !== cluster) { + return; + } + const result = Atomics.waitAsync(cluster.versionView, 0, currentVersion); + const onChanged = (): void => { + if (this.#clusters.get(cluster.clusterId) !== cluster) { + return; + } + this.#positionDirty = true; + this.#scheduleFlush(); + arm(Atomics.load(cluster.versionView, 0)); + }; + if (result.async) { + void result.value.then((status) => { + if (status === "ok") { + onChanged(); + } + }); + } else { + // Version changed between load and wait ("not-equal"): re-arm immediately. + onChanged(); + } + }; + arm(Atomics.load(cluster.versionView, 0)); + } + + #scheduleFlush(): void { + if (this.#flushHandle !== undefined) { + return; + } + this.#flushHandle = requestAnimationFrame(() => { + this.#flushHandle = undefined; + this.#flush(); + }); + } + + #flush(): void { + if (this.#structureDirty) { + this.#structureDirty = false; + const frame = this.#structure; + if (frame) { + for (const listener of this.#listeners) { + listener({ kind: "structure", frame }); + } + } + } + if (this.#positionDirty) { + this.#positionDirty = false; + const frame = this.#positions; + const replacedBuffers = this.#replacedBuffers; + this.#replacedBuffers = []; + if (frame) { + for (const listener of this.#listeners) { + listener({ kind: "position", frame, replacedBuffers }); + } + } + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/bezier-sdf-layer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/bezier-sdf-layer.ts new file mode 100644 index 00000000000..c34d467353f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/bezier-sdf-layer.ts @@ -0,0 +1,530 @@ +/** + * Custom Deck.gl layer: renders cubic Bezier curves using SDF + * (Signed Distance Field) evaluation in the fragment shader. + * + * Each instance is a screen-space quad covering the Bezier control + * point bounding box. The fragment shader computes exact distance + * to the cubic Bezier via brute-force search + Newton refinement, + * then applies smoothstep antialiasing. + * + * Result: pixel-perfect smooth curves at any zoom level, no + * tessellation artifacts, no jagged joints. GPU-accelerated. + * + * Data format: each datum represents one cubic Bezier segment + * with control points p0, p1, p2, p3 in world coordinates. + */ +import { Layer, picking, project32, UNIT } from "@deck.gl/core"; +import { Geometry, Model } from "@luma.gl/engine"; + +import type { LayerProps, Unit, UpdateParameters } from "@deck.gl/core"; +import type { ShaderModule } from "@luma.gl/shadertools"; + +export interface BezierSegmentDatum { + readonly p0: readonly [number, number]; + readonly p1: readonly [number, number]; + readonly p2: readonly [number, number]; + readonly p3: readonly [number, number]; + readonly color: readonly [number, number, number, number]; + readonly width: number; + /** Clip circles `(cx, cy, signedRadius)` per end; default no clip. */ + readonly clipA?: readonly [number, number, number]; + readonly clipB?: readonly [number, number, number]; +} + +interface BezierUniformProps { + /** Halo (soft underlay band) colour, normalized 0..1 RGBA. */ + uHaloColor: [number, number, number, number]; + uViewportSize: [number, number]; + uBoundsPaddingPixels: number; + /** Width unit + scale, same mechanism as the core LineLayer. */ + widthUnits: number; + widthScale: number; + /** Halo stroke width as a multiple of the core width; <= 1 disables the halo. */ + uHaloWidthFactor: number; +} + +// Declaration order matches `uniformTypes` and packs std140 without holes +// (vec4, vec2, then scalars). +const bezierUniformsGlsl = `\ +layout(std140) uniform bezierUniforms { + vec4 uHaloColor; + vec2 uViewportSize; + float uBoundsPaddingPixels; + float widthScale; + float uHaloWidthFactor; + highp int widthUnits; +} bezier; +`; + +const bezierUniforms = { + name: "bezier", + vs: bezierUniformsGlsl, + fs: bezierUniformsGlsl, + uniformTypes: { + uHaloColor: "vec4", + uViewportSize: "vec2", + uBoundsPaddingPixels: "f32", + widthScale: "f32", + uHaloWidthFactor: "f32", + widthUnits: "i32", + }, + defaultUniforms: { + uHaloColor: [0, 0, 0, 0] as [number, number, number, number], + uViewportSize: [1, 1] as [number, number], + uBoundsPaddingPixels: 4, + widthScale: 1, + uHaloWidthFactor: 0, + widthUnits: UNIT.pixels, + }, +} satisfies ShaderModule; + +const vs = `\ +#version 300 es +#define SHADER_NAME bezier-sdf-layer-vertex + +in vec2 positions; + +in vec2 instanceP0; +in vec2 instanceP1; +in vec2 instanceP2; +in vec2 instanceP3; +in float instanceWidths; +in vec4 instanceColors; +in vec3 instanceClipA; +in vec3 instanceClipB; +in vec3 instancePickingColors; + +out vec2 vPixel; +out vec2 vP0; +out vec2 vP1; +out vec2 vP2; +out vec2 vP3; +out float vWidth; +out vec4 vColor; +out vec2 vClipACenter; +out float vClipARadiusPx; +out vec2 vClipBCenter; +out float vClipBRadiusPx; + +vec2 graphToPixel(vec2 position) { + // Control points are 2D (z = 0); lift to vec3 for projection. + vec3 projected = project_position(vec3(position, 0.0)); + vec4 clip = project_common_position_to_clipspace(vec4(projected, 1.0)); + vec2 ndc = clip.xy / clip.w; + return (ndc * 0.5 + 0.5) * bezier.uViewportSize; +} + +void main(void) { + vec2 p0 = graphToPixel(instanceP0); + vec2 p1 = graphToPixel(instanceP1); + vec2 p2 = graphToPixel(instanceP2); + vec2 p3 = graphToPixel(instanceP3); + + vec2 minP = min(min(p0, p1), min(p2, p3)); + vec2 maxP = max(max(p0, p1), max(p2, p3)); + + // Stroke width to pixels via Deck's unit conversion (pixels / common / meters). Readability is a + // caller-owned design decision; this shader does not hide it behind min/max clamps. + float widthPx = max( + 0.0, + project_size_to_pixel(instanceWidths * bezier.widthScale, bezier.widthUnits) + ); + + // The quad must cover the widest band drawn: the halo when enabled. + float pad = widthPx * 0.5 * max(1.0, bezier.uHaloWidthFactor) + + bezier.uBoundsPaddingPixels; + minP -= vec2(pad); + maxP += vec2(pad); + + vec2 uv = positions * 0.5 + 0.5; + vec2 pixel = mix(minP, maxP, uv); + + vPixel = pixel; + vP0 = p0; + vP1 = p1; + vP2 = p2; + vP3 = p3; + vWidth = widthPx; + vColor = instanceColors; + + // Each segment is one pickable instance; deck decodes this back to its index on hover. + picking_setPickingColor(instancePickingColors); + + // Clip circles to pixel space. Project the centre, and a point one world-radius + // away, so the pixel radius tracks the same projection the bubble uses. Keep + // the radius SIGN (which side to erase); a zero radius means "no clip". + vClipACenter = graphToPixel(instanceClipA.xy); + vClipARadiusPx = + distance(vClipACenter, graphToPixel(instanceClipA.xy + vec2(abs(instanceClipA.z), 0.0))) + * sign(instanceClipA.z); + vClipBCenter = graphToPixel(instanceClipB.xy); + vClipBRadiusPx = + distance(vClipBCenter, graphToPixel(instanceClipB.xy + vec2(abs(instanceClipB.z), 0.0))) + * sign(instanceClipB.z); + + vec2 ndc = pixel / bezier.uViewportSize * 2.0 - 1.0; + gl_Position = vec4(ndc, 0.0, 1.0); +} +`; + +// SAMPLE_SIZE 12 / NEWTON_ITERATIONS 3: tuned for near-straight highway +// control nets; increase if tight S-curves show distance error. +const SAMPLE_SIZE = 12; +const NEWTON_ITERATIONS = 3; + +const fs = `\ +#version 300 es +#define SHADER_NAME bezier-sdf-layer-fragment +precision highp float; + +in vec2 vPixel; +in vec2 vP0; +in vec2 vP1; +in vec2 vP2; +in vec2 vP3; +in float vWidth; +in vec4 vColor; +in vec2 vClipACenter; +in float vClipARadiusPx; +in vec2 vClipBCenter; +in float vClipBRadiusPx; + +out vec4 fragColor; + +vec2 cubicBezier(vec2 a, vec2 b, vec2 c, vec2 d, float t) { + float u = 1.0 - t; + return u * u * u * a + + 3.0 * u * u * t * b + + 3.0 * u * t * t * c + + t * t * t * d; +} + +vec2 cubicBezierDeriv(vec2 a, vec2 b, vec2 c, vec2 d, float t) { + float u = 1.0 - t; + return 3.0 * u * u * (b - a) + + 6.0 * u * t * (c - b) + + 3.0 * t * t * (d - c); +} + +vec2 cubicBezierDeriv2(vec2 a, vec2 b, vec2 c, vec2 d, float t) { + return 6.0 * (1.0 - t) * (c - 2.0 * b + a) + + 6.0 * t * (d - 2.0 * c + b); +} + +float distToCubicBezier(vec2 p, vec2 a, vec2 b, vec2 c, vec2 d) { + // Coarse search: ${SAMPLE_SIZE} uniform samples. The edge control nets drawn here are + // near-straight (highway lanes with a mild bow), so the distance-to-t + // relation has no tight folds and a dozen seeds put Newton inside its + // convergence basin; more samples double the per-fragment ALU for no + // visible change in the stroke. + float bestT = 0.0; + float bestD2 = 1.0e30; + + for (int i = 0; i <= ${SAMPLE_SIZE}; i++) { + float t = float(i) / ${SAMPLE_SIZE}.0; + vec2 q = cubicBezier(a, b, c, d, t); + float d2 = dot(q - p, q - p); + if (d2 < bestD2) { + bestD2 = d2; + bestT = t; + } + } + + // Newton refinement: ${NEWTON_ITERATIONS} iterations. + float t = bestT; + for (int i = 0; i < ${NEWTON_ITERATIONS}; i++) { + vec2 q = cubicBezier(a, b, c, d, t); + vec2 d1 = cubicBezierDeriv(a, b, c, d, t); + vec2 d2 = cubicBezierDeriv2(a, b, c, d, t); + + vec2 r = q - p; + float num = dot(r, d1); + float den = dot(d1, d1) + dot(r, d2); + + if (abs(den) > 1.0e-5) { + t = clamp(t - num / den, 0.0, 1.0); + } + } + + vec2 q = cubicBezier(a, b, c, d, t); + return length(q - p); +} + +// Clip one side via a circle, which is positioned at the centre with the radius specified. +// A negative signedRadiusPx erases outside (keep inside); positive erases inside (keep outside); +// ~0 means no clip. Returns a 0..1 alpha multiplier. +float clipFactor(vec2 pixel, vec2 center, float signedRadiusPx) { + float r = abs(signedRadiusPx); + if (r < 0.001) { + return 1.0; + } + float caa = 1.0; + float coverage = smoothstep(r - caa, r + caa, distance(pixel, center)); + return signedRadiusPx > 0.0 ? coverage : 1.0 - coverage; +} + +void main(void) { + float dist = distToCubicBezier(vPixel, vP0, vP1, vP2, vP3); + + float radius = vWidth * 0.5; + float aa = max(fwidth(dist), 0.75); + + float coreCoverage = 1.0 - smoothstep(radius - aa, radius + aa, dist); + + float clip = clipFactor(vPixel, vClipACenter, vClipARadiusPx) + * clipFactor(vPixel, vClipBCenter, vClipBRadiusPx); + + // Picking hit-tests the core stroke only; the halo is a non-interactive + // backdrop. + if (bool(picking.isActive)) { + float pickAlpha = vColor.a * coreCoverage * clip; + if (pickAlpha <= 0.001) { + discard; + } + fragColor = picking_filterPickingColor(vec4(vColor.rgb, pickAlpha)); + return; + } + + // Optional halo: when uHaloWidthFactor > 1, composite a wider smoothstep + // band under the core stroke in one evaluation. + float coreAlpha = vColor.a * coreCoverage; + float haloAlpha = 0.0; + if (bezier.uHaloWidthFactor > 1.0) { + float haloRadius = radius * bezier.uHaloWidthFactor; + haloAlpha = + bezier.uHaloColor.a * (1.0 - smoothstep(haloRadius - aa, haloRadius + aa, dist)); + } + + float blendedAlpha = coreAlpha + haloAlpha * (1.0 - coreAlpha); + float outAlpha = blendedAlpha * clip; + if (outAlpha <= 0.001) { + discard; + } + + // "Core over halo" resolved to one straight-alpha output: weight the core + // colour by its share of the blended alpha. + vec3 outColor = + mix(bezier.uHaloColor.rgb, vColor.rgb, coreAlpha / max(blendedAlpha, 1.0e-6)); + + fragColor = vec4(outColor, outAlpha); +} +`; + +/** + * A single binary instance attribute supplied via `data.attributes`. The + * `value` typed array is uploaded directly to the GPU; `stride`/`offset` + * (both in bytes) allow several attributes to share one interleaved buffer. + */ +interface BinaryAttribute { + readonly value: Float32Array | Uint8Array; + readonly size: number; + readonly stride?: number; + readonly offset?: number; + readonly normalized?: boolean; +} + +/** + * Binary form of `data`: a row count plus pre-packed attributes keyed by + * accessor name. This bypasses the per-datum accessor functions entirely. + */ +interface BinaryData { + readonly length: number; + readonly attributes: Record; +} + +interface BezierSDFLayerProps< + D extends BezierSegmentDatum = BezierSegmentDatum, +> extends LayerProps { + readonly data: readonly D[] | BinaryData; + readonly getP0?: (datum: D) => readonly [number, number]; + readonly getP1?: (datum: D) => readonly [number, number]; + readonly getP2?: (datum: D) => readonly [number, number]; + readonly getP3?: (datum: D) => readonly [number, number]; + readonly getWidth?: (datum: D) => number; + readonly getColor?: (datum: D) => readonly [number, number, number, number]; + readonly getClipA?: (datum: D) => readonly [number, number, number]; + readonly getClipB?: (datum: D) => readonly [number, number, number]; + readonly boundsPaddingPixels?: number; + /** Width unit: `"pixels"` (default), `"common"` (world, scales with zoom), `"meters"`. */ + readonly widthUnits?: Unit; + readonly widthScale?: number; + /** + * Halo stroke width as a multiple of the core width. + * + * @defaultValue 0 (disabled). Values > 1 draw a soft wide band in the same + * fragment pass, avoiding a second SDF solve over overlapping quads. + */ + readonly haloWidthFactor?: number; + /** Halo RGBA, 0-255 like other layer colours. Only drawn when {@link haloWidthFactor} > 1. */ + readonly haloColor?: readonly [number, number, number, number]; +} + +const DEFAULT_COLOR: readonly [number, number, number, number] = [ + 255, 255, 255, 255, +]; + +const defaultProps = { + getP0: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.p0, + }, + getP1: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.p1, + }, + getP2: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.p2, + }, + getP3: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.p3, + }, + getWidth: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.width, + }, + getColor: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.color, + }, + getClipA: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.clipA ?? [0, 0, 0], + }, + getClipB: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.clipB ?? [0, 0, 0], + }, + boundsPaddingPixels: 4, + widthUnits: "pixels" as const, + widthScale: 1, + haloWidthFactor: 0, + haloColor: [0, 0, 0, 0] as readonly [number, number, number, number], +}; + +export class BezierSDFLayer extends Layer { + static layerName = "BezierSDFLayer"; + static defaultProps = defaultProps; + + getShaders() { + return super.getShaders({ + vs, + fs, + modules: [project32, picking, bezierUniforms], + }); + } + + initializeState() { + // initializeState runs after Layer constructs the attribute manager. + const attributeManager = this.getAttributeManager()!; + + attributeManager.addInstanced({ + instanceP0: { + size: 2, + accessor: "getP0", + defaultValue: [0, 0], + }, + instanceP1: { + size: 2, + accessor: "getP1", + defaultValue: [0, 0], + }, + instanceP2: { + size: 2, + accessor: "getP2", + defaultValue: [0, 0], + }, + instanceP3: { + size: 2, + accessor: "getP3", + defaultValue: [0, 0], + }, + instanceWidths: { + size: 1, + accessor: "getWidth", + defaultValue: 4, + }, + instanceColors: { + size: 4, + accessor: "getColor", + type: "unorm8", + defaultValue: DEFAULT_COLOR, + }, + instanceClipA: { + size: 3, + accessor: "getClipA", + defaultValue: [0, 0, 0], + }, + instanceClipB: { + size: 3, + accessor: "getClipB", + defaultValue: [0, 0, 0], + }, + }); + + this.setState({ model: this._getModel() }); + } + + updateState(params: UpdateParameters) { + super.updateState(params); + + if (params.changeFlags.extensionsChanged) { + // Layer.state is untyped; model is optional until initializeState completes. + (this.state as { model?: Model }).model?.destroy(); + this.setState({ model: this._getModel() }); + this.getAttributeManager()?.invalidateAll(); + } + } + + draw() { + const model = (this.state as { model: Model }).model; + const { viewport, renderPass } = this.context; + const haloColor = this.props.haloColor ?? [0, 0, 0, 0]; + + model.shaderInputs.setProps({ + bezier: { + uHaloColor: [ + haloColor[0] / 255, + haloColor[1] / 255, + haloColor[2] / 255, + haloColor[3] / 255, + ] as [number, number, number, number], + uViewportSize: [viewport.width, viewport.height] as [number, number], + uBoundsPaddingPixels: this.props.boundsPaddingPixels ?? 4, + widthUnits: UNIT[this.props.widthUnits ?? "pixels"], + widthScale: this.props.widthScale ?? 1, + uHaloWidthFactor: this.props.haloWidthFactor ?? 0, + }, + }); + + model.draw(renderPass); + } + + finalizeState(context: Parameters[0]) { + (this.state as { model?: Model }).model?.destroy(); + super.finalizeState(context); + } + + _getModel(): Model { + // Two triangles forming a [-1,1] quad. Each Bezier segment + // is instanced onto this quad, which the vertex shader scales + // to the control point bounding box in screen space. + const positions = new Float32Array([ + -1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1, + ]); + + return new Model(this.context.device, { + ...this.getShaders(), + id: this.props.id, + bufferLayout: this.getAttributeManager()!.getBufferLayouts(), + geometry: new Geometry({ + topology: "triangle-list", + attributes: { + positions: { size: 2, value: positions }, + }, + }), + isInstanced: true, + }); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/bubble-set-sdf-layer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/bubble-set-sdf-layer.ts new file mode 100644 index 00000000000..7c053db45cc --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/bubble-set-sdf-layer.ts @@ -0,0 +1,400 @@ +/** + * Custom Deck.gl layer: renders community "BubbleSets" as crisp metaball + * isocontours; one smooth, hard-edged organic hull per Louvain community. + * + * Each instance is one occupied grid cell of one community (`2 * fieldRadius` + * cells, packed by `render/bubble-grid.ts`): a screen-space quad covering the + * cell's world rect. The fragment shader sums a finite-support metaball + * kernel over the kernels copied for that cell; read from a positions + * texture via `texelFetch`, using the per-instance [offset, count] range; + * and thresholds the field (`smoothstep` with `fwidth` AA). Because a cell's + * copies contain every kernel whose support reaches the cell, the per-cell + * sum equals the whole-community field, so the result is the same single + * smooth contour; but each fragment only pays for local kernels, and + * pixels in empty cells are never shaded at all (the R9 fill-rate fix). + * Cells of the same community are disjoint rects, so no double-blend. + * + * Connectivity (BubbleSets virtual edges): the field also sums thin capsule + * kernels over corridor segments; endpoint texel pairs in the same + * positions texture, addressed by the per-instance [segmentOffset, segmentCount] + * range, each pair's first texel carrying the capsule radius in `.b`. The + * corridors follow an MST over the community's members (planned CPU-side in + * `render/bubble-corridors.ts`), so one community always renders as one + * connected contour instead of an island per spatial clump. + * + * Drawn behind the dots/edges (community-force only). Node positions come from + * the flat SAB (gathered + binned per cell by the presentation layer); + * everything is in `common`/world units, so the contour scales with zoom. The + * per-pixel kernel sum is sqrt-free and exits early once the field saturates + * the iso threshold, so interior fragments stop after a few kernels. + */ +import { Layer, project32 } from "@deck.gl/core"; +import { Geometry, Model } from "@luma.gl/engine"; + +import type { LayerProps, UpdateParameters } from "@deck.gl/core"; +import type { Texture } from "@luma.gl/core"; +import type { ShaderModule } from "@luma.gl/shadertools"; + +/** + * Loop bound for the per-pixel node sum. Callers must not point an instance's + * [offset, count] range at more nodes than this: the shader stops summing at + * the bound, so the hull would silently ignore the excess. The presentation + * layer downsamples larger communities to fit (see `render/community.ts`); + * a grid cell's copies are a subset of its community's members, so per-cell + * ranges inherit the bound. + * + * The bound does NOT set per-fragment cost: fragments sum only the kernels + * binned into their own grid cell (`render/bubble-grid.ts`), and the packer's + * cell edge scales with the kernel radius, which itself scales with the + * sample-density deficit (see `communityFieldRadius`), keeping the per-cell + * population roughly constant. Raising the bound costs texture texels and + * CPU gather time (both linear, both tiny), not fill rate. + */ +export const MAX_NODES_PER_COMMUNITY = 1024; + +/** + * Loop bound for the per-pixel corridor sum. Corridors span at most + * `CORRIDOR_ANCHOR_CAP` (256) anchors per community (`bubble-corridors.ts`), + * so an MST has <= 255 edges; each may split into 2 segments when rerouted + * around foreign nodes, so 512 covers the worst case (again a per-community + * bound that any per-cell subset inherits). + */ +export const MAX_SEGMENTS_PER_COMMUNITY = 512; + +interface BubbleUniformProps { + /** Field value of the isocontour (where the hard edge sits). */ + isoThreshold: number; + /** Width of the positions texture, for texelFetch index to (x, y). */ + texWidth: number; +} + +const bubbleUniforms = { + name: "bubble", + vs: `\ +layout(std140) uniform bubbleUniforms { + float isoThreshold; + highp int texWidth; +} bubble; +`, + fs: `\ +layout(std140) uniform bubbleUniforms { + float isoThreshold; + highp int texWidth; +} bubble; +`, + uniformTypes: { + isoThreshold: "f32", + texWidth: "i32", + }, + defaultUniforms: { + isoThreshold: 0.5, + texWidth: 256, + }, +} satisfies ShaderModule; + +const vs = `\ +#version 300 es +#define SHADER_NAME bubble-set-sdf-layer-vertex + +in vec2 positions; + +in vec4 instanceBounds; // minX, minY, maxX, maxY (world) +in vec4 instanceColors; +in vec2 instanceNodeRange; // offset, count into the positions texture +in vec2 instanceSegmentRange; // first endpoint-pair texel, segment count +in float instanceFieldRadius; // point-kernel radius (world), per community + +out vec2 vWorldPos; +out vec4 vColor; +flat out int vOffset; +flat out int vCount; +flat out int vSegmentOffset; +flat out int vSegmentCount; +flat out float vFieldRadius; + +void main(void) { + vec2 uv = positions * 0.5 + 0.5; + vec2 worldPos = mix(instanceBounds.xy, instanceBounds.zw, uv); + + vWorldPos = worldPos; + vColor = instanceColors; + vOffset = int(instanceNodeRange.x); + vCount = int(instanceNodeRange.y); + vSegmentOffset = int(instanceSegmentRange.x); + vSegmentCount = int(instanceSegmentRange.y); + vFieldRadius = instanceFieldRadius; + + vec3 projected = project_position(vec3(worldPos, 0.0)); + gl_Position = project_common_position_to_clipspace(vec4(projected, 1.0)); +} +`; + +const fs = `\ +#version 300 es +#define SHADER_NAME bubble-set-sdf-layer-fragment +precision highp float; + +uniform sampler2D positionsTex; + +in vec2 vWorldPos; +in vec4 vColor; +flat in int vOffset; +flat in int vCount; +flat in int vSegmentOffset; +flat in int vSegmentCount; +flat in float vFieldRadius; + +out vec4 fragColor; + +vec4 fetchTexel(int idx) { + return texelFetch(positionsTex, ivec2(idx % bubble.texWidth, idx / bubble.texWidth), 0); +} + +void main(void) { + // The Wyvill kernel (1 - d^2)^2 only needs the squared distance, so the + // whole field sum runs without a single sqrt. + float invFieldRadiusSq = 1.0 / (vFieldRadius * vFieldRadius); + // Saturation exit: kernels only add, so once the field sits this far above + // the iso threshold the smoothstep below is pinned at 1 no matter what the + // remaining kernels contribute -- stop summing. Zoomed in, almost every + // covered fragment is deep interior (the camera is inside the hull), so + // this turns the dominant fragment population from ~(nodes + segments) + // texel fetches into a handful. + float saturation = bubble.isoThreshold + 1.0; + + // Sum the finite-support metaball kernel over this community's node centres. + float field = 0.0; + for (int i = 0; i < ${MAX_NODES_PER_COMMUNITY}; i++) { + if (i >= vCount || field >= saturation) { + break; + } + vec2 toNode = vWorldPos - fetchTexel(vOffset + i).rg; + float dSq = dot(toNode, toNode) * invFieldRadiusSq; + if (dSq < 1.0) { + // Wyvill-style kernel: 1 at the centre, 0 at the rim, C1-continuous so + // overlapping fields merge into one smooth contour. + float base = 1.0 - dSq; + field += base * base; + } + } + + // Add the corridor capsules (BubbleSets virtual edges): thin segment kernels + // along the community's MST, guaranteeing the contour is connected. Each + // segment is an endpoint-pair of texels; the first texel's .b carries the + // capsule radius (world units). + for (int s = 0; s < ${MAX_SEGMENTS_PER_COMMUNITY}; s++) { + if (s >= vSegmentCount || field >= saturation) { + break; + } + + vec4 endpointA = fetchTexel(vSegmentOffset + s * 2); + vec2 endpointB = fetchTexel(vSegmentOffset + s * 2 + 1).rg; + float radius = endpointA.b; + if (radius <= 0.0) { + continue; + } + vec2 pa = vWorldPos - endpointA.rg; + vec2 ba = endpointB - endpointA.rg; + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1.0e-6), 0.0, 1.0); + vec2 toSpine = pa - ba * h; + float dSq = dot(toSpine, toSpine) / (radius * radius); + if (dSq < 1.0) { + float base = 1.0 - dSq; + field += base * base; + } + } + + // Crisp isocontour at isoThreshold, antialiased by the field's screen-space + // gradient -- a hard edge at any zoom, not a soft gradient. + float aa = max(fwidth(field), 1.0e-4); + float alpha = + smoothstep(bubble.isoThreshold - aa, bubble.isoThreshold + aa, field) * vColor.a; + if (alpha <= 0.002) { + discard; + } + fragColor = vec4(vColor.rgb, alpha); +} +`; + +interface BinaryAttribute { + readonly value: Float32Array | Uint8Array; + readonly size: number; + readonly stride?: number; + readonly offset?: number; + readonly normalized?: boolean; +} + +interface BinaryData { + readonly length: number; + readonly attributes: Record; +} + +interface BubbleSetSDFLayerProps extends LayerProps { + readonly data: BinaryData; + /** Per-cell kernel copies (world) packed by `render/bubble-grid.ts`: + * `texWidth * texHeight` rgba32f texels (`.rg` position, `.b` capsule radius + * on a segment pair's first texel, unused on point texels). */ + readonly positions: Float32Array; + /** Bumped when positions are refilled in place; drives a texture re-upload without reallocation. */ + readonly positionsVersion?: number; + readonly texWidth: number; + readonly texHeight: number; + readonly isoThreshold?: number; +} + +const defaultProps = { + positions: { type: "object" as const, value: new Float32Array(0) }, + positionsVersion: { type: "number" as const, value: 0 }, + texWidth: { type: "number" as const, value: 256 }, + texHeight: { type: "number" as const, value: 1 }, + isoThreshold: { type: "number" as const, value: 0.5 }, +}; + +interface BubbleLayerState { + model?: Model; + texture?: Texture; +} + +export class BubbleSetSDFLayer extends Layer { + static layerName = "BubbleSetSDFLayer"; + static defaultProps = defaultProps; + + getShaders() { + return super.getShaders({ + vs, + fs, + modules: [project32, bubbleUniforms], + }); + } + + initializeState() { + // initializeState: attribute manager is always constructed by Layer base. + this.getAttributeManager()!.addInstanced({ + instanceBounds: { + size: 4, + accessor: "getBounds", + defaultValue: [0, 0, 0, 0], + }, + instanceColors: { + size: 4, + accessor: "getColor", + type: "unorm8", + defaultValue: [255, 255, 255, 64], + }, + instanceNodeRange: { + size: 2, + accessor: "getNodeRange", + defaultValue: [0, 0], + }, + instanceSegmentRange: { + size: 2, + accessor: "getSegmentRange", + defaultValue: [0, 0], + }, + instanceFieldRadius: { + size: 1, + accessor: "getFieldRadius", + defaultValue: [55], + }, + }); + this.setState({ model: this._getModel() }); + } + + updateState(params: UpdateParameters) { + super.updateState(params); + const { changeFlags, props, oldProps } = params; + + if (changeFlags.extensionsChanged) { + (this.state as BubbleLayerState).model?.destroy(); + this.setState({ model: this._getModel() }); + this.getAttributeManager()?.invalidateAll(); + } + + const state = this.state as BubbleLayerState; + const dimensionsChanged = + props.texWidth !== oldProps.texWidth || + props.texHeight !== oldProps.texHeight; + if (state.texture === undefined || dimensionsChanged) { + // First build or dimensions changed: (re)allocate the texture. + this._createTexture(); + } else if ( + props.positions !== oldProps.positions || + props.positionsVersion !== oldProps.positionsVersion + ) { + // Same dimensions, moved centres: re-upload in place. + this._uploadTexture(); + } + } + + draw() { + const state = this.state as BubbleLayerState; + const { model, texture } = state; + if (!model || !texture) { + return; + } + model.setBindings({ positionsTex: texture }); + model.shaderInputs.setProps({ + bubble: { + isoThreshold: this.props.isoThreshold ?? 0.5, + texWidth: this.props.texWidth, + }, + }); + model.draw(this.context.renderPass); + } + + finalizeState(context: Parameters[0]) { + const state = this.state as BubbleLayerState; + state.texture?.destroy(); + state.model?.destroy(); + super.finalizeState(context); + } + + /** Allocate the rgba32float positions texture and upload the current node centres. */ + _createTexture() { + const state = this.state as BubbleLayerState; + state.texture?.destroy(); + state.texture = this.context.device.createTexture({ + format: "rgba32float", + width: this.props.texWidth, + height: this.props.texHeight, + data: this.props.positions, + sampler: { minFilter: "nearest", magFilter: "nearest" }, + }); + } + + /** Re-upload moved node centres into the existing texture. */ + _uploadTexture() { + const state = this.state as BubbleLayerState; + state.texture?.writeData(this.props.positions, { + width: this.props.texWidth, + height: this.props.texHeight, + }); + } + + _getModel(): Model { + const positions = new Float32Array([ + -1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1, + ]); + return new Model(this.context.device, { + ...this.getShaders(), + id: this.props.id, + bufferLayout: this.getAttributeManager()!.getBufferLayouts(), + geometry: new Geometry({ + topology: "triangle-list", + attributes: { + positions: { size: 2, value: positions }, + }, + }), + isInstanced: true, + // A community hull is a pure backdrop -- drawn first, behind everything, never occluding. + // It must not write depth: each instance fills its community's bounding quad and only the + // metaball hull inside is opaque, so with depth-write on the whole (mostly transparent) quad + // would stamp the depth buffer and the coplanar bezier edges drawn afterward would fail the + // depth test across that rectangle and vanish. depthCompare "always" as nothing is behind it. + parameters: { + depthWriteEnabled: false, + depthCompare: "always", + }, + }); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/endpoint-v-layer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/endpoint-v-layer.ts new file mode 100644 index 00000000000..4b1c47eae02 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/endpoint-v-layer.ts @@ -0,0 +1,294 @@ +/** + * Instanced endpoint V caps for directed edges. + * + * Each instance supplies position, angle, size, chord, and color; one static + * two-arm mesh is instanced in the vertex shader, keeping CPU buffers to one + * record per arrow. Chord-based screen-space fade runs in the shader via the + * project module's world→pixel conversion (`project_size_to_pixel`, which + * tracks the live viewport), so the flat tier can feed tens of thousands of + * arrows as binary attributes + * ({@link "../../frames".RenderEndpointArrowBuffers}) without any per-arrow + * CPU work on zoom or on frame. + */ +import { Layer, project32 } from "@deck.gl/core"; +import { Geometry, Model } from "@luma.gl/engine"; + +import type { Color } from "../../frames"; +import type { Position } from "../../geometry"; +import type { LayerProps, UpdateParameters } from "@deck.gl/core"; + +interface EndpointVDatum extends Position { + readonly angle: number; + readonly size: number; + readonly chord: number; + readonly color: Color; +} + +// Declaration order matches `uniformTypes` (all scalars; std140-safe). +const arrowFadeUniformsGlsl = `\ +layout(std140) uniform arrowFadeUniforms { + float uMinScreenChord; + float uFadePx; + float uAlphaBoost; +} arrowFade; +`; + +const arrowFadeUniforms = { + name: "arrowFade", + vs: arrowFadeUniformsGlsl, + uniformTypes: { + uMinScreenChord: "f32" as const, + uFadePx: "f32" as const, + uAlphaBoost: "f32" as const, + }, + defaultUniforms: { + uMinScreenChord: 36, + uFadePx: 14, + uAlphaBoost: 1.28, + }, +}; + +const vs = `\ +#version 300 es +#define SHADER_NAME endpoint-v-layer-vertex + +in vec2 positions; + +in vec2 instancePositions; +in float instanceAngles; +in float instanceSizes; +in float instanceChords; +in vec4 instanceColors; + +out vec4 vColor; + +void main(void) { + float c = cos(instanceAngles); + float s = sin(instanceAngles); + vec2 local = positions * instanceSizes; + vec2 worldOffset = vec2( + local.x * c - local.y * s, + local.x * s + local.y * c + ); + vec2 worldPosition = instancePositions + worldOffset; + + vec3 projected = project_position(vec3(worldPosition, 0.0)); + gl_Position = project_common_position_to_clipspace(vec4(projected, 1.0)); + + // Screen-space chord fade: arrows on lanes shorter than uMinScreenChord + // on screen fade to nothing over uFadePx. Chords are world units (= common + // under the orthographic view), converted by the project module against + // the live viewport. + float screenChord = project_size_to_pixel(instanceChords, UNIT_COMMON); + float fade = clamp( + (screenChord - arrowFade.uMinScreenChord + arrowFade.uFadePx) / + arrowFade.uFadePx, + 0.0, + 1.0 + ); + float boosted = min(1.0, instanceColors.a * arrowFade.uAlphaBoost); + vColor = vec4(instanceColors.rgb, boosted * fade); +} +`; + +const fs = `\ +#version 300 es +#define SHADER_NAME endpoint-v-layer-fragment +precision highp float; + +in vec4 vColor; + +out vec4 fragColor; + +void main(void) { + if (vColor.a <= 0.001) { + discard; + } + fragColor = vColor; +} +`; + +interface EndpointVLayerProps extends LayerProps { + /** Object datums, or a deck binary attribute table (flat tier). */ + readonly data: LayerProps["data"]; + readonly getPosition?: (datum: EndpointVDatum) => readonly [number, number]; + readonly getAngle?: (datum: EndpointVDatum) => number; + readonly getSize?: (datum: EndpointVDatum) => number; + readonly getChord?: (datum: EndpointVDatum) => number; + readonly getColor?: (datum: EndpointVDatum) => Color; +} + +const DEFAULT_COLOR: Color = [255, 255, 255, 255]; + +const defaultProps = { + getPosition: { + type: "accessor" as const, + value: (datum: EndpointVDatum) => [datum.x, datum.y] as const, + }, + getAngle: { + type: "accessor" as const, + value: (datum: EndpointVDatum) => datum.angle, + }, + getSize: { + type: "accessor" as const, + value: (datum: EndpointVDatum) => datum.size, + }, + getChord: { + type: "accessor" as const, + value: (datum: EndpointVDatum) => datum.chord, + }, + getColor: { + type: "accessor" as const, + value: (datum: EndpointVDatum) => datum.color, + }, +}; + +function endpointVGeometry(): Float32Array { + const length = 1.95; + const halfWidth = 0.96; + const stroke = 0.16; + const innerIncidenceX = -0.38; + const armLength = Math.hypot(length, halfWidth); + const halfStroke = stroke / 2; + + const upperUx = -length / armLength; + const upperUy = halfWidth / armLength; + const upperInnerNx = -upperUy * halfStroke; + const upperInnerNy = upperUx * halfStroke; + const upperBaseX = -length; + const upperBaseY = halfWidth; + const upperInnerBaseX = upperBaseX + upperInnerNx; + const upperInnerBaseY = upperBaseY + upperInnerNy; + const upperOuterBaseX = upperBaseX - upperInnerNx; + const upperOuterBaseY = upperBaseY - upperInnerNy; + + const lowerUx = -length / armLength; + const lowerUy = -halfWidth / armLength; + const lowerNormalX = -lowerUy * halfStroke; + const lowerNormalY = lowerUx * halfStroke; + const lowerBaseX = -length; + const lowerBaseY = -halfWidth; + const lowerInnerBaseX = lowerBaseX - lowerNormalX; + const lowerInnerBaseY = lowerBaseY - lowerNormalY; + const lowerOuterBaseX = lowerBaseX + lowerNormalX; + const lowerOuterBaseY = lowerBaseY + lowerNormalY; + + return new Float32Array([ + 0, + 0, + upperOuterBaseX, + upperOuterBaseY, + upperInnerBaseX, + upperInnerBaseY, + 0, + 0, + upperInnerBaseX, + upperInnerBaseY, + innerIncidenceX, + 0, + 0, + 0, + innerIncidenceX, + 0, + lowerInnerBaseX, + lowerInnerBaseY, + 0, + 0, + lowerInnerBaseX, + lowerInnerBaseY, + lowerOuterBaseX, + lowerOuterBaseY, + ]); +} + +export class EndpointVLayer extends Layer { + static layerName = "EndpointVLayer"; + static defaultProps = defaultProps; + + getShaders() { + return super.getShaders({ + vs, + fs, + modules: [project32, arrowFadeUniforms], + }); + } + + initializeState() { + const attributeManager = this.getAttributeManager()!; + + attributeManager.addInstanced({ + instancePositions: { + size: 2, + accessor: "getPosition", + defaultValue: [0, 0], + }, + instanceAngles: { + size: 1, + accessor: "getAngle", + defaultValue: 0, + }, + instanceSizes: { + size: 1, + accessor: "getSize", + defaultValue: 1, + }, + instanceChords: { + size: 1, + accessor: "getChord", + defaultValue: 0, + }, + instanceColors: { + size: 4, + accessor: "getColor", + type: "unorm8", + defaultValue: DEFAULT_COLOR, + }, + }); + + this.setState({ model: this._getModel() }); + } + + updateState(params: UpdateParameters) { + super.updateState(params); + + if (params.changeFlags.extensionsChanged) { + (this.state as { model?: Model }).model?.destroy(); + this.setState({ model: this._getModel() }); + this.getAttributeManager()?.invalidateAll(); + } + } + + draw() { + const model = (this.state as { model: Model }).model; + const { renderPass } = this.context; + + model.shaderInputs.setProps({ + arrowFade: { + uMinScreenChord: 36, + uFadePx: 14, + uAlphaBoost: 1.28, + }, + }); + model.draw(renderPass); + } + + finalizeState(context: Parameters[0]) { + (this.state as { model?: Model }).model?.destroy(); + super.finalizeState(context); + } + + _getModel(): Model { + return new Model(this.context.device, { + ...this.getShaders(), + id: this.props.id, + bufferLayout: this.getAttributeManager()!.getBufferLayouts(), + geometry: new Geometry({ + topology: "triangle-list", + attributes: { + positions: { size: 2, value: endpointVGeometry() }, + }, + }), + isInstanced: true, + }); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/icon-atlas.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/icon-atlas.test.ts new file mode 100644 index 00000000000..631b5f2b8d0 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/icon-atlas.test.ts @@ -0,0 +1,126 @@ +/** + * IconAtlas contract tests on scripted DOM fakes (node environment, no + * jsdom): the synchronous emoji path, the async URL lifecycle, and the + * terminal-failure rule that a URL key which failed to load is skipped by + * every later `ensureIcons` call (no second fetch, no fresh slot claim) + * while subsequent keys keep working. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { IconAtlas } from "./icon-atlas"; + +const noop = (): void => undefined; + +/** 2D-context stand-in covering exactly the calls the atlas makes. */ +const fakeContext = () => + ({ + save: noop, + restore: noop, + beginPath: noop, + rect: noop, + clip: noop, + clearRect: noop, + fillRect: noop, + fillText: noop, + drawImage: noop, + font: "", + textAlign: "", + textBaseline: "", + fillStyle: "", + globalCompositeOperation: "", + }) as unknown as CanvasRenderingContext2D; + +const fakeCanvas = () => ({ + width: 0, + height: 0, + getContext: fakeContext, +}); + +/** Captures every `new Image()` so tests can resolve or fail loads manually. */ +class FakeImage { + static instances: FakeImage[] = []; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + crossOrigin: string | null = null; + naturalWidth = 24; + naturalHeight = 24; + src = ""; + + constructor() { + FakeImage.instances.push(this); + } +} + +const URL_KEY = "https://icons.test/type.svg"; + +beforeEach(() => { + FakeImage.instances = []; + vi.stubGlobal("document", { createElement: () => fakeCanvas() }); + vi.stubGlobal("Image", FakeImage); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function newAtlas(): { atlas: IconAtlas; updateCount: () => number } { + let updates = 0; + const atlas = new IconAtlas(() => { + updates += 1; + }); + return { atlas, updateCount: () => updates }; +} + +describe("IconAtlas", () => { + it("rasterises emoji synchronously into the mapping", () => { + const { atlas } = newAtlas(); + + atlas.ensureIcons(["📦"]); + + expect(atlas.has("📦")).toBe(true); + expect(atlas.getMapping()["📦"]).toMatchObject({ x: 0, y: 0 }); + }); + + it("keeps a URL key pending until its load resolves", () => { + const { atlas, updateCount } = newAtlas(); + + atlas.ensureIcons([URL_KEY]); + expect(FakeImage.instances).toHaveLength(1); + expect(FakeImage.instances[0]!.src).toBe(URL_KEY); + expect(atlas.has(URL_KEY)).toBe(false); + + // Re-ensuring an in-flight key must not start a second request. + atlas.ensureIcons([URL_KEY]); + expect(FakeImage.instances).toHaveLength(1); + + const versionBefore = atlas.version; + FakeImage.instances[0]!.onload?.(); + expect(atlas.has(URL_KEY)).toBe(true); + expect(atlas.getMapping()[URL_KEY]).toMatchObject({ x: 0, y: 0 }); + expect(atlas.version).toBe(versionBefore + 1); + expect(updateCount()).toBe(1); + }); + + it("treats a failed URL load as terminal: skipped forever, slot spent once", () => { + const { atlas, updateCount } = newAtlas(); + + atlas.ensureIcons([URL_KEY]); + // The browser reports a failed request through the error event; the atlas + // deliberately leaves it unhandled, so the entry stays claimed, not ready. + FakeImage.instances[0]!.onerror?.(); + expect(atlas.has(URL_KEY)).toBe(false); + expect(atlas.getMapping()[URL_KEY]).toBeUndefined(); + + // Icon rescans (tier changes, leaf rebuilds, streamed entities sharing the + // type's icon) re-send the key: it must be skipped, not refetched into a + // freshly claimed slot. + atlas.ensureIcons([URL_KEY]); + expect(FakeImage.instances).toHaveLength(1); + expect(updateCount()).toBe(0); + + // The failed key's slot 0 stays spent (a blank cell): the next key claims + // slot 1, one cell width in. + atlas.ensureIcons(["📦"]); + expect(atlas.getMapping()["📦"]).toMatchObject({ x: 64, y: 0 }); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/icon-atlas.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/icon-atlas.ts new file mode 100644 index 00000000000..20895e68e28 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/gpu/icon-atlas.ts @@ -0,0 +1,286 @@ +/** + * Incrementally-built rasterised icon atlas. + * + * A 2D canvas grid of fixed-size cells; each unique icon key is rasterised once. + * {@link version} bumps on any change (new cell, finished async raster, canvas grow). + * + * Icon formats: + * - URL (`http(s)://` or `/`): monochrome SVG drawn as a white silhouette + * (recoloured via `source-in`), loaded asynchronously. Pending until + * resolved; a failed load is terminal (the key stays not-ready and is never + * retried for the atlas's lifetime). + * - Other string: emoji, drawn synchronously with `fillText`. + * + * Cells are pre-coloured (white silhouettes / full-colour emoji). The atlas owns + * the GPU texture lifecycle: {@link getTexture} lazily rebuilds from the canvas + * when the version or device changes. + */ + +import type { Position } from "../../geometry"; +import type { Device, Texture } from "@luma.gl/core"; + +/** A rasterised icon's cell rectangle within the atlas canvas, in device pixels. */ +export interface AtlasCell extends Position { + readonly width: number; + readonly height: number; +} + +/** Side length (device px) of one square atlas cell -- the rasterisation resolution per icon. */ +const CELL_SIZE = 64; + +/** + * Cells per row. Fixed for the atlas's life; growth only adds rows, so + * existing slots keep their (col, row) and pixels copy with one blit. + */ +const COLUMNS = 8; + +/** Initial row count; the canvas grows by {@link ROW_GROWTH} rows whenever the grid fills. */ +const INITIAL_ROWS = 8; +const ROW_GROWTH = 8; + +/** Emoji glyph size within a cell (leaves a small margin so it never clips the cell edge). */ +const EMOJI_FONT_PX = Math.round(CELL_SIZE * 0.8); + +/** True when the icon string is http(s) or root-relative (async SVG silhouette); otherwise treated as emoji. */ +function isUrlIcon(icon: string): boolean { + return ( + icon.startsWith("http://") || + icon.startsWith("https://") || + icon.startsWith("/") + ); +} + +/** Resolve root-relative paths against window.location.origin. */ +function resolveIconUrl(icon: string): string { + return icon.startsWith("/") + ? new URL(icon, window.location.origin).href + : icon; +} + +/** + * A claimed cell. `cellIndex` is a stable row-major slot (never reused). + * `ready` flips to true once the raster lands. A URL entry whose load failed + * keeps `ready` false forever: keeping the claim recorded is what stops + * {@link IconAtlas.ensureIcons} from retrying the key into a fresh slot. + */ +interface AtlasEntry { + readonly cellIndex: number; + ready: boolean; +} + +export class IconAtlas { + /** Bumped on any change (new cell, finished async raster, canvas grow). */ + #version = 0; + /** The backing canvas; a grid of {@link CELL_SIZE} cells, uploaded to the GPU by the layer. */ + #canvas: HTMLCanvasElement; + #ctx: CanvasRenderingContext2D; + /** Current row count (columns are fixed at {@link COLUMNS}); grows by {@link ROW_GROWTH}. */ + #rows = INITIAL_ROWS; + /** Next free cell slot (row-major); never decreases, so slots are stable across grows. */ + #nextCell = 0; + /** Every claimed key (ready, in flight, or failed) -> its stable slot + ready flag. */ + readonly #entries = new Map(); + /** Fired after an async raster finishes (so the Scene re-pushes the layers). */ + readonly #onUpdate: () => void; + /** Cached GPU texture + the atlas version + device it was built from (rebuilt when stale). */ + #texture: Texture | undefined; + #textureVersion = -1; + #textureDevice: Device | undefined; + /** + * Cached deck `iconMapping` + the version it reflects. A per-frame layer build then reuses one + * object identity until the atlas actually changes, so deck does not re-process the mapping each + * frame (it diffs `iconMapping` by identity). + */ + #mapping: Record | undefined; + #mappingVersion = -1; + + constructor(onUpdate: () => void) { + this.#onUpdate = onUpdate; + this.#canvas = document.createElement("canvas"); + this.#canvas.width = COLUMNS * CELL_SIZE; + this.#canvas.height = this.#rows * CELL_SIZE; + this.#ctx = this.#contextOf(this.#canvas); + } + + get version(): number { + return this.#version; + } + + /** The backing canvas (exposed mainly for tests; the layer uses {@link getTexture}). */ + get canvas(): HTMLCanvasElement { + return this.#canvas; + } + + /** GPU texture, rebuilt from the canvas when version or device changes. */ + getTexture(device: Device): Texture { + if ( + this.#texture === undefined || + this.#textureVersion !== this.#version || + this.#textureDevice !== device + ) { + this.#texture?.destroy(); + this.#texture = device.createTexture({ + data: this.#canvas, + width: this.#canvas.width, + height: this.#canvas.height, + sampler: { minFilter: "linear", magFilter: "linear" }, + }); + this.#textureVersion = this.#version; + this.#textureDevice = device; + } + return this.#texture; + } + + /** deck.gl `iconMapping` for ready keys. Cached by version (stable identity). */ + getMapping(): Record { + if (this.#mapping !== undefined && this.#mappingVersion === this.#version) { + return this.#mapping; + } + const mapping: Record = {}; + for (const [key, entry] of this.#entries) { + if (entry.ready) { + mapping[key] = this.#cellRect(entry.cellIndex); + } + } + this.#mapping = mapping; + this.#mappingVersion = this.#version; + return mapping; + } + + /** Is `key` rasterised and ready to draw? A pending, failed, or unknown key is not. */ + has(key: string): boolean { + return this.#entries.get(key)?.ready === true; + } + + /** + * Ensure each key is rasterised or in flight. Claimed keys are skipped, so + * repeated calls with overlapping key sets are idempotent. That includes URL + * keys whose load failed: they stay claimed but never ready, and are never + * refetched or retried into a fresh slot. + */ + ensureIcons(keys: readonly string[]): void { + for (const key of keys) { + if (key.length === 0 || this.#entries.has(key)) { + continue; + } + if (isUrlIcon(key)) { + this.#rasterizeUrl(key); + } else { + this.#rasterizeEmoji(key); + } + } + } + + /** Release the GPU texture. The atlas must not be used afterwards. */ + dispose(): void { + this.#texture?.destroy(); + this.#texture = undefined; + } + + #contextOf(canvas: HTMLCanvasElement): CanvasRenderingContext2D { + const ctx = canvas.getContext("2d"); + if (ctx === null) { + throw new Error("IconAtlas: 2D canvas context unavailable"); + } + return ctx; + } + + /** Claim the next free slot, growing the canvas first if the grid is full. */ + #claimSlot(): number { + if (this.#nextCell >= COLUMNS * this.#rows) { + this.#grow(); + } + const slot = this.#nextCell; + this.#nextCell += 1; + return slot; + } + + /** Add rows: taller canvas, single blit copy, existing cell rectangles stay valid. */ + #grow(): void { + const previous = this.#canvas; + this.#rows += ROW_GROWTH; + const next = document.createElement("canvas"); + next.width = COLUMNS * CELL_SIZE; + next.height = this.#rows * CELL_SIZE; + const ctx = this.#contextOf(next); + ctx.drawImage(previous, 0, 0); + this.#canvas = next; + this.#ctx = ctx; + this.#version += 1; + } + + /** The cell rectangle for a row-major slot index (columns fixed at {@link COLUMNS}). */ + #cellRect(slot: number): AtlasCell { + const col = slot % COLUMNS; + const row = Math.floor(slot / COLUMNS); + return { + x: col * CELL_SIZE, + y: row * CELL_SIZE, + width: CELL_SIZE, + height: CELL_SIZE, + }; + } + + #rasterizeEmoji(key: string): void { + const slot = this.#claimSlot(); + const cell = this.#cellRect(slot); + const ctx = this.#ctx; + ctx.save(); + ctx.clearRect(cell.x, cell.y, cell.width, cell.height); + ctx.font = `${EMOJI_FONT_PX}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(key, cell.x + cell.width / 2, cell.y + cell.height / 2); + ctx.restore(); + this.#entries.set(key, { cellIndex: slot, ready: true }); + this.#version += 1; + } + + #rasterizeUrl(key: string): void { + const slot = this.#claimSlot(); + const entry: AtlasEntry = { cellIndex: slot, ready: false }; + this.#entries.set(key, entry); + const image = new Image(); + image.crossOrigin = "anonymous"; + image.onload = () => { + // The canvas may have grown while this loaded; the slot is stable, so recompute its rect. + this.#drawSilhouette(image, this.#cellRect(entry.cellIndex)); + entry.ready = true; + this.#version += 1; + this.#onUpdate(); + }; + // A failed load is terminal for the atlas's lifetime: the error event goes + // unhandled, so the entry stays claimed but never turns ready. The key then + // never enters the mapping (the layer draws no icon for it) and later + // `ensureIcons` calls keep skipping it. Retrying instead would claim a + // fresh slot per attempt (slots are never reused) and refetch a URL that + // most likely fails again on every icon rescan; the terminal failure costs + // one blank cell, once. + image.src = resolveIconUrl(key); + } + + /** Draw `image` fitted into `cell`, then recolour to a white silhouette via `source-in`. */ + #drawSilhouette(image: HTMLImageElement, cell: AtlasCell): void { + const ctx = this.#ctx; + ctx.save(); + ctx.beginPath(); + ctx.rect(cell.x, cell.y, cell.width, cell.height); + ctx.clip(); + ctx.clearRect(cell.x, cell.y, cell.width, cell.height); + const naturalWidth = image.naturalWidth || cell.width; + const naturalHeight = image.naturalHeight || cell.height; + const fit = Math.min( + cell.width / naturalWidth, + cell.height / naturalHeight, + ); + const drawWidth = naturalWidth * fit; + const drawHeight = naturalHeight * fit; + const drawX = cell.x + (cell.width - drawWidth) / 2; + const drawY = cell.y + (cell.height - drawHeight) / 2; + ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight); + ctx.globalCompositeOperation = "source-in"; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(cell.x, cell.y, cell.width, cell.height); + ctx.restore(); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/label-character-set.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/label-character-set.test.ts new file mode 100644 index 00000000000..6ab85606e50 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/label-character-set.test.ts @@ -0,0 +1,64 @@ +/** + * LabelCharacterSet tests: the reference-stability contract deck's font-atlas + * manager depends on (same array unless the union grew), and seed coverage + * for the characters our own label builders synthesise. + */ +import { describe, expect, it } from "vitest"; + +import { LabelCharacterSet } from "./label-character-set"; + +describe("LabelCharacterSet", () => { + it("seeds printable ASCII and the synthesised label characters", () => { + const characters = new LabelCharacterSet().characters; + + // ASCII bounds plus a mid-range spot check. + expect(characters).toContain(" "); + expect(characters).toContain("~"); + expect(characters).toContain("A"); + // Worker-side label builders emit these outside ASCII. + expect(characters).toContain("…"); + expect(characters).toContain("→"); + expect(characters).toContain("←"); + }); + + it("keeps the array reference when no new characters appear", () => { + const set = new LabelCharacterSet(); + const before = set.characters; + + const after = set.extend(["Widget (42)", "→ Material", "1.2k…"]); + + expect(after).toBe(before); + }); + + it("grows once for new characters, then stabilises on the grown array", () => { + const set = new LabelCharacterSet(); + const seed = set.characters; + + const grown = set.extend(["Straße", "数量"]); + expect(grown).not.toBe(seed); + expect(grown).toContain("ß"); + expect(grown).toContain("数"); + expect(grown).toContain("量"); + + // Same characters again: the grown reference must hold. + expect(set.extend(["Straße 数"])).toBe(grown); + }); + + it("treats astral code points as single characters", () => { + const set = new LabelCharacterSet(); + + const grown = set.extend(["📦 crate"]); + + expect(grown).toContain("📦"); + }); + + it("never admits newlines (line breaks, not glyphs)", () => { + const set = new LabelCharacterSet(); + const before = set.characters; + + const after = set.extend(["Type A\n(12)"]); + + expect(after).toBe(before); + expect(after).not.toContain("\n"); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/label-character-set.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/label-character-set.ts new file mode 100644 index 00000000000..e6e6e325fce --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/label-character-set.ts @@ -0,0 +1,72 @@ +/** + * Stable `characterSet` for the label TextLayers, replacing `"auto"`. + * + * Why not `"auto"`: deck re-derives the set from the currently VISIBLE label + * strings on every data change and stores it as a fresh `Set`; the TextLayer's + * font-atlas manager compares that prop by reference, so every label rebuild + * re-enters it and bumps the layer's `styleVersion` (regenerating the text + * sublayer's attributes), and any character it has not rasterised yet + * triggers a synchronous canvas re-raster plus a GPU texture upload -- right + * as zoom crosses a label bucket, the regime where the render bench shows + * hitches (see docs/PERFORMANCE.md, layer bisection). + * + * The replacement is one grow-only union per scene: seeded with printable + * ASCII plus every character our worker-side label builders synthesise + * themselves, extended with each character observed in actual label text. + * The exposed array keeps its reference unless the union grew, so deck's + * reference compare short-circuits and the atlas is left alone; when new + * characters do arrive (new data), deck rasterises just the new glyphs into + * its cached atlas once, at ingest rather than mid-zoom. + */ + +/** + * Characters the worker's label builders emit that are NOT printable ASCII: + * `…` from property-value truncation (worker/entity-graph/store/property.ts), `→`/`←` + * from link features (worker/hierarchy/cluster-feature-source.ts), plus + * typographic dashes/quotes so user text stays safe under copy tweaks. + */ +const SYNTHESIZED_LABEL_CHARACTERS = "…→←–—\u2018\u2019\u201C\u201D"; + +function seedCharacters(): Set { + const seed = new Set(); + // Printable ASCII (32..126), deck's own default atlas range. + for (let code = 32; code < 127; code++) { + seed.add(String.fromCharCode(code)); + } + for (const character of SYNTHESIZED_LABEL_CHARACTERS) { + seed.add(character); + } + return seed; +} + +export class LabelCharacterSet { + readonly #seen = seedCharacters(); + + #stable: readonly string[] = [...this.#seen]; + + /** The current set as an array whose reference only changes when the set grows. */ + get characters(): readonly string[] { + return this.#stable; + } + + /** + * Union in every character (code point) of every string. Newlines are + * line breaks to the TextLayer, not glyphs, and are skipped. Returns + * {@link characters}, refreshed only if the union grew. + */ + extend(texts: Iterable): readonly string[] { + let grew = false; + for (const text of texts) { + for (const character of text) { + if (character !== "\n" && !this.#seen.has(character)) { + this.#seen.add(character); + grew = true; + } + } + } + if (grew) { + this.#stable = [...this.#seen]; + } + return this.#stable; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/labels.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/labels.ts new file mode 100644 index 00000000000..43bd267b5a3 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/labels.ts @@ -0,0 +1,200 @@ +/** + * Cluster and highway edge labels, faded by on-screen size so they don't + * pop in/out at low zoom. + * + * Both layers take an explicit reference-stable `characterSet` (grown by + * `LabelCharacterSet`) instead of `"auto"`, which re-derives a fresh set + * from the visible labels on every rebuild and re-enters deck's font-atlas + * manager each time (see label-character-set.ts). + * + * TODO: adopt @deck.gl/extensions CollisionFilterExtension once label density + * exceeds ~N visible cluster+edge labels at zoom Z (measured overlap in the + * capture harness). + */ +import { TextLayer } from "@deck.gl/layers"; + +import { graphColors } from "../visual-style"; + +import type { + PositionsFrame, + RenderCluster, + RenderEdgeLabel, + StructureFrame, +} from "../frames"; +import type { Layer } from "@deck.gl/core"; + +/** A cluster needs this much screen presence before its circle-contained label can breathe. */ +const CLUSTER_LABEL_MIN_SCREEN_RADIUS = 34; +/** A highway needs enough screen length for its lane-sized count capsule to avoid visual noise. */ +const EDGE_LABEL_MIN_SCREEN_CHORD = 82; +const LABEL_FADE_PX = 18; +/** Line-to-line spacing for multi-line cluster labels (also passed to the TextLayer). */ +const LABEL_LINE_HEIGHT = 1.08; + +/** Every string the two label layers can render, for character-set growth. */ +export function* labelTexts( + structure: StructureFrame, + positions: PositionsFrame, +): Generator { + for (const cluster of structure.clusters) { + yield cluster.label; + } + for (const label of positions.edgeLabels) { + yield label.text; + } +} + +function fadeAlpha( + screenMetric: number, + threshold: number, + maxAlpha: number, +): number { + const progress = Math.min( + 1, + Math.max(0, (screenMetric - threshold + LABEL_FADE_PX) / LABEL_FADE_PX), + ); + return Math.round(maxAlpha * progress); +} + +function clusterLabelSize(cluster: RenderCluster): number { + // Property labels arrive multi-line ("Title = value" per line + a "(count)" line); width + // is set by the longest line and the stack must fit the bubble vertically. A single-line + // type-set label (lineCount 1) keeps its original size via the per-line cap. + const lines = cluster.label.split("\n"); + let longest = 1; + for (const line of lines) { + longest = Math.max(longest, line.length); + } + const widthBudget = + cluster.depth > 0 ? cluster.radius * 0.9 : cluster.radius * 1.45; + const perLineCap = + cluster.depth > 0 ? cluster.radius * 0.22 : cluster.radius * 0.34; + const stackBudget = + cluster.depth > 0 ? cluster.radius * 0.62 : cluster.radius * 0.78; + const widthFit = widthBudget / (longest * 0.58); + const heightFit = Math.min( + perLineCap, + stackBudget / (lines.length * LABEL_LINE_HEIGHT), + ); + return Math.min(widthFit, heightFit); +} + +export interface ClusterLabelLayerConfig { + readonly structure: StructureFrame; + readonly positions: PositionsFrame; + readonly zoom: number; + /** Reference-stable glyph set; a new array only when the union grew. */ + readonly characterSet: readonly string[]; +} + +export function clusterLabelLayer({ + structure, + positions, + zoom, + characterSet, +}: ClusterLabelLayerConfig): Layer | undefined { + const scale = 2 ** zoom; + const data: { cluster: RenderCluster; x: number; y: number }[] = []; + for (let index = 0; index < structure.clusters.length; index++) { + const cluster = structure.clusters[index]!; + data.push({ + cluster, + x: positions.clusterPositions[index * 2] ?? 0, + y: positions.clusterPositions[index * 2 + 1] ?? 0, + }); + } + + return new TextLayer<{ cluster: RenderCluster; x: number; y: number }>({ + id: "cluster-labels", + data, + getPosition: (datum) => [ + datum.x, + datum.cluster.depth > 0 ? datum.y - datum.cluster.radius * 0.82 : datum.y, + ], + getText: (datum) => datum.cluster.label, + getSize: (datum) => clusterLabelSize(datum.cluster), + sizeUnits: "common", + lineHeight: LABEL_LINE_HEIGHT, + getColor: (datum) => [ + 255, + 255, + 255, + fadeAlpha( + datum.cluster.radius * scale, + CLUSTER_LABEL_MIN_SCREEN_RADIUS, + datum.cluster.depth > 0 ? 210 : 255, + ), + ], + getTextAnchor: "middle", + getAlignmentBaseline: "center", + fontFamily: "Inter, sans-serif", + // deck's prop type wants a mutable array; it never mutates it. + characterSet: characterSet as string[], + pickable: false, + updateTriggers: { + getColor: zoom, + }, + }); +} + +export interface EdgeLabelLayerConfig { + readonly positions: PositionsFrame; + readonly zoom: number; + readonly positionVersion: number; + /** Reference-stable glyph set; a new array only when the union grew. */ + readonly characterSet: readonly string[]; +} + +export function edgeLabelLayer({ + positions, + zoom, + positionVersion, + characterSet, +}: EdgeLabelLayerConfig): Layer | undefined { + if (positions.edgeLabels.length === 0) { + return undefined; + } + const scale = 2 ** zoom; + const data = positions.edgeLabels; + + return new TextLayer({ + id: "edge-labels", + data, + getPosition: (label) => [label.x, label.y], + getText: (label) => label.text, + getSize: (label) => label.size, + getAngle: (label) => label.angle, + sizeUnits: "common", + getColor: (label) => [ + graphColors.edgeLabelText[0], + graphColors.edgeLabelText[1], + graphColors.edgeLabelText[2], + fadeAlpha(label.chord * scale, EDGE_LABEL_MIN_SCREEN_CHORD, 255), + ], + background: true, + getBackgroundColor: (label) => [ + graphColors.edgeLabelBackground[0], + graphColors.edgeLabelBackground[1], + graphColors.edgeLabelBackground[2], + fadeAlpha( + label.chord * scale, + EDGE_LABEL_MIN_SCREEN_CHORD, + graphColors.edgeLabelBackground[3], + ), + ], + backgroundPadding: [6, 3, 6, 3], + getTextAnchor: "middle", + getAlignmentBaseline: "center", + fontFamily: "Inter, sans-serif", + fontWeight: 600, + // deck's prop type wants a mutable array; it never mutates it. + characterSet: characterSet as string[], + pickable: false, + updateTriggers: { + getColor: zoom, + getBackgroundColor: zoom, + getSize: positionVersion, + getAngle: positionVersion, + }, + }); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/callbacks.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/callbacks.ts new file mode 100644 index 00000000000..ab15d863b77 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/callbacks.ts @@ -0,0 +1,104 @@ +import type { Position } from "../../geometry"; +/** + * The Scene's outward-facing contract, parameterized over node identity (see + * {@link "./handle"}): the hover/selection/label payloads it reports and the + * callback set the host React component provides. Everything here is in + * container-pixel space, ready to position HTML overlays. + * + * Hierarchical-tier payloads ({@link HighwayHover}, {@link ClusterHover}) + * stay entity-typed: only the entity lifecycle has that tier, so they never + * fire for other node identities. + */ +import type { EntityId, VersionedUrl } from "@blockprotocol/type-system"; + +/** A hovered flat-tier node dot: its id and the cursor position in container pixels. */ +export interface NodeHover extends Position { + readonly nodeId: NodeId; +} + +/** A hovered aggregated highway: a summary of the links it bundles, at the cursor. */ +export interface HighwayHover extends Position { + /** The lane's single link type used to look up its icon; null for a rollup lane. */ + readonly typeId: VersionedUrl | null; + readonly typeLabel: string; + readonly count: number; + readonly direction: "forward" | "reverse" | "both"; +} + +/** + * A hovered wholly-frontier cluster bubble (every member fetched-but-unexpanded): its frontier + * EntityIds plus the bubble's on-screen geometry, re-emitted as the camera moves / layout settles + * so an action card can sit at its edge and offer to load it. Null on leave. + */ +export interface ClusterHover extends Position { + readonly count: number; + readonly frontierEntityIds: readonly EntityId[]; + + /** Bubble on-screen radius (px), so the card can sit just outside its edge. */ + readonly radiusPx: number; +} + +/** + * The selected node: its id and its on-screen position in container pixels, re-emitted as + * the node settles and the camera moves so a pinned card can follow it. + */ +export interface NodeSelection extends Position { + readonly nodeId: NodeId; +} + +/** + * An always-on node label to overlay as HTML: the node, its display name, and its current + * on-screen position (container pixels). Re-emitted each frame so labels track camera motion + * and layout settling; intended for HTML overlay (viewport-culled), not GPU text. + */ +export interface NodeLabel extends Position { + readonly nodeId: NodeId; + readonly text: string; +} + +/** + * A hovered flat-tier edge that is not itself a node (the type lifecycle's + * link-type edges; see {@link "./handle".FlatEdgePick}), at the cursor. + */ +export interface FlatEdgeHover extends Position { + readonly source: NodeId; + readonly target: NodeId; + readonly linkType: NodeId; +} + +export interface SceneCallbacks { + /** Report the hovered flat-tier node, or null on leave. */ + readonly onNodeHover?: (hover: NodeHover | null) => void; + /** Report a hovered aggregated highway's summary, or null on leave. */ + readonly onHighwayHover?: (hover: HighwayHover | null) => void; + /** Report the selected node + its tracked on-screen position, or null when cleared. */ + readonly onNodeSelect?: (selection: NodeSelection | null) => void; + /** Open a table of the link entities aggregated by a clicked highway (hierarchical tier). */ + readonly onOpenLinkTable?: (linkNodeIds: readonly NodeId[]) => void; + /** Report a hovered wholly-frontier cluster bubble (offer to load its entities), or null on leave. */ + readonly onClusterHover?: (hover: ClusterHover | null) => void; + /** Report a hovered non-node flat edge (a type graph's link-type edge), or null on leave. */ + readonly onEdgeHover?: (hover: FlatEdgeHover | null) => void; + /** + * Resolve a node's display label for always-on graph labels. Called only while the label + * eligibility set is (re)built (on zoom or structure change), never per frame. + */ + readonly resolveNodeLabel?: (nodeId: NodeId) => string | undefined; + /** + * Resolve a node's icon to an atlas key (emoji or image URL), or null when none exists. + * Called only while the icon cache is (re)built on structure change, never per frame. + */ + readonly resolveNodeIcon?: (nodeId: NodeId) => string | null; + /** + * Report the always-on node labels to overlay as HTML, re-emitted each frame with their + * current on-screen positions (so they track the camera / settle). Empty when none are visible. + */ + readonly onNodeLabels?: (labels: readonly NodeLabel[]) => void; + /** Fired once when the first structure frame lands, signalling that the graph is ready to display. */ + readonly onFirstStructure: () => void; +} + +/** Entity-lifecycle aliases (the concrete types the entity bridge and overlays speak). */ +export type EntityHover = NodeHover; +export type EntitySelection = NodeSelection; +export type EntityLabel = NodeLabel; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/camera.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/camera.ts new file mode 100644 index 00000000000..58ec4761f54 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/camera.ts @@ -0,0 +1,458 @@ +/** + * The camera: owns the Deck view state (never React state), the coarse zoom + * buckets that gate expensive rebuilds, and the throttled viewport stream to + * the worker's LOD. Camera moves flow through {@link SceneCamera.applyViewState}, + * which invokes {@link SceneCameraDependencies.afterViewStateApplied} when bucket crossings occur. + */ +import { LinearInterpolator } from "@deck.gl/core"; + +import { + FLAT_HEADER_BYTES, + FLAT_RADIUS_BYTE_OFFSET, + FLAT_RECORD_BYTES, + leafNodeX, + leafNodeY, +} from "../../worker/buffers/position-buffer"; +import { + INITIAL_VIEW_STATE, + WORKER_VIEWPORT_MIN_INTERVAL_MS, + WorkerViewportSnapshot, + iconZoomBucket, + labelColorZoomBucket, + labelZoomBucket, + viewStateZoom, +} from "./view-state"; + +import type { PlacedCluster } from "../clusters"; +import type { FrameHandle } from "../frame-connection"; +import type { ViewState } from "./view-state"; +import type { Deck, OrthographicView } from "@deck.gl/core"; + +export interface ViewBounds { + readonly minX: number; + readonly minY: number; + readonly maxX: number; + readonly maxY: number; +} + +/** Which gated rebuilds a view-state change crossed into. */ +export interface ZoomBucketChanges { + readonly labelEligibilityChanged: boolean; + readonly iconEligibilityChanged: boolean; + readonly labelColorBucketChanged: boolean; +} + +/** The world-space bounding box of everything currently rendered, or null before frames. */ +function contentBounds(handle: FrameHandle): ViewBounds | null { + const positions = handle.getPositions(); + const structure = handle.getStructure(); + if (!positions || !structure) { + return null; + } + + let bounds: ViewBounds | null = null; + const include = (x: number, y: number, radius: number) => { + if (!Number.isFinite(x) || !Number.isFinite(y)) { + return; + } + const next = { + minX: x - radius, + minY: y - radius, + maxX: x + radius, + maxY: y + radius, + }; + bounds = + bounds === null + ? next + : { + minX: Math.min(bounds.minX, next.minX), + minY: Math.min(bounds.minY, next.minY), + maxX: Math.max(bounds.maxX, next.maxX), + maxY: Math.max(bounds.maxY, next.maxY), + }; + }; + + const flatGraph = structure.flatGraph; + if (flatGraph) { + const flat = handle.getClusters().get(flatGraph.layoutId); + if (flat) { + const data = new DataView(flat.versionView.buffer); + const count = data.getUint32(4, true); + for (let index = 0; index < count; index++) { + const recordOffset = FLAT_HEADER_BYTES + index * FLAT_RECORD_BYTES; + include( + data.getFloat32(recordOffset, true), + data.getFloat32(recordOffset + 4, true), + data.getFloat32(recordOffset + FLAT_RADIUS_BYTE_OFFSET, true), + ); + } + } + return bounds; + } + + const clusters = handle.getClusters(); + for (const [index, cluster] of structure.clusters.entries()) { + const x = positions.clusterPositions[index * 2] ?? 0; + const y = positions.clusterPositions[index * 2 + 1] ?? 0; + include(x, y, cluster.radius); + } + + for (const layer of structure.entityLayers) { + const ref = clusters.get(layer.layoutId); + if (!ref) { + continue; + } + const originX = positions.clusterPositions[layer.leafClusterIndex * 2] ?? 0; + const originY = + positions.clusterPositions[layer.leafClusterIndex * 2 + 1] ?? 0; + for (let index = 0; index < ref.nodeIds.length; index++) { + include( + originX + leafNodeX(ref.positions, index), + originY + leafNodeY(ref.positions, index), + // Leaf nodes have no per-record radius in the SAB; use a fixed 4-world-unit pad for fit-to-content bounds. + 4, + ); + } + } + + return bounds; +} + +/** + * World bounds of the largest rendered bubble, or null when none exists + * (before frames, or flat-force without communities). + * + * Flat tier: the biggest Louvain community by member count, its bounds + * gathered from the members' live SAB positions (padded by dot radius) -- + * this is the "giant ball" the BubbleSet layer draws. Hierarchical tier: + * the largest leaf cluster bubble by world radius (depth > 0 are opened + * containers, faint outlines whose fill lives in their leaves). + */ +function largestBubbleBounds(handle: FrameHandle): ViewBounds | null { + const positions = handle.getPositions(); + const structure = handle.getStructure(); + if (!positions || !structure) { + return null; + } + + const flatGraph = structure.flatGraph; + if (flatGraph) { + const membership = flatGraph.communities; + if (!membership) { + return null; + } + const flat = handle.getClusters().get(flatGraph.layoutId); + if (!flat) { + return null; + } + const data = new DataView(flat.versionView.buffer); + const count = Math.min(data.getUint32(4, true), membership.length); + + const memberCounts = new Map(); + let largestCommunity = -1; + let largestSize = 0; + for (let index = 0; index < count; index++) { + const community = membership[index] ?? -1; + if (community < 0) { + continue; + } + const size = (memberCounts.get(community) ?? 0) + 1; + memberCounts.set(community, size); + if (size > largestSize) { + largestSize = size; + largestCommunity = community; + } + } + if (largestCommunity < 0) { + return null; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (let index = 0; index < count; index++) { + if (membership[index] !== largestCommunity) { + continue; + } + const recordOffset = FLAT_HEADER_BYTES + index * FLAT_RECORD_BYTES; + const x = data.getFloat32(recordOffset, true); + const y = data.getFloat32(recordOffset + 4, true); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + continue; + } + const radius = data.getFloat32( + recordOffset + FLAT_RADIUS_BYTE_OFFSET, + true, + ); + minX = Math.min(minX, x - radius); + minY = Math.min(minY, y - radius); + maxX = Math.max(maxX, x + radius); + maxY = Math.max(maxY, y + radius); + } + return minX === Infinity ? null : { minX, minY, maxX, maxY }; + } + + let largest: ViewBounds | null = null; + let largestRadius = 0; + for (const [index, cluster] of structure.clusters.entries()) { + if (cluster.depth !== 0 || cluster.radius <= largestRadius) { + continue; + } + const x = positions.clusterPositions[index * 2] ?? 0; + const y = positions.clusterPositions[index * 2 + 1] ?? 0; + largestRadius = cluster.radius; + largest = { + minX: x - cluster.radius, + minY: y - cluster.radius, + maxX: x + cluster.radius, + maxY: y + cluster.radius, + }; + } + return largest; +} + +export interface SceneCameraDependencies { + readonly container: HTMLDivElement; + readonly handle: FrameHandle; + readonly deck: () => Deck; + /** React to a camera move: gated rebuilds + HTML overlay re-projection. */ + readonly afterViewStateApplied: (changes: ZoomBucketChanges) => void; +} + +export class SceneCamera { + readonly #dependencies: SceneCameraDependencies; + + #viewState: ViewState = INITIAL_VIEW_STATE; + #labelZoomBucket = labelZoomBucket(viewStateZoom(INITIAL_VIEW_STATE)); + #iconZoomBucket = iconZoomBucket(viewStateZoom(INITIAL_VIEW_STATE)); + #labelColorZoomBucket = labelColorZoomBucket( + viewStateZoom(INITIAL_VIEW_STATE), + ); + + #viewportFrame: number | null = null; + #viewportTimer: number | null = null; + #viewportDirty = false; + readonly #nextViewport = new WorkerViewportSnapshot(); + readonly #lastViewport = new WorkerViewportSnapshot(); + #hasLastViewport = false; + #lastViewportSentAt = 0; + + constructor(dependencies: SceneCameraDependencies) { + this.#dependencies = dependencies; + } + + get viewState(): ViewState { + return this.#viewState; + } + + get zoom(): number { + return viewStateZoom(this.#viewState); + } + + get iconBucket(): number { + return this.#iconZoomBucket; + } + + dispose(): void { + if (this.#viewportFrame !== null) { + cancelAnimationFrame(this.#viewportFrame); + } + if (this.#viewportTimer !== null) { + clearTimeout(this.#viewportTimer); + } + } + + zoomBy(delta: number): void { + const zoom = this.zoom; + const nextZoom = Math.min( + this.#maxZoom(), + Math.max(this.#minZoom(), zoom + delta), + ); + this.applyViewState({ + ...this.#viewState, + zoomX: nextZoom, + zoomY: nextZoom, + transitionDuration: 160, + transitionInterpolator: new LinearInterpolator(["zoomX", "zoomY"]), + }); + } + + fitToContent(): void { + const bounds = contentBounds(this.#dependencies.handle); + if (bounds) { + this.fitToBounds(bounds, 72); + } + } + + /** + * Frame the largest rendered bubble (Louvain community on the flat tier, + * leaf cluster otherwise) so it fills the padded viewport; fit-to-content + * when none exists. Data-derived, so repeated calls frame the same view. + */ + frameLargestBubble(): void { + const bounds = largestBubbleBounds(this.#dependencies.handle); + if (bounds) { + this.fitToBounds(bounds, 48); + } else { + this.fitToContent(); + } + } + + /** Frame a world-space box: centre it and zoom so it fills the padded viewport. */ + fitToBounds(bounds: ViewBounds, padding: number): void { + const viewport = this.#dependencies.deck().getViewports()[0]; + if (!viewport) { + return; + } + + const width = Math.max(1, bounds.maxX - bounds.minX); + const height = Math.max(1, bounds.maxY - bounds.minY); + const usableWidth = Math.max(1, viewport.width - padding * 2); + const usableHeight = Math.max(1, viewport.height - padding * 2); + const nextZoom = Math.log2( + Math.min(usableWidth / width, usableHeight / height), + ); + const clampedZoom = Math.min( + this.#maxZoom(), + Math.max(this.#minZoom(), nextZoom), + ); + + this.applyViewState({ + ...this.#viewState, + target: [ + (bounds.minX + bounds.maxX) / 2, + (bounds.minY + bounds.maxY) / 2, + 0, + ], + zoomX: clampedZoom, + zoomY: clampedZoom, + transitionDuration: 240, + transitionInterpolator: new LinearInterpolator([ + "target", + "zoomX", + "zoomY", + ]), + }); + } + + /** Ease the camera to centre a point, holding the current zoom. */ + focusOn(x: number, y: number): void { + this.#transitionTo({ + ...this.#viewState, + target: [x, y, 0], + transitionDuration: 350, + transitionInterpolator: new LinearInterpolator(["target"]), + }); + } + + /** Animate onto a cluster bubble so its on-screen radius crosses the open threshold. */ + zoomToBubble(placed: PlacedCluster): void { + const targetZoom = Math.log2(Math.max(1e-3, 320 / placed.cluster.radius)); + this.#transitionTo({ + ...this.#viewState, + target: [placed.x, placed.y, 0], + zoomX: targetZoom, + zoomY: targetZoom, + transitionDuration: 450, + transitionInterpolator: new LinearInterpolator([ + "target", + "zoomX", + "zoomY", + ]), + }); + } + + /** + * Apply a view state and run the gated reactions: bucket-crossing rebuilds + * (via the hook), the throttled worker viewport, and overlay re-projection. + */ + applyViewState(viewState: ViewState): void { + const nextZoom = viewStateZoom(viewState); + const nextLabelZoomBucket = labelZoomBucket(nextZoom); + const labelEligibilityChanged = + nextLabelZoomBucket !== this.#labelZoomBucket; + const nextIconZoomBucket = iconZoomBucket(nextZoom); + const iconEligibilityChanged = nextIconZoomBucket !== this.#iconZoomBucket; + const nextLabelColorZoomBucket = labelColorZoomBucket(nextZoom); + const labelColorBucketChanged = + nextLabelColorZoomBucket !== this.#labelColorZoomBucket; + this.#viewState = viewState; + this.#labelZoomBucket = nextLabelZoomBucket; + this.#iconZoomBucket = nextIconZoomBucket; + this.#labelColorZoomBucket = nextLabelColorZoomBucket; + this.#dependencies.deck().setProps({ viewState }); + this.scheduleViewport(); + this.#dependencies.afterViewStateApplied({ + labelEligibilityChanged, + iconEligibilityChanged, + labelColorBucketChanged, + }); + } + + /** Debounce + rate-limit the LOD viewport sent to the worker. */ + scheduleViewport(): void { + this.#viewportDirty = true; + if (this.#viewportFrame !== null || this.#viewportTimer !== null) { + return; + } + const elapsed = performance.now() - this.#lastViewportSentAt; + const delay = Math.max(0, WORKER_VIEWPORT_MIN_INTERVAL_MS - elapsed); + const scheduleFrame = () => { + this.#viewportFrame = requestAnimationFrame(() => { + this.#viewportFrame = null; + this.#emitViewport(); + }); + }; + if (delay === 0) { + scheduleFrame(); + } else { + this.#viewportTimer = window.setTimeout(() => { + this.#viewportTimer = null; + scheduleFrame(); + }, delay); + } + } + + #emitViewport(): void { + if (!this.#viewportDirty) { + return; + } + this.#viewportDirty = false; + this.#nextViewport.update(this.#dependencies.container, this.#viewState); + if ( + this.#hasLastViewport && + this.#nextViewport.equals(this.#lastViewport) + ) { + return; + } + this.#lastViewport.copyFrom(this.#nextViewport); + this.#hasLastViewport = true; + this.#lastViewportSentAt = performance.now(); + this.#dependencies.handle.sendViewport({ + zoom: this.#lastViewport.zoom, + center: this.#lastViewport.center, + width: this.#lastViewport.width, + height: this.#lastViewport.height, + }); + } + + // Transitions set the state and let Deck's onViewStateChange route the + // animated frames back through applyViewState (which runs the gates). + #transitionTo(next: ViewState): void { + this.#viewState = next; + this.#dependencies.deck().setProps({ viewState: next }); + } + + #minZoom(): number { + return typeof this.#viewState.minZoom === "number" + ? this.#viewState.minZoom + : -12; + } + + #maxZoom(): number { + return typeof this.#viewState.maxZoom === "number" + ? this.#viewState.maxZoom + : 24; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/geometry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/geometry.ts new file mode 100644 index 00000000000..f09a5bc658f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/geometry.ts @@ -0,0 +1,49 @@ +/** + * Live world-space lookups against the current frames: a node's position by + * layout + render index, and a selected link's edge midpoint. Shared by the + * interaction controller (rings, pinned cards) and the hub-label overlay. + */ +import { nodeGeometry } from "../selection"; + +import type { PositionsFrame } from "../../frames"; +import type { ClusterId } from "../../ids"; +import type { FrameHandle } from "../frame-connection"; +import type { SelectionGeometry } from "../selection"; + +/** Live world position + radius of a node by its layout + render index, or null if gone. */ +export function liveNodeGeometry( + handle: FrameHandle, + layoutId: ClusterId, + localIndex: number, +): SelectionGeometry | null { + const cluster = handle.getClusters().get(layoutId); + const structure = handle.getStructure(); + const positions = handle.getPositions(); + if (!cluster || !structure || !positions) { + return null; + } + return nodeGeometry(layoutId, localIndex, cluster, structure, positions); +} + +/** + * World midpoint of a selected flat edge, by re-locating its bezier segment (segment order + * changes per tick, but the edge's id -- the link's EntityIdx, or the type lifecycle's + * edge-table index -- is stable). null if the edge isn't rendered. + */ +export function linkMidpoint( + positions: PositionsFrame, + flatEdgeId: number, +): { x: number; y: number } | null { + const { ids, positions: pos } = positions.beziers; + for (let index = 0; index < ids.length; index++) { + if (ids[index] === flatEdgeId) { + // Flat links are straight cubics, so the chord midpoint (p0+p3)/2 is the visual centre. + const base = index * 8; + return { + x: ((pos[base] ?? 0) + (pos[base + 6] ?? 0)) / 2, + y: ((pos[base + 1] ?? 0) + (pos[base + 7] ?? 0)) / 2, + }; + } + } + return null; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/gpu-frame-timer.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/gpu-frame-timer.test.ts new file mode 100644 index 00000000000..625d9b202c8 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/gpu-frame-timer.test.ts @@ -0,0 +1,197 @@ +/** + * GpuFrameTimer tests over a scripted fake WebGL2 context: extension + * detection, asynchronous result delivery in submit order, disjoint + * discard, and query-object pooling. + */ +import { describe, expect, it } from "vitest"; + +import { GpuFrameTimer } from "./gpu-frame-timer"; + +const TIME_ELAPSED_EXT = 0x88bf; +const GPU_DISJOINT_EXT = 0x8fbb; +const QUERY_RESULT_AVAILABLE = 0x8867; +const QUERY_RESULT = 0x8866; + +/** Fake WebGL2 context: queries complete when the test says so. */ +class FakeGl { + readonly QUERY_RESULT_AVAILABLE = QUERY_RESULT_AVAILABLE; + readonly QUERY_RESULT = QUERY_RESULT; + + hasExtension = true; + disjoint = false; + createdCount = 0; + + /** Query -> result in nanoseconds, or null while still pending. */ + readonly #results = new Map(); + #active: WebGLQuery | null = null; + + getExtension(name: string): unknown { + if (name !== "EXT_disjoint_timer_query_webgl2" || !this.hasExtension) { + return null; + } + return { TIME_ELAPSED_EXT, GPU_DISJOINT_EXT }; + } + + createQuery(): WebGLQuery { + this.createdCount += 1; + return { fakeQuery: this.createdCount } as unknown as WebGLQuery; + } + + beginQuery(_target: number, query: WebGLQuery): void { + this.#active = query; + this.#results.set(query, null); + } + + endQuery(_target: number): void { + this.#active = null; + } + + getParameter(parameter: number): unknown { + return parameter === GPU_DISJOINT_EXT ? this.disjoint : undefined; + } + + getQueryParameter(query: WebGLQuery, parameter: number): unknown { + if (parameter === QUERY_RESULT_AVAILABLE) { + return this.#results.get(query) !== null; + } + return this.#results.get(query) ?? 0; + } + + /** Test hook: mark the query's result ready (nanoseconds). */ + finish(query: WebGLQuery, nanoseconds: number): void { + this.#results.set(query, nanoseconds); + } + + get lastBegun(): WebGLQuery | null { + return this.#active; + } +} + +const asContext = (fake: FakeGl): WebGL2RenderingContext => + fake as unknown as WebGL2RenderingContext; + +interface Recorded { + readonly samples: { submittedAtMs: number; durationMs: number }[]; + disjoints: number; +} + +function timerOver(clockTimes: number[]): { + timer: GpuFrameTimer; + recorded: Recorded; +} { + const recorded: Recorded = { samples: [], disjoints: 0 }; + const timer = new GpuFrameTimer( + (submittedAtMs, durationMs) => { + recorded.samples.push({ submittedAtMs, durationMs }); + }, + () => { + recorded.disjoints += 1; + }, + () => clockTimes.shift() ?? 0, + ); + return { timer, recorded }; +} + +describe("GpuFrameTimer", () => { + it("reports unavailability once probed and never samples", () => { + const fake = new FakeGl(); + fake.hasExtension = false; + const { timer, recorded } = timerOver([0]); + + expect(timer.available).toBeNull(); + timer.frameBegin(asContext(fake)); + timer.frameEnd(); + + expect(timer.available).toBe(false); + expect(recorded.samples).toEqual([]); + }); + + it("delivers completed queries in submit order with submit timestamps", () => { + const fake = new FakeGl(); + const { timer, recorded } = timerOver([100, 116, 132]); + + timer.frameBegin(asContext(fake)); + const first = fake.lastBegun!; + timer.frameEnd(); + timer.frameBegin(asContext(fake)); + const second = fake.lastBegun!; + timer.frameEnd(); + + // Nothing ready yet: a poll delivers no samples. + timer.poll(); + expect(recorded.samples).toEqual([]); + + // 4ms and 80ms, in nanoseconds; both become ready. + fake.finish(first, 4e6); + fake.finish(second, 80e6); + timer.poll(); + + expect(recorded.samples).toEqual([ + { submittedAtMs: 100, durationMs: 4 }, + { submittedAtMs: 116, durationMs: 80 }, + ]); + expect(timer.available).toBe(true); + }); + + it("stops polling at the first unavailable result (submit order holds)", () => { + const fake = new FakeGl(); + const { timer, recorded } = timerOver([0, 16]); + + timer.frameBegin(asContext(fake)); + const first = fake.lastBegun!; + timer.frameEnd(); + timer.frameBegin(asContext(fake)); + const second = fake.lastBegun!; + timer.frameEnd(); + + // Only the SECOND is ready; the first must still block delivery. + fake.finish(second, 5e6); + timer.poll(); + expect(recorded.samples).toEqual([]); + + fake.finish(first, 3e6); + timer.poll(); + expect(recorded.samples.map((sample) => sample.durationMs)).toEqual([3, 5]); + }); + + it("discards in-flight queries on a disjoint event", () => { + const fake = new FakeGl(); + const { timer, recorded } = timerOver([0, 16]); + + timer.frameBegin(asContext(fake)); + const first = fake.lastBegun!; + timer.frameEnd(); + fake.finish(first, 7e6); + + fake.disjoint = true; + timer.poll(); + + expect(recorded.disjoints).toBe(1); + expect(recorded.samples).toEqual([]); + + // After the flag clears, later frames measure normally again. + fake.disjoint = false; + timer.frameBegin(asContext(fake)); + const next = fake.lastBegun!; + timer.frameEnd(); + fake.finish(next, 9e6); + timer.poll(); + expect(recorded.samples).toEqual([{ submittedAtMs: 16, durationMs: 9 }]); + }); + + it("pools completed query objects instead of re-creating", () => { + const fake = new FakeGl(); + const { timer } = timerOver([0, 16, 32, 48]); + + for (let frame = 0; frame < 4; frame++) { + timer.frameBegin(asContext(fake)); + const query = fake.lastBegun!; + timer.frameEnd(); + fake.finish(query, 1e6); + timer.poll(); + } + + // Every frame's query completed before the next began: one object suffices. + expect(fake.createdCount).toBe(1); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/gpu-frame-timer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/gpu-frame-timer.ts new file mode 100644 index 00000000000..6d479aab685 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/gpu-frame-timer.ts @@ -0,0 +1,158 @@ +/** + * Whole-frame GPU elapsed-time sampling via `EXT_disjoint_timer_query_webgl2`. + * + * Deck reports `gpuTimePerFrame: 0` because nothing wires the WebGL timer + * extension, which leaves the GPU side of a hitch invisible: a long rAF + * interval with an idle main thread could be fragment load, pipeline + * back-pressure, or the compositor. This timer brackets each Deck redraw + * (`onBeforeRender`/`onAfterRender`) with a `TIME_ELAPSED_EXT` query, giving + * the GPU-side execution time of that frame's draw commands. It does NOT + * include compositing/present -- but "91 ms frame, 80 ms GPU draw time" vs + * "91 ms frame, 2 ms GPU draw time" is exactly the fork the render bench + * needs to attribute a stall. + * + * Query results are asynchronous (typically ready 1-3 frames later), so + * completed samples are polled at each subsequent frame start and reported + * through a callback together with the submit-time timestamp -- the sample + * lines up with the frame that PRODUCED it, not the frame where the result + * arrived. Availability is a runtime question (the extension is commonly + * missing on macOS Chrome, where ANGLE runs over Metal): {@link available} + * stays `null` until the first frame probes the context, then reports what + * the platform gave us. + */ + +/** The extension object: two GLenums beyond the WebGL2 query API. */ +interface TimerExtension { + readonly TIME_ELAPSED_EXT: number; + readonly GPU_DISJOINT_EXT: number; +} + +interface PendingQuery { + readonly query: WebGLQuery; + readonly submittedAtMs: number; +} + +/** Receives one completed sample per timed frame, in submit order. */ +export type GpuSampleSink = (submittedAtMs: number, durationMs: number) => void; + +/** Notified when a disjoint event discards the in-flight queries. */ +export type GpuDisjointSink = () => void; + +const NANOSECONDS_PER_MILLISECOND = 1e6; + +export class GpuFrameTimer { + #gl: WebGL2RenderingContext | null = null; + #extension: TimerExtension | null = null; + #probed = false; + #active: WebGLQuery | null = null; + #pending: PendingQuery[] = []; + #pool: WebGLQuery[] = []; + + readonly #onSample: GpuSampleSink; + readonly #onDisjoint: GpuDisjointSink; + readonly #clock: () => number; + + constructor( + onSample: GpuSampleSink, + onDisjoint: GpuDisjointSink, + clock: () => number = () => performance.now(), + ) { + this.#onSample = onSample; + this.#onDisjoint = onDisjoint; + this.#clock = clock; + } + + /** `null` until the first {@link frameBegin} probes the context. */ + get available(): boolean | null { + return this.#probed ? this.#extension !== null : null; + } + + /** + * Bracket start: poll finished queries, then begin timing this frame. + * Call only for frames that should be sampled (capture gating lives with + * the caller); un-sampled frames still deliver pending results on the + * next sampled one. + */ + frameBegin(gl: WebGL2RenderingContext): void { + if (!this.#probed) { + this.#probed = true; + this.#gl = gl; + this.#extension = gl.getExtension( + "EXT_disjoint_timer_query_webgl2", + ) as TimerExtension | null; + } + + if (this.#extension === null || this.#gl === null) { + return; + } + + this.poll(); + if (this.#active !== null) { + // Unbalanced begin (a lost frameEnd); close it out rather than throw. + this.#endActive(); + } + + const query = this.#pool.pop() ?? this.#gl.createQuery(); + this.#gl.beginQuery(this.#extension.TIME_ELAPSED_EXT, query); + this.#pending.push({ query, submittedAtMs: this.#clock() }); + this.#active = query; + } + + /** Bracket end; a no-op when the matching begin never started a query. */ + frameEnd(): void { + if (this.#active !== null) { + this.#endActive(); + } + } + + #endActive(): void { + this.#gl?.endQuery(this.#extension!.TIME_ELAPSED_EXT); + this.#active = null; + } + + /** + * Drain completed queries into the sample sink. Results complete in + * submit order, so polling stops at the first unavailable one. A disjoint + * event (GPU context churn) invalidates every in-flight measurement; + * those queries are discarded wholesale and the disjoint sink is told. + */ + poll(): void { + const gl = this.#gl; + const extension = this.#extension; + if (gl === null || extension === null || this.#pending.length === 0) { + return; + } + + if (gl.getParameter(extension.GPU_DISJOINT_EXT) === true) { + for (const { query } of this.#pending) { + if (query !== this.#active) { + this.#pool.push(query); + } + } + this.#pending = this.#active === null ? [] : this.#pending.slice(-1); + this.#onDisjoint(); + return; + } + + let completed = 0; + for (const { query, submittedAtMs } of this.#pending) { + if (query === this.#active) { + break; + } + if (gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE) !== true) { + break; + } + const nanoseconds = gl.getQueryParameter( + query, + gl.QUERY_RESULT, + ) as number; + this.#onSample(submittedAtMs, nanoseconds / NANOSECONDS_PER_MILLISECOND); + this.#pool.push(query); + completed += 1; + } + + if (completed > 0) { + this.#pending.splice(0, completed); + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/handle.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/handle.ts new file mode 100644 index 00000000000..4e94dab2225 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/handle.ts @@ -0,0 +1,72 @@ +/** + * The connection surface the {@link "../scene"} render pipeline drives, + * parameterized over node identity so both worker lifecycles plug in: + * `EntityWorkerConnection` implements `SceneHandle`, + * `TypeWorkerConnection` implements `SceneHandle`. + * + * Two identity currencies, deliberately distinct: + * - `NodeId` (branded string) is the domain identity the host UI speaks -- + * hover cards, labels, icons, selection payloads. + * - node keys (plain `number` here; `EntityIndex` / `TypeId` inside the + * connections) are the u32 join keys stored in the flat buffer records and + * the highlight/ego wire currency. The scene never manufactures keys, it + * only passes values from one handle method into another, so the interface + * deliberately un-brands them. + */ +import type { ClusterId } from "../../ids"; +import type { FrameHandle } from "../frame-connection"; + +/** + * A flat-tier edge pick, resolved by the connection from the bezier segment's + * id channel: + * - entity lifecycle: a link IS an entity, so the pick is a `node` (the link + * entity's card shows, exactly like a dot pick); + * - type lifecycle: an edge is a link *type* between two type nodes, so the + * pick carries the triple for an edge hover card. + */ +export type FlatEdgePick = + | { readonly kind: "node"; readonly nodeId: NodeId } + | { + readonly kind: "edge"; + readonly source: NodeId; + readonly target: NodeId; + readonly linkType: NodeId; + }; + +/** A node's neighbourhood, in scene currency (reply to {@link SceneHandle.queryEgo}). */ +export interface NodeEgo { + /** Join keys of visible node neighbours (kept at full colour by the highlight). */ + readonly nodeKeys: readonly NodeIndex[]; + /** Collapsed-cluster neighbours (hierarchical entity tier only; ringed, not opened). */ + readonly clusterIds: readonly ClusterId[]; +} + +/** The scene-facing handle surface both worker connections implement. */ +export interface SceneHandle< + NodeId extends string, + NodeIndex extends number, + EdgeIndex extends number, +> extends FrameHandle { + /** Resolve a picked dot (layout + render index) to its domain node id. */ + resolveNodeId(layoutId: ClusterId, recordIndex: number): NodeId | undefined; + /** The u32 join key at a record (the highlight/ego currency). */ + nodeKeyAt(layoutId: ClusterId, recordIndex: number): NodeIndex | undefined; + /** Decode a join key back to its domain node id. */ + nodeKeyToId(nodeKey: NodeIndex): NodeId | undefined; + /** Resolve a flat-tier edge pick (its bezier id) to a node or edge target. */ + resolveFlatEdge(edgeId: EdgeIndex): FlatEdgePick | null; + /** A selected node's neighbourhood (visible node keys + collapsed clusters). */ + queryEgo(nodeKey: NodeIndex): Promise>; + /** Keys kept at full colour; everything else dims. Empty restores full colour. */ + setHighlight(nodeKeys: readonly number[]): void; + /** + * Pin a hierarchical leaf open (with ancestors) regardless of zoom, or null + * to clear. No-op for lifecycles without a hierarchical tier. + */ + setPinned(clusterId: ClusterId | null): void; + /** + * The node keys of the links a highway lane aggregates (hierarchical entity + * tier only; other lifecycles resolve empty). + */ + queryHighwayLinks(laneId: number): Promise; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/hover-tracking.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/hover-tracking.ts new file mode 100644 index 00000000000..416bc8b722a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/hover-tracking.ts @@ -0,0 +1,220 @@ +/** + * Hover-card state: the hovered node dot (card at the cursor), a hovered + * non-node flat edge (a type graph's link-type edge, card at the cursor), + * the hovered highway (summary anchored to a world point), and the hovered + * wholly-frontier cluster bubble (load card anchored to the bubble). The + * anchored cards are re-emitted on every frame / camera move so they track + * pan + settle. + */ +import type { ClusterId } from "../../ids"; +import type { PlacedCluster } from "../clusters"; +import type { FrameHandle } from "../frame-connection"; +import type { SceneCallbacks } from "./callbacks"; +import type { FlatEdgePick } from "./handle"; +import type { Deck, OrthographicView } from "@deck.gl/core"; + +export interface HoverTrackerDependencies { + readonly handle: FrameHandle; + readonly deck: () => Deck; + readonly callbacks: () => SceneCallbacks; + /** The persistent cluster-bubble set (positions mutate in place per tick). */ + readonly placed: () => readonly PlacedCluster[]; + readonly zoom: () => number; +} + +export class HoverTracker { + readonly #dependencies: HoverTrackerDependencies; + + #hoveredNode: NodeId | null = null; + + /** True while a non-node flat edge's card is showing (cursor-positioned, not anchored). */ + #edgeHoverActive = false; + + /** + * The hovered highway's lane + the hovered point in WORLD space. Kept so the summary card tracks + * the highway as the camera pans / the layout settles -- re-projected by {@link emitHighwayHover} + * on every view change + frame, the same way the pinned selection card tracks its node. + */ + #hoveredHighway: { laneId: number; worldX: number; worldY: number } | null = + null; + + /** + * The hovered wholly-frontier cluster bubble, by id. Kept so the load card tracks the bubble as + * the camera pans / the layout settles -- re-projected by {@link emitClusterHover} on every view + * change + frame, like {@link #hoveredHighway}. Its frontier data is read live from the placed set. + */ + #hoveredClusterId: ClusterId | null = null; + + constructor(dependencies: HoverTrackerDependencies) { + this.#dependencies = dependencies; + } + + /** A node dot (or a flat link edge that is a node) is hovered: card at the cursor. */ + setNode(nodeId: NodeId, x: number, y: number): void { + this.#hoveredNode = nodeId; + this.#dependencies.callbacks().onNodeHover?.({ nodeId, x, y }); + this.clearEdge(); + this.clearHighway(); + this.clearCluster(); + } + + /** A non-node flat edge is hovered (type lifecycle): its triple's card at the cursor. */ + setEdge( + edge: Extract, { kind: "edge" }>, + x: number, + y: number, + ): void { + this.clearNode(); + this.clearHighway(); + this.clearCluster(); + this.#edgeHoverActive = true; + this.#dependencies.callbacks().onEdgeHover?.({ + source: edge.source, + target: edge.target, + linkType: edge.linkType, + x, + y, + }); + } + + /** + * A highway is hovered: anchor the summary to the hovered point in WORLD space so it tracks + * the highway as the camera pans / the layout settles (re-projected by + * {@link emitHighwayHover}), like the pinned card. + */ + setHighway(laneId: number, screenX: number, screenY: number): void { + this.clearNode(); + this.clearEdge(); + this.clearCluster(); + const world = this.#viewport()?.unproject([screenX, screenY]); + this.#hoveredHighway = + world === undefined + ? null + : { laneId, worldX: world[0] ?? 0, worldY: world[1] ?? 0 }; + this.emitHighwayHover(); + } + + /** A wholly-frontier bubble is hovered: the load card tracks it through pan / settle. */ + setCluster(clusterId: ClusterId): void { + this.clearNode(); + this.clearEdge(); + this.clearHighway(); + this.#hoveredClusterId = clusterId; + this.emitClusterHover(); + } + + /** Hide the hover card (the cursor left the dots, or a pan started under it). */ + clearNode(): void { + if (this.#hoveredNode !== null) { + this.#hoveredNode = null; + this.#dependencies.callbacks().onNodeHover?.(null); + } + } + + /** Clear the non-node edge card (cursor left it, or another hover took over). */ + clearEdge(): void { + if (this.#edgeHoverActive) { + this.#edgeHoverActive = false; + this.#dependencies.callbacks().onEdgeHover?.(null); + } + } + + /** Clear the highway summary (cursor left it, or a dot/link took over). */ + clearHighway(): void { + this.#hoveredHighway = null; + this.#dependencies.callbacks().onHighwayHover?.(null); + } + + /** Clear the load card (cursor left the bubble, or a dot/edge/highway took over). */ + clearCluster(): void { + if (this.#hoveredClusterId === null) { + return; + } + this.#hoveredClusterId = null; + this.#dependencies.callbacks().onClusterHover?.(null); + } + + clearAll(): void { + this.clearNode(); + this.clearEdge(); + this.clearHighway(); + this.clearCluster(); + } + + /** Re-project both anchored cards (positions moved or the camera did). */ + emitAnchored(): void { + this.emitHighwayHover(); + this.emitClusterHover(); + } + + /** + * Re-project the hovered highway's world anchor to screen and re-emit its summary, so the card + * tracks the highway through a pan / settle. No-op when nothing is hovered. + */ + emitHighwayHover(): void { + if (this.#hoveredHighway === null) { + return; + } + const lane = + this.#dependencies.handle.getStructure()?.highwayLanes[ + this.#hoveredHighway.laneId + ]; + const viewport = this.#viewport(); + if (!lane || lane.count === 0 || !viewport) { + return; + } + const projected = viewport.project([ + this.#hoveredHighway.worldX, + this.#hoveredHighway.worldY, + ]); + const x = projected[0]; + const y = projected[1]; + if (x === undefined || y === undefined) { + return; + } + this.#dependencies.callbacks().onHighwayHover?.({ + typeId: lane.typeId, + typeLabel: lane.typeLabel, + count: lane.count, + direction: lane.direction, + x, + y, + }); + } + + /** + * Re-project the hovered frontier bubble to screen and re-emit its load summary, so the card + * tracks the bubble through a pan / settle. No-op when nothing is hovered or the bubble is no + * longer a wholly-frontier one. + */ + emitClusterHover(): void { + if (this.#hoveredClusterId === null) { + return; + } + const placed = this.#dependencies + .placed() + .find((entry) => entry.cluster.id === this.#hoveredClusterId); + const frontierIds = placed?.cluster.frontierEntityIds; + const viewport = this.#viewport(); + if (!placed || !frontierIds || frontierIds.length === 0 || !viewport) { + return; + } + const projected = viewport.project([placed.x, placed.y]); + const x = projected[0]; + const y = projected[1]; + if (x === undefined || y === undefined) { + return; + } + this.#dependencies.callbacks().onClusterHover?.({ + count: placed.cluster.count, + frontierEntityIds: frontierIds, + x, + y, + radiusPx: placed.cluster.radius * 2 ** this.#dependencies.zoom(), + }); + } + + #viewport() { + return this.#dependencies.deck().getViewports()[0]; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/hub-labels.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/hub-labels.ts new file mode 100644 index 00000000000..3c828e89d76 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/hub-labels.ts @@ -0,0 +1,295 @@ +/** + * Always-on hub labels, overlaid as HTML by React: which dots label is a + * cached eligibility set (rebuilt only on zoom/structure change); each frame + * re-projects that set to screen, culls to the viewport, resolves + * label-box collisions, and emits. + * + * Which dots are eligible is the host's {@link LabelPolicy}: the entity + * lifecycle labels only the biggest hubs (a large graph would otherwise + * drown in text); a type graph labels every node (small, and dots are + * meaningless without their names). + */ +import { + FLAT_HEADER_BYTES, + FLAT_RADIUS_BYTE_OFFSET, + FLAT_RECORD_BYTES, +} from "../../worker/buffers/position-buffer"; +import { radiusForDegree } from "../../worker/entity-style"; +import { liveNodeGeometry } from "./geometry"; + +import type { ClusterId } from "../../ids"; +import type { NodeLabel, SceneCallbacks } from "./callbacks"; +import type { SceneHandle } from "./handle"; +import type { Deck, OrthographicView } from "@deck.gl/core"; + +/** Which dots get an always-on label. See {@link defaultLabelPolicy} for the entity defaults. */ +export interface LabelPolicy { + /** + * Minimum world radius for a dot to be a label candidate. Dot radius is the worker's by-degree + * sizing, so this is the "is it a hub?" cut, expressed in the radius currency the SAB records + * carry. 0 makes every node a candidate. + */ + readonly minRadius: number; + /** Of the eligible candidates, only the largest `maxCount` label (screen-crowding cap). */ + readonly maxCount: number; + /** + * Minimum on-screen dot diameter (px) for its label to be eligible. Under OrthographicView one + * world unit is `2 ** zoom` px, so a dot of `worldRadius` clears this once + * `worldRadius * 2 * 2 ** zoom` exceeds it. 0 labels regardless of zoom. + */ + readonly minScreenDiameter: number; +} + +/** + * The entity lifecycle's policy: only genuine hubs label (radius of at least a 4-degree node, + * the 12 largest, and only once the dot reads at >= 24 px), so the labels orient the view + * without crowding it. Ordinary entities are hover-only (the card). + */ +export const defaultLabelPolicy: LabelPolicy = { + minRadius: radiusForDegree(4), + maxCount: 12, + minScreenDiameter: 24, +}; + +/** + * Off-screen margin (px) for culling HTML node labels: keep a label whose dot sits just past the + * edge (so it shows partially) but drop the rest, so React only renders what's on screen. + */ +const NODE_LABEL_CULL_MARGIN_PX = 80; + +/** Gap (px) between a hub dot's edge and its label, which sits to the dot's right. */ +const NODE_LABEL_GAP_PX = 6; +/** + * Maximum label width (px) used to size its collision box. + * + * @defaultValue 180. Lower values collide (and hide) fewer neighbouring labels but truncate + * long names sooner; higher values show more of the name at the cost of more collisions. + */ +const NODE_LABEL_MAX_WIDTH_PX = 180; +const NODE_LABEL_HEIGHT_PX = 22; +/** + * Approximate glyph width (px) per character, used to estimate label width for collision boxes. + * + * @defaultValue 7. Cheaper than measuring actual text width on the canvas, at the cost of + * collision boxes that are slightly off for unusually wide or narrow glyphs. + */ +const NODE_LABEL_APPROX_CHAR_WIDTH_PX = 7; +const NODE_LABEL_COLLISION_PADDING_PX = 4; + +/** + * Cached label eligibility set. Rebuilt on zoom/structure change; + * each frame projects SAB positions and emits on-screen labels. + */ +interface NodeLabelDatum { + readonly layoutId: ClusterId; + readonly recordIndex: number; + readonly nodeId: NodeId; + readonly text: string; + /** The dot's world radius, so the label can sit just below the dot's edge at any zoom. */ + readonly worldRadius: number; +} + +export interface HubLabelsDependencies< + NodeId extends string, + NodeIndex extends number, + EdgeIndex extends number, +> { + readonly handle: SceneHandle; + readonly deck: () => Deck; + readonly callbacks: () => SceneCallbacks; + readonly zoom: () => number; + readonly labelPolicy: LabelPolicy; +} + +export class HubLabels< + NodeId extends string, + NodeIndex extends number, + EdgeIndex extends number, +> { + readonly #dependencies: HubLabelsDependencies; + + /** Label eligibility + resolved text. Rebuilt on zoom/structure change. */ + #data: NodeLabelDatum[] = []; + #frame: number | null = null; + /** Signature of the last emitted label set; skips callback when screen positions are unchanged. */ + #lastEmittedSignature = ""; + + constructor( + dependencies: HubLabelsDependencies, + ) { + this.#dependencies = dependencies; + } + + dispose(): void { + if (this.#frame !== null) { + cancelAnimationFrame(this.#frame); + } + } + + /** + * Recompute which dots label + their text. O(dots) scan; fires only on + * zoom/structure change (position frames reuse the cached set). Eligibility + * is the host's {@link LabelPolicy} (radius cut, count cap, screen size). + */ + rebuild(): void { + const resolveLabel = this.#dependencies.callbacks().resolveNodeLabel; + const structure = this.#dependencies.handle.getStructure(); + if (resolveLabel === undefined || structure === undefined) { + this.#data = []; + return; + } + const policy = this.#dependencies.labelPolicy; + const scale = 2 ** this.#dependencies.zoom(); + const data: NodeLabelDatum[] = []; + const push = ( + layoutId: ClusterId, + recordIndex: number, + worldRadius: number, + ): void => { + const nodeId = this.#dependencies.handle.resolveNodeId( + layoutId, + recordIndex, + ); + if (nodeId === undefined) { + return; + } + const text = resolveLabel(nodeId); + if (text !== undefined && text.length > 0) { + data.push({ layoutId, recordIndex, nodeId, text, worldRadius }); + } + }; + + // Flat tier: one whole-graph SAB. Each record carries its by-degree radius (the worker's + // connectivity authority). A dot is a candidate when that radius clears the policy's cut + // and it is large enough on screen; of the candidates, only the largest `maxCount` are + // kept. (Ranking by radius, not a main-thread degree tally, is what lets a node enlarged + // by frontier expansion still read as a hub.) + const flatGraph = structure.flatGraph; + if (flatGraph !== undefined) { + const cluster = this.#dependencies.handle + .getClusters() + .get(flatGraph.layoutId); + if (cluster !== undefined) { + const floats = new Float32Array(cluster.versionView.buffer); + const candidates: { index: number; radius: number }[] = []; + for (let index = 0; index < flatGraph.count; index++) { + const recordBase = + (FLAT_HEADER_BYTES + index * FLAT_RECORD_BYTES) / 4; + const radius = floats[recordBase + FLAT_RADIUS_BYTE_OFFSET / 4] ?? 0; + if ( + radius >= policy.minRadius && + (policy.minScreenDiameter === 0 || + radius * 2 * scale > policy.minScreenDiameter) + ) { + candidates.push({ index, radius }); + } + } + candidates.sort((lhs, rhs) => rhs.radius - lhs.radius); + for (const candidate of candidates.slice(0, policy.maxCount)) { + push(flatGraph.layoutId, candidate.index, candidate.radius); + } + } + } + + this.#data = data; + } + + /** + * Project the cached label set to screen and emit the on-screen ones for React to overlay as + * HTML. Called wherever positions change (frame + view change), so the labels track the camera / + * settle. The label set (which dots + text) is not recomputed here (that is the gated + * {@link rebuild}); only positions re-project, bounded by the set. + */ + schedule(): void { + if (this.#frame !== null) { + return; + } + this.#frame = requestAnimationFrame(() => { + this.#frame = null; + this.#emit(); + }); + } + + #emit(): void { + const onLabels = this.#dependencies.callbacks().onNodeLabels; + if (onLabels === undefined) { + return; + } + const viewport = this.#dependencies.deck().getViewports()[0]; + if (!viewport) { + return; + } + const margin = NODE_LABEL_CULL_MARGIN_PX; + const maxX = viewport.width + margin; + const maxY = viewport.height + margin; + const scale = 2 ** this.#dependencies.zoom(); + const labels: NodeLabel[] = []; + const occupied: { + readonly left: number; + readonly right: number; + readonly top: number; + readonly bottom: number; + }[] = []; + for (const datum of this.#data) { + const geometry = liveNodeGeometry( + this.#dependencies.handle, + datum.layoutId, + datum.recordIndex, + ); + if (geometry === null) { + continue; + } + const projected = viewport.project([geometry.x, geometry.y]); + const x = projected[0]; + const y = projected[1]; + if (x === undefined || y === undefined) { + continue; + } + if (x < -margin || y < -margin || x > maxX || y > maxY) { + continue; + } + // Anchor to the right of the dot's edge (its radius scales with zoom), left-aligned and + // vertically centred on the dot in React -- so the label reads "● Name" and its anchor is + // the text start, which holds steady beside the dot as the camera zooms. + const labelX = x + datum.worldRadius * scale + NODE_LABEL_GAP_PX; + const labelWidth = Math.min( + NODE_LABEL_MAX_WIDTH_PX, + datum.text.length * NODE_LABEL_APPROX_CHAR_WIDTH_PX + 14, + ); + const rect = { + left: labelX - NODE_LABEL_COLLISION_PADDING_PX, + right: labelX + labelWidth + NODE_LABEL_COLLISION_PADDING_PX, + top: y - NODE_LABEL_HEIGHT_PX / 2 - NODE_LABEL_COLLISION_PADDING_PX, + bottom: y + NODE_LABEL_HEIGHT_PX / 2 + NODE_LABEL_COLLISION_PADDING_PX, + }; + if ( + occupied.some( + (other) => + rect.left < other.right && + rect.right > other.left && + rect.top < other.bottom && + rect.bottom > other.top, + ) + ) { + continue; + } + occupied.push(rect); + labels.push({ nodeId: datum.nodeId, text: datum.text, x: labelX, y }); + } + // Skip the React setState when the projected label set is unchanged + // (positions rounded to whole pixels so sub-pixel drift is invisible). + const signature = labels + .map( + (label) => + `${label.nodeId}|${Math.round(label.x)}|${Math.round(label.y)}|${ + label.text + }`, + ) + .join(";"); + if (signature === this.#lastEmittedSignature) { + return; + } + this.#lastEmittedSignature = signature; + onLabels(labels); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/interactions.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/interactions.ts new file mode 100644 index 00000000000..6e2d30919fe --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/interactions.ts @@ -0,0 +1,497 @@ +/** + * Click/hover dispatch and selection state: the selected node (ring + camera + * focus + pinned card), a selected flat edge that is a node (a link entity: + * pinned card only), and the ego highlight (dim everything except the + * selection's neighbourhood). Hover-card state lives in {@link HoverTracker}. + * + * Node identity flows through the {@link SceneHandle}, so the same controller + * serves both worker lifecycles; hierarchical-only affordances (highways, + * pins, frontier bubbles) simply never trigger for lifecycles without that + * tier. + * + * Owns no layers. Exposes selection geometry, ego-highlight state, and overlay + * re-projection hooks consumed during layer builds and position/camera updates. + */ +import { liveNodeGeometry, linkMidpoint } from "./geometry"; +import { HoverTracker } from "./hover-tracking"; +import { + isPickableEdgeLayer, + PICKABLE_EDGE_LAYER_IDS, + pickedFlatEdgeId, + pickedHighwayLaneId, + resolvePickedNode, +} from "./picking"; + +import type { ClusterId } from "../../ids"; +import type { PlacedCluster } from "../clusters"; +import type { Selection, SelectionGeometry } from "../selection"; +import type { SceneCallbacks } from "./callbacks"; +import type { SceneHandle } from "./handle"; +import type { Deck, OrthographicView, PickingInfo } from "@deck.gl/core"; + +export interface SceneInteractionsDependencies< + NodeId extends string, + NodeIndex extends number, + EdgeIndex extends number, +> { + readonly handle: SceneHandle; + readonly deck: () => Deck; + readonly callbacks: () => SceneCallbacks; + /** The persistent cluster-bubble set (positions mutate in place per tick). */ + readonly placed: () => readonly PlacedCluster[]; + readonly zoom: () => number; + readonly isDisposed: () => boolean; + /** Trigger a data-layer rebuild when ring or dim state changes outside a position tick. */ + readonly refreshDataLayers: () => void; + readonly focusCamera: (x: number, y: number) => void; + readonly zoomToBubble: (placed: PlacedCluster) => void; +} + +export class SceneInteractions< + NodeId extends string, + NodeIndex extends number, + EdgeIndex extends number, +> { + readonly #dependencies: SceneInteractionsDependencies< + NodeId, + NodeIndex, + EdgeIndex + >; + + readonly #hover: HoverTracker; + + #isDragging = false; + + /** The selected node dot: ring + camera focus + pinned card. Set on click. */ + #selected: Selection | null = null; + /** A selected node-kind flat edge (a link entity, by its bezier edge id): a pinned card + Open, + * no ring/dim (a link isn't a node to focus). Tracked by re-finding its bezier each emit. + * Excludes #selected. */ + #selectedFlatEdge: EdgeIndex | null = null; + + /** The selected node's collapsed-cluster ego neighbours: the only ego targets we ring + keep at + * full colour, since visible node neighbours read as the un-dimmed dots. */ + #egoClusterIds: readonly ClusterId[] = []; + + /** Bumped when the selection/ego changes, to re-evaluate the cluster-bubble focus dim. */ + #highlightTick = 0; + /** + * Monotonic counter bumped at the start of each ego query. {@link #highlightTick} catches up + * when the async result lands; equality means the dim set matches the current selection (see + * {@link queryEgo}). + */ + #highlightVersion = 0; + /** Node keys kept at full colour (selection + visible ego neighbours), used to dim + * leaf-edge lines in step with their endpoint dots. */ + #highlightedNodeKeys: ReadonlySet = new Set(); + + constructor( + dependencies: SceneInteractionsDependencies, + ) { + this.#dependencies = dependencies; + this.#hover = new HoverTracker({ + handle: dependencies.handle, + deck: dependencies.deck, + callbacks: dependencies.callbacks, + placed: dependencies.placed, + zoom: dependencies.zoom, + }); + } + + get highlightTick(): number { + return this.#highlightTick; + } + + get highlightedNodeKeys(): ReadonlySet { + return this.#highlightedNodeKeys; + } + + /** Live geometry of the selected node, for the ring overlay. Null when nothing is selected. */ + selectedGeometry(): SelectionGeometry | null { + if (this.#selected === null) { + return null; + } + return liveNodeGeometry( + this.#dependencies.handle, + this.#selected.layoutId, + this.#selected.localIndex, + ); + } + + /** + * The clusters to keep at full colour while a selection is active (the ego's collapsed- + * neighbor bubbles); null when nothing is selected. Every other leaf bubble recedes. + */ + keepFullClusters(): ReadonlySet | null { + if (this.#selected === null || !this.#isEgoResolved()) { + return null; + } + return new Set(this.#egoClusterIds); + } + + /** A pan begins: hover cards are anchored to moving graph geometry, so hide them. */ + onDragStart(): void { + this.#isDragging = true; + this.#hover.clearAll(); + } + + onDragEnd(): void { + this.#isDragging = false; + } + + /** + * A structure frame landed: the cut changed, so a flat-buffer reorder (or a + * closed leaf) can invalidate the selected index. Keep the selection only + * while it still resolves to the same node, then refresh the ego set. + */ + onStructure(): void { + if ( + this.#selected !== null && + this.#dependencies.handle.resolveNodeId( + this.#selected.layoutId, + this.#selected.localIndex, + ) !== this.#selected.nodeId + ) { + this.#selected = null; + } + this.queryEgo(); + } + + /** Re-project every tracked overlay (positions moved or the camera did). */ + afterPositions(): void { + this.emitSelection(); + this.#hover.emitAnchored(); + } + + handleClick(info: PickingInfo): void { + // Dots render above edges and bubbles, so a dot pick wins outright. Node opening is handled + // by the pinned card's Open action, not this click handler. + const picked = resolvePickedNode(this.#dependencies.handle, info); + if (picked) { + this.#select(picked); + return; + } + + // Click a flat edge: a node-kind edge (a link entity) pins its card (Open -> slideover), no + // ring/dim; an edge-kind edge (a link type) shows its card at the click point. Click a + // hierarchical highway: open a table of the links it aggregates. Edges render under the + // bubbles but win the click over them (#edgePickFor queries the pickable edge layers when + // the topmost pick is a bubble). + const edgeInfo = this.#edgePickFor(info); + if (edgeInfo) { + const edgeId = pickedFlatEdgeId( + this.#dependencies.handle, + edgeInfo, + ); + const edgePick = + edgeId === null + ? null + : this.#dependencies.handle.resolveFlatEdge(edgeId); + + if (edgeId !== null && edgePick?.kind === "node") { + this.#selectFlatEdge(edgeId); + return; + } + + if (edgePick?.kind === "edge") { + this.#hover.setEdge(edgePick, info.x, info.y); + return; + } + + const laneId = pickedHighwayLaneId(this.#dependencies.handle, edgeInfo); + if (laneId !== null) { + this.#openHighwayLinks(laneId); + return; + } + } + + // Cluster bubble: animate so its on-screen radius crosses the open threshold. + // Deck only attaches PlacedCluster to cluster-bubble layer picks. + const placed = info.object as PlacedCluster | undefined; + if (placed?.cluster) { + this.#dependencies.zoomToBubble(placed); + return; + } + + this.#select(null); + } + + handleHover(info: PickingInfo): void { + if (this.#isDragging) { + return; + } + // Node dot (flat graph or an open hierarchical leaf): show its card at the cursor. Dots draw + // on top, so a dot pick wins outright (and clears any highway summary). + const picked = resolvePickedNode(this.#dependencies.handle, info); + if (picked) { + this.#hover.setNode(picked.nodeId, info.x, info.y); + return; + } + // Edges render under the bubbles but still win a hover over them (#edgePickFor). + const edgeInfo = this.#edgePickFor(info); + if (edgeInfo) { + // Flat edge, resolved via the handle: an entity graph's link is itself an entity (node + // card); a type graph's edge is a link type (edge card). + const edgeId = pickedFlatEdgeId( + this.#dependencies.handle, + edgeInfo, + ); + const edgePick = + edgeId === null + ? null + : this.#dependencies.handle.resolveFlatEdge(edgeId); + + if (edgePick?.kind === "node") { + this.#hover.setNode(edgePick.nodeId, info.x, info.y); + return; + } + + if (edgePick?.kind === "edge") { + this.#hover.setEdge(edgePick, info.x, info.y); + return; + } + + // Hierarchical highway: a summary of the links it bundles. + const laneId = pickedHighwayLaneId(this.#dependencies.handle, edgeInfo); + const lane = + laneId === null + ? undefined + : this.#dependencies.handle.getStructure()?.highwayLanes[laneId]; + + if (laneId !== null && lane && lane.count > 0) { + this.#hover.setHighway(laneId, info.x, info.y); + return; + } + } + // Wholly-frontier cluster bubble (no dot/edge over it): offer to load its entities. Anchored to + // the bubble so the load card tracks it through pan / settle. + // Cluster-bubble layer objects are always PlacedCluster. + const placed = info.object as PlacedCluster | undefined; + const frontierIds = placed?.cluster.frontierEntityIds; + if (placed && frontierIds && frontierIds.length > 0) { + this.#hover.setCluster(placed.cluster.id); + return; + } + this.#hover.clearAll(); + } + + /** + * Push the selected node's current screen position to React so the pinned card tracks it + * through settle + pan/zoom; emit null only when nothing is selected (a transient missing + * geometry keeps the last position rather than flickering the card off). + */ + emitSelection(): void { + // The pinned card tracks a selected node (its dot) or a selected link edge (its midpoint). + let world: { x: number; y: number } | null = null; + let nodeId: NodeId | undefined; + if (this.#selected !== null) { + world = this.selectedGeometry(); + nodeId = this.#selected.nodeId; + } else if (this.#selectedFlatEdge !== null) { + const positions = this.#dependencies.handle.getPositions(); + world = positions + ? linkMidpoint(positions, this.#selectedFlatEdge) + : null; + const pick = this.#dependencies.handle.resolveFlatEdge( + this.#selectedFlatEdge, + ); + nodeId = pick?.kind === "node" ? pick.nodeId : undefined; + } + if (world === null || nodeId === undefined) { + // Nothing selected -> clear the card; a transiently missing geometry keeps the last + // position rather than flickering the card off. + if (this.#selected === null && this.#selectedFlatEdge === null) { + this.#dependencies.callbacks().onNodeSelect?.(null); + } + return; + } + const viewport = this.#dependencies.deck().getViewports()[0]; + if (!viewport) { + return; + } + const projected = viewport.project([world.x, world.y]); + const x = projected[0]; + const y = projected[1]; + if (x === undefined || y === undefined) { + return; + } + this.#dependencies.callbacks().onNodeSelect?.({ nodeId, x, y }); + } + + /** + * Fetch + resolve the selected node's visible neighbors for ego-highlight. Async (a worker + * round-trip); a result that lands after the selection changed is dropped. + */ + queryEgo(): void { + const selection = this.#selected; + + if (selection === null) { + this.#dependencies.handle.setHighlight([]); + return; + } + + const nodeKey = this.#dependencies.handle.nodeKeyAt( + selection.layoutId, + selection.localIndex, + ); + + if (nodeKey === undefined) { + // Transient (e.g. the flat buffer reordered mid-resolve); keep the current dim and let + // the next structure frame re-query, rather than flicker it off and back on. + return; + } + + // Bump the version, then capture it as this query's -- the order is load-bearing: capturing + // before the bump leaves it one behind, so the guard below would drop even this query's own + // result. A later query (re-select / structure re-query) bumps again, so a stale in-flight + // result sees currentVersion !== #highlightVersion and drops; only the most recent applies. + this.#highlightVersion += 1; + const currentVersion = this.#highlightVersion; + + void this.#dependencies.handle.queryEgo(nodeKey).then((ego) => { + if (this.#dependencies.isDisposed() || this.#selected !== selection) { + return; + } + + this.#egoClusterIds = ego.clusterIds; + if (currentVersion !== this.#highlightVersion) { + // While waiting for the result, someone else has already updated the highlight version; + // ignore this result and wait for the next structure frame to re-query. + return; + } + + // The dim set: the selected node + its visible neighbours stay full colour; + // collapsed-cluster neighbours are not opened (that would defeat the LOD), just dimmed. + const highlighted = [nodeKey, ...ego.nodeKeys]; + + this.#highlightedNodeKeys = new Set(highlighted); + this.#highlightTick = this.#highlightVersion; + + this.#dependencies.handle.setHighlight(highlighted); + this.#dependencies.refreshDataLayers(); + }); + } + + /** + * The edge pick for a cursor, decoupled from render order. Edges render under the cluster bubbles + * (for the depth-opacity look) but must still win a click/hover over a bubble. If the topmost pick + * is already an edge, use it; if it's a bubble, an edge may sit under it, so query the pickable + * edge layers directly -- deck renders only those layers to the pick buffer, ignoring the bubble + * on top. Returns null over empty space / dots, so the extra pick render happens only when the + * cursor is over a bubble. + */ + #edgePickFor(info: PickingInfo): PickingInfo | null { + if (isPickableEdgeLayer(info.layer?.id)) { + return info; + } + // Only cluster-bubble layer picks carry a PlacedCluster with a .cluster field. + const overBubble = + (info.object as PlacedCluster | undefined)?.cluster !== undefined; + if (!overBubble) { + return null; + } + return this.#dependencies.deck().pickObject({ + x: info.x, + y: info.y, + radius: 4, + layerIds: PICKABLE_EDGE_LAYER_IDS, + }); + } + + /** + * Select a node dot, or clear with null: ring + camera focus + a pinned card. The ring + * is part of the data layers (world-space); the pinned card is React, fed the node's + * tracked screen position via onNodeSelect. + */ + #select(selection: Selection | null): void { + this.#selected = selection; + this.#selectedFlatEdge = null; + + // A selection change un-dims immediately; the focus dim re-applies once the ego query + // resolves -- so it never half-applies mid-resolve, and a re-query on a structure frame + // (same selection) leaves the current dim untouched instead of flashing it off. + this.#egoClusterIds = []; + this.#highlightedNodeKeys = new Set(); + + // Bump highlightTick with highlightVersion so the dim overlay stays stable + // through the in-flight ego re-query (avoids a one-frame undim flash). + this.#highlightVersion += 1; + this.#highlightTick = this.#highlightVersion; + + if (selection) { + const geometry = this.selectedGeometry(); + if (geometry) { + this.#dependencies.focusCamera(geometry.x, geometry.y); + } + } + + this.#pinSelection(selection); + this.queryEgo(); + this.emitSelection(); + this.#dependencies.refreshDataLayers(); + } + + /** + * Select a node-kind flat edge (a link entity): a pinned card (with Open) that tracks the + * edge's midpoint -- no ring/dim/ego (a link isn't a node to focus). Clears any node + * selection first (un-dims). + */ + #selectFlatEdge(edgeId: EdgeIndex): void { + this.#select(null); + this.#selectedFlatEdge = edgeId; + this.emitSelection(); + } + + /** + * Open the link entities aggregated by a highway lane in a table (the slide-stack). Async: + * the worker maps the laneId to its link node keys; we resolve those to node ids. + */ + #openHighwayLinks(laneId: number): void { + const onOpen = this.#dependencies.callbacks().onOpenLinkTable; + if (!onOpen) { + return; + } + + void this.#dependencies.handle + .queryHighwayLinks(laneId) + .then((linkNodeKeys) => { + if (this.#dependencies.isDisposed()) { + return; + } + + const linkNodeIds: NodeId[] = []; + + for (const nodeKey of linkNodeKeys) { + const nodeId = this.#dependencies.handle.nodeKeyToId(nodeKey); + if (nodeId !== undefined) { + linkNodeIds.push(nodeId); + } + } + + if (linkNodeIds.length > 0) { + onOpen(linkNodeIds); + } + }); + } + + /** + * Pin the selected node's leaf open (hierarchical only -- the flat layout has no LOD) so it + * stays visible as you zoom out for a birds-eye view; clear the pin on deselect. + */ + #pinSelection(selection: Selection | null): void { + if (selection === null) { + this.#dependencies.handle.setPinned(null); + return; + } + const cluster = this.#dependencies.handle + .getClusters() + .get(selection.layoutId); + this.#dependencies.handle.setPinned( + cluster && cluster.flatCapacity === undefined ? selection.layoutId : null, + ); + } + + #isEgoResolved(): boolean { + return this.#highlightVersion === this.#highlightTick; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/layer-kinds.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/layer-kinds.ts new file mode 100644 index 00000000000..db806da65a3 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/layer-kinds.ts @@ -0,0 +1,48 @@ +/** + * Coarse layer-kind mapping for the dev harness's GPU-cost bisection: hide + * one kind at a time under a pinned-camera render bench and the fps delta + * attributes the fill cost. Prefix matching covers Deck sublayers, whose + * ids extend their parent's (`flat-entities-circle`, `cluster-labels-...`). + */ + +export type LayerKind = + | "dots" + | "bubbles" + | "edges" + | "icons" + | "labels" + | "other"; + +/** Every kind the harness can toggle ("other" stays always-on: overlays, selection). */ +export const TOGGLEABLE_LAYER_KINDS = [ + "dots", + "bubbles", + "edges", + "icons", + "labels", +] as const satisfies readonly LayerKind[]; + +const KIND_BY_PREFIX: readonly (readonly [string, LayerKind])[] = [ + ["flat-entities", "dots"], + ["entities:", "dots"], + ["flat-bubbles", "bubbles"], + ["clusters", "bubbles"], + ["flat-edges", "edges"], + ["hierarchical-edges", "edges"], + ["internal:", "edges"], + ["fanout:", "edges"], + ["edge-endpoint-arrows", "edges"], + ["edge-lane-chevrons", "edges"], + ["flat-type-icons", "icons"], + ["cluster-labels", "labels"], + ["edge-labels", "labels"], +]; + +export function layerKindOf(layerId: string): LayerKind { + for (const [prefix, kind] of KIND_BY_PREFIX) { + if (layerId.startsWith(prefix)) { + return kind; + } + } + return "other"; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/node-icons.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/node-icons.test.ts new file mode 100644 index 00000000000..a2aa03e6bce --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/node-icons.test.ts @@ -0,0 +1,200 @@ +/** + * Guards for the incremental icon scan (R2): a grow-only flat structure frame + * must resolve icons only for the added record range, an unchanged frame must + * cost zero resolver calls (and keep the version stable), and a shrink / + * tier change must fall back to a full rescan. Leaf caches reuse by `nodeIds` + * identity. + */ +import { describe, expect, it, vi } from "vitest"; + +import { ClusterId, type EntityIndex } from "../../ids"; +import { NodeIcons } from "./node-icons"; + +import type { StructureFrame } from "../../frames"; +import type { ClusterReference } from "../frame-connection"; +import type { IconAtlas } from "../gpu/icon-atlas"; +import type { SceneCallbacks } from "./callbacks"; +import type { SceneHandle } from "./handle"; +import type { EntityId } from "@blockprotocol/type-system"; + +const FLAT_ID = ClusterId("flat:all"); +const LEAF_ID = ClusterId("cluster:leaf-1"); + +function flatFrame(count: number): StructureFrame { + return { + version: 1, + mode: "community-force", + clusters: [], + entityLayers: [], + flatGraph: { layoutId: FLAT_ID, count }, + highwayLanes: [], + } as unknown as StructureFrame; +} + +function leafFrame( + layers: readonly { layoutId: ClusterId; count: number }[], +): StructureFrame { + return { + version: 1, + mode: "hierarchical-lod", + clusters: [], + entityLayers: layers, + highwayLanes: [], + } as unknown as StructureFrame; +} + +function clusterRef(nodeIds: readonly string[]): ClusterReference { + return { nodeIds } as unknown as ClusterReference; +} + +interface Harness { + readonly icons: NodeIcons; + readonly clusters: Map; + readonly resolveCalls: () => number; + setStructure(frame: StructureFrame | undefined): void; + ensuredKeys(): string[][]; +} + +function newHarness(): Harness { + let structure: StructureFrame | undefined; + const clusters = new Map(); + const resolveNodeIcon = vi.fn((entityId: EntityId): string | null => + entityId.endsWith("0") ? null : `icon:${entityId}`, + ); + const ensureIcons = vi.fn(); + + const handle = { + getStructure: () => structure, + getClusters: () => clusters, + resolveNodeId: (layoutId: ClusterId, index: number) => + `${layoutId}/${index}` as EntityId, + } as unknown as SceneHandle; + + const icons = new NodeIcons({ + handle, + callbacks: () => + ({ resolveNodeIcon }) as unknown as SceneCallbacks, + iconAtlas: { ensureIcons } as unknown as IconAtlas, + }); + + return { + icons, + clusters, + resolveCalls: () => resolveNodeIcon.mock.calls.length, + setStructure(frame) { + structure = frame; + }, + ensuredKeys: () => ensureIcons.mock.calls.map(([keys]) => keys as string[]), + }; +} + +describe("NodeIcons, incremental flat scan", () => { + it("resolves only the added range on a grow-only structure frame", () => { + const harness = newHarness(); + harness.clusters.set(FLAT_ID, clusterRef([])); + + harness.setStructure(flatFrame(100)); + harness.icons.rebuild(); + expect(harness.resolveCalls()).toBe(100); + expect(harness.icons.flatNames).toHaveLength(100); + const versionAfterBuild = harness.icons.version; + + // Streamed growth: 100 -> 150 must resolve exactly the 50 new records. + harness.setStructure(flatFrame(150)); + harness.icons.rebuild(); + expect(harness.resolveCalls()).toBe(150); + expect(harness.icons.flatNames).toHaveLength(150); + expect(harness.icons.flatScannedCount).toBe(150); + expect(harness.icons.version).toBe(versionAfterBuild + 1); + + // Confirms the incremental scan neither corrupts the retained prefix nor skips the new tail. + expect(harness.icons.flatNames[0]).toBe(null); // ".../0" resolves null + expect(harness.icons.flatNames[1]).toBe(`icon:${FLAT_ID}/1`); + expect(harness.icons.flatNames[149]).toBe(`icon:${FLAT_ID}/149`); + }); + + it("does nothing on a frame with an unchanged count (e.g. a Louvain-only refresh)", () => { + const harness = newHarness(); + harness.clusters.set(FLAT_ID, clusterRef([])); + + harness.setStructure(flatFrame(80)); + harness.icons.rebuild(); + const version = harness.icons.version; + const names = harness.icons.flatNames; + + harness.icons.rebuild(); + expect(harness.resolveCalls()).toBe(80); + expect(harness.icons.version).toBe(version); + expect(harness.icons.flatNames).toBe(names); + }); + + it("rescans fully on a shrink (defensive: add-only stores cannot shrink)", () => { + const harness = newHarness(); + harness.clusters.set(FLAT_ID, clusterRef([])); + + harness.setStructure(flatFrame(100)); + harness.icons.rebuild(); + + harness.setStructure(flatFrame(90)); + harness.icons.rebuild(); + expect(harness.resolveCalls()).toBe(190); + expect(harness.icons.flatNames).toHaveLength(90); + expect(harness.icons.flatScannedCount).toBe(90); + }); + + it("clears the flat cache when the tier leaves flat mode, and rescans on return", () => { + const harness = newHarness(); + harness.clusters.set(FLAT_ID, clusterRef([])); + + harness.setStructure(flatFrame(60)); + harness.icons.rebuild(); + expect(harness.icons.flatNames).toHaveLength(60); + + harness.setStructure(leafFrame([])); + harness.icons.rebuild(); + expect(harness.icons.flatNames).toHaveLength(0); + + harness.setStructure(flatFrame(60)); + harness.icons.rebuild(); + expect(harness.resolveCalls()).toBe(120); + expect(harness.icons.flatNames).toHaveLength(60); + }); + + it("only sends the added range's keys to the atlas on an incremental scan", () => { + const harness = newHarness(); + harness.clusters.set(FLAT_ID, clusterRef([])); + + harness.setStructure(flatFrame(3)); + harness.icons.rebuild(); + harness.setStructure(flatFrame(5)); + harness.icons.rebuild(); + + const [firstKeys, secondKeys] = harness.ensuredKeys(); + expect(firstKeys).toEqual([`icon:${FLAT_ID}/1`, `icon:${FLAT_ID}/2`]); + expect(secondKeys).toEqual([`icon:${FLAT_ID}/3`, `icon:${FLAT_ID}/4`]); + }); +}); + +describe("NodeIcons, leaf caches", () => { + it("reuses a leaf's keys while its nodeIds identity holds, rescans when it changes", () => { + const harness = newHarness(); + const firstNodeIds = ["1", "2", "3"]; + harness.clusters.set(LEAF_ID, clusterRef(firstNodeIds)); + harness.setStructure(leafFrame([{ layoutId: LEAF_ID, count: 3 }])); + + harness.icons.rebuild(); + expect(harness.resolveCalls()).toBe(3); + const cached = harness.icons.leafNames.get(LEAF_ID); + + // Same identity (e.g. a growth republish kept nodeIds): reuse, no rescan. + harness.icons.rebuild(); + expect(harness.resolveCalls()).toBe(3); + expect(harness.icons.leafNames.get(LEAF_ID)).toBe(cached); + + // A leaf layout rebuild adopts fresh nodeIds: same count, different membership. + harness.clusters.set(LEAF_ID, clusterRef(["1", "2", "4"])); + harness.icons.rebuild(); + expect(harness.resolveCalls()).toBe(6); + expect(harness.icons.leafNames.get(LEAF_ID)).not.toBe(cached); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/node-icons.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/node-icons.ts new file mode 100644 index 00000000000..a62c2152e50 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/node-icons.ts @@ -0,0 +1,254 @@ +/** + * Per-dot type-icon atlas keys for both tiers, index-aligned with each + * layout's render records. Fires only on a structure/resolver change, and the + * scan is incremental on the streaming path: the flat SAB is append-only + * (see below), so a structure frame that only grew resolves icons for the + * added tail `[prevCount, count)` and keeps the cached prefix. The icon + * layers read the cached arrays (keyed by a version) every layer build. + * + * Node identity flows through the {@link SceneHandle}, so both worker + * lifecycles share this scan (the icon resolver just answers by its own + * NodeId currency). + * + * Why the flat prefix is reusable: flat records are ordered by ascending + * join key in every code path. The entity tier seeds from the sorted + * `snapshotNodeEntityIdxs()` column and appends newcomers (interner indices + * are monotonic, so they sort after every existing record); the type tier + * seeds from the interner's id order the same way. Stores are add-only, so + * record `i` maps to the same node forever; a full rescan happens only on a + * shrink (defensive; add-only stores cannot shrink) or when the flat layout + * disappears (tier change). + * + * Cached keys are kept across resolver identity changes: the entity bridge + * recreates the resolver on every appended page, and treating that as an + * invalidation would re-trigger the full O(dots) rescan per batch this class + * exists to avoid. Both streaming flows land a node and its type context + * in the bridge before the worker's structure frame for it returns, so a + * prefix `null` is a genuine no-icon answer, not a not-yet-known one. + * + * A cached key is the icon of the node's type set as the bridge captured + * it when the record was first scanned. Type sets are editable in-product + * (clicking a dot opens the entity editor in the slide stack), but an edit + * does not reach an open graph: subgraph responses are unnormalized scalars + * Apollo cannot update in place, no graph host wires an entity edit to a + * refetch, and the worker pins type-derived grouping and dot colour at first + * ingest, ignoring re-sent entities. Icon staleness is that same snapshot + * contract, not an icon-specific carve-out; a full rescan (tier change or + * remount) re-resolves icons while colours never refresh, so a dot's icon is + * never staler than its colour. A bridge capture can still be replaced + * mid-session (a later frontier expansion re-fetching an expansion-held + * neighbour); hover cards and hub labels read the fresh copy on their next + * build while icon and colour keep ingest state. Rescanning icons on such a + * refresh would only desync them from colour, so showing type edits live is + * an upstream product decision (refetch plus worker restyle), not a cache + * policy here. + * + * Hierarchical leaves (entity lifecycle only) have no append-only guarantee + * (group re-targeting can change a leaf's membership), so a leaf's cached + * array is reused only while the leaf's `nodeIds` identity is unchanged. + * Every leaf layout (re)build adopts a fresh `nodeIds` via LAYOUT_CREATED, + * while a growth republish keeps it. + */ +import type { ClusterId } from "../../ids"; +import type { IconAtlas } from "../gpu/icon-atlas"; +import type { SceneCallbacks } from "./callbacks"; +import type { SceneHandle } from "./handle"; + +export interface NodeIconsDependencies< + NodeId extends string, + NodeIndex extends number, + EdgeIndex extends number, +> { + readonly handle: SceneHandle; + readonly callbacks: () => SceneCallbacks; + readonly iconAtlas: IconAtlas; +} + +export class NodeIcons< + NodeId extends string, + NodeIndex extends number, + EdgeIndex extends number, +> { + readonly #dependencies: NodeIconsDependencies; + + /** Flat-tier per-render-index icon atlas key. Extended in place on grow-only frames. */ + #flatNames: (string | null)[] = []; + /** Records already resolved into {@link #flatNames} (the reusable prefix). */ + #flatScannedCount = 0; + #flatLayoutId: ClusterId | undefined; + /** Hierarchical per-leaf icon atlas keys. Shares version with {@link #flatNames}. */ + #leafNames = new Map(); + /** Per-leaf `nodeIds` identity the cached array was resolved against. */ + #leafSourceNodeIds = new Map(); + #version = 0; + + constructor( + dependencies: NodeIconsDependencies, + ) { + this.#dependencies = dependencies; + } + + get flatNames(): (string | null)[] { + return this.#flatNames; + } + + get leafNames(): Map { + return this.#leafNames; + } + + get version(): number { + return this.#version; + } + + /** Number of flat-tier records whose icon keys have been resolved (the reusable prefix length). */ + get flatScannedCount(): number { + return this.#flatScannedCount; + } + + /** + * Recompute per-render-index icon atlas keys for both tiers and ensure they + * are rasterised. No zoom gate: the IconLayer's soft-LOD sizing handles + * small dots. Bumps {@link version} only when a cached array actually + * changed, so an unchanged frame (e.g. a communities-only Louvain refresh) + * costs no resolver calls and no icon-attribute regeneration. + */ + rebuild(): void { + const resolveIcon = this.#dependencies.callbacks().resolveNodeIcon; + const structure = this.#dependencies.handle.getStructure(); + + if (resolveIcon === undefined || structure === undefined) { + if (this.#flatNames.length > 0 || this.#leafNames.size > 0) { + this.#version += 1; + } + + this.#flatNames = []; + this.#flatScannedCount = 0; + this.#flatLayoutId = undefined; + this.#leafNames = new Map(); + this.#leafSourceNodeIds = new Map(); + return; + } + + const keys = new Set(); + + // Only the tail [from, to) is scanned; the prefix is index-aligned with the layout SAB. + const scanRange = ( + layoutId: ClusterId, + from: number, + to: number, + ): (string | null)[] => { + const scanned: (string | null)[] = []; + + for (let index = from; index < to; index++) { + let name: string | null = null; + const nodeId = this.#dependencies.handle.resolveNodeId(layoutId, index); + + if (nodeId !== undefined) { + const key = resolveIcon(nodeId); + + if (key !== null && key.length > 0) { + name = key; + keys.add(key); + } + } + + scanned.push(name); + } + + return scanned; + }; + + let changed = false; + + // Flat tier: one whole-graph SAB, append-only record order (see header). + const flatGraph = structure.flatGraph; + const flatUsable = + flatGraph !== undefined && + this.#dependencies.handle.getClusters().get(flatGraph.layoutId) !== + undefined; + + if (!flatUsable) { + if (this.#flatNames.length > 0) { + changed = true; + } + + this.#flatNames = []; + this.#flatScannedCount = 0; + this.#flatLayoutId = undefined; + } else { + const prefixReusable = + this.#flatLayoutId === flatGraph.layoutId && + flatGraph.count >= this.#flatScannedCount; + + if (!prefixReusable) { + this.#flatNames = []; + this.#flatScannedCount = 0; + } + + if (flatGraph.count !== this.#flatScannedCount) { + changed = true; + + for (const name of scanRange( + flatGraph.layoutId, + this.#flatScannedCount, + flatGraph.count, + )) { + this.#flatNames.push(name); + } + + this.#flatScannedCount = flatGraph.count; + } + + this.#flatLayoutId = flatGraph.layoutId; + } + + // Hierarchical tier: one SAB per open leaf. A leaf's cached keys stay + // valid while its nodeIds identity holds (no membership change). + const leafNames = new Map(); + const leafSourceNodeIds = new Map(); + + for (const layer of structure.entityLayers) { + const cluster = this.#dependencies.handle + .getClusters() + .get(layer.layoutId); + + if (cluster === undefined) { + continue; + } + + const cached = this.#leafNames.get(layer.layoutId); + const cacheValid = + cached !== undefined && + cached.length === layer.count && + this.#leafSourceNodeIds.get(layer.layoutId) === cluster.nodeIds; + + if (cacheValid) { + leafNames.set(layer.layoutId, cached); + } else { + leafNames.set( + layer.layoutId, + scanRange(layer.layoutId, 0, layer.count), + ); + + changed = true; + } + leafSourceNodeIds.set(layer.layoutId, cluster.nodeIds); + } + + if (leafNames.size !== this.#leafNames.size) { + changed = true; + } + + this.#leafNames = leafNames; + this.#leafSourceNodeIds = leafSourceNodeIds; + + if (changed) { + this.#version += 1; + // Rasterise any not-yet-known icons; emoji land synchronously, URLs resolve async and bump + // the atlas version + re-push on load (so a still-loading icon is simply absent, then + // appears). Incremental scans only collect the added range's keys; earlier keys are + // already rasterised (ensureIcons is idempotent). + this.#dependencies.iconAtlas.ensureIcons([...keys]); + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/picking.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/picking.ts new file mode 100644 index 00000000000..44cb55ce76a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/picking.ts @@ -0,0 +1,114 @@ +/** + * Pick resolution: mapping Deck picks to node dots, flat-tier edges, and + * highway lanes, decoupled from render order (edges render under the bubbles + * but still win a click/hover over them). Node identity is resolved through + * the {@link SceneHandle}, so the same paths serve both worker lifecycles. + */ +import { BEZIER_NO_LINK } from "../../frames"; + +import type { ClusterId } from "../../ids"; +import type { FrameHandle } from "../frame-connection"; +import type { Selection } from "../selection"; +import type { SceneHandle } from "./handle"; +import type { PickingInfo } from "@deck.gl/core"; + +export const FLAT_EDGE_LAYER_ID = "flat-edges"; +export const HIERARCHICAL_EDGE_LAYER_ID = "hierarchical-edges"; +// Mutable string[] (not `as const`) so it can be passed to deck's pickObject +// without a defensive copy on every hover. +export const PICKABLE_EDGE_LAYER_IDS: string[] = [ + FLAT_EDGE_LAYER_ID, + HIERARCHICAL_EDGE_LAYER_ID, +]; + +export function isPickableEdgeLayer(layerId: string | undefined): boolean { + return ( + layerId === FLAT_EDGE_LAYER_ID || layerId === HIERARCHICAL_EDGE_LAYER_ID + ); +} + +function isFlatMode(handle: FrameHandle): boolean { + return handle.getStructure()?.flatGraph !== undefined; +} + +/** + * Map a pick on any node-dot layer to a selection (the node + the buffer/index it + * resolved from, needed to read its live position). The flat tier is one whole-graph layer + * ("flat-entities"); the hierarchical tier is one layer per open leaf, id + * "entities:". The pick index maps to a render record in that layout's buffer. + */ +export function resolvePickedNode< + NodeId extends string, + NodeIndex extends number, + EdgeIndex extends number, +>( + handle: SceneHandle, + info: PickingInfo, +): Selection | null { + const layerId = info.layer?.id; + if (layerId === undefined || info.index < 0) { + return null; + } + const structure = handle.getStructure(); + if (!structure) { + return null; + } + let layoutId: ClusterId | undefined; + if (layerId === "flat-entities") { + layoutId = structure.flatGraph?.layoutId; + } else if (layerId.startsWith("entities:")) { + layoutId = structure.entityLayers.find( + (entry) => `entities:${entry.layoutId}` === layerId, + )?.layoutId; + } + if (layoutId === undefined) { + return null; + } + const nodeId = handle.resolveNodeId(layoutId, info.index); + return nodeId === undefined + ? null + : { nodeId, layoutId, localIndex: info.index }; +} + +/** + * The edge id for a pick on a flat-tier edge (there beziers.ids carries the edge's identity: + * the link's EntityIdx in the entity lifecycle, the edge-table index in the type lifecycle; + * resolve it via {@link SceneHandle.resolveFlatEdge}). Null otherwise -- including the + * hierarchical tier, where the same channel carries an aggregate laneId instead (see + * {@link pickedHighwayLaneId}). + */ +export function pickedFlatEdgeId( + handle: FrameHandle, + info: PickingInfo, +): EdgeIndex | null { + if ( + info.layer?.id !== FLAT_EDGE_LAYER_ID || + info.index < 0 || + !isFlatMode(handle) + ) { + return null; + } + const id = handle.getPositions()?.beziers.ids[info.index]; + // BEZIER_NO_LINK is the only non-edge sentinel in the flat-tier id channel. + // + return id === undefined || id === BEZIER_NO_LINK ? null : (id as EdgeIndex); +} + +/** + * The aggregate lane id for a pick on a hierarchical-tier highway (there beziers.ids carries + * the laneId). Null otherwise (flat tier / not an edge / the BEZIER_NO_LINK sentinel). + */ +export function pickedHighwayLaneId( + handle: FrameHandle, + info: PickingInfo, +): number | null { + if ( + info.layer?.id !== HIERARCHICAL_EDGE_LAYER_ID || + info.index < 0 || + isFlatMode(handle) + ) { + return null; + } + const id = handle.getPositions()?.beziers.ids[info.index]; + return id === undefined || id === BEZIER_NO_LINK ? null : id; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/render-metrics.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/render-metrics.test.ts new file mode 100644 index 00000000000..02f84b7cd00 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/render-metrics.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, it } from "vitest"; + +import { RenderMetricsProbe } from "./render-metrics"; + +import type { DeckMetrics, FrameLoop, LongTaskSource } from "./render-metrics"; + +/** Manual frame loop: the test fires rAF callbacks with explicit timestamps. */ +function manualFrameLoop(): FrameLoop & { + fire: (timestampMs: number) => void; + cancelled: number[]; +} { + let nextHandle = 1; + let pending: { + handle: number; + callback: (timestampMs: number) => void; + } | null = null; + const cancelled: number[] = []; + return { + schedule: (callback) => { + const handle = nextHandle; + nextHandle += 1; + pending = { handle, callback }; + return handle; + }, + cancel: (handle) => { + cancelled.push(handle); + if (pending?.handle === handle) { + pending = null; + } + }, + fire: (timestampMs) => { + const current = pending; + pending = null; + current?.callback(timestampMs); + }, + cancelled, + }; +} + +const deckSample = (overrides: Partial): DeckMetrics => ({ + fps: 60, + setPropsTime: 0, + layersCount: 0, + drawLayersCount: 0, + updateLayersCount: 0, + updateAttributesTime: 0, + updateAttributesCount: 0, + framesRedrawn: 0, + pickTime: 0, + pickCount: 0, + pickLayersCount: 0, + gpuTime: 0, + gpuTimePerFrame: 0, + cpuTime: 0, + cpuTimePerFrame: 0, + bufferMemory: 0, + textureMemory: 0, + renderbufferMemory: 0, + gpuMemory: 0, + ...overrides, +}); + +function probeAt(times: number[]): RenderMetricsProbe { + return new RenderMetricsProbe({ clock: () => times.shift() ?? 0 }); +} + +/** Manual long-task source: the test emits entries directly. */ +function manualLongTaskSource(): LongTaskSource & { + emit: (startTimeMs: number, durationMs: number) => void; + disconnected: () => boolean; +} { + let deliver: ((startTimeMs: number, durationMs: number) => void) | null = + null; + return { + observe: (onEntry) => { + deliver = onEntry; + return () => { + deliver = null; + }; + }, + emit: (startTimeMs, durationMs) => { + deliver?.(startTimeMs, durationMs); + }, + disconnected: () => deliver === null, + }; +} + +describe("RenderMetricsProbe", () => { + it("summarises rebuild spans with mean, p95 and max", () => { + const probe = probeAt([0, 10_000]); + probe.start(0); + // 1..20ms: p95 (nearest rank of 20 values) = 19, max = 20, mean = 10.5. + for (let elapsed = 1; elapsed <= 20; elapsed++) { + probe.recordRebuild(elapsed); + } + + const report = probe.stop(0); + + expect(report.durationMs).toBe(10_000); + expect(report.rebuild).toEqual({ + count: 20, + meanMs: 10.5, + p95Ms: 19, + maxMs: 20, + }); + }); + + it("averages deck samples and totals redrawn frames", () => { + const probe = probeAt([0, 2000]); + probe.start(0); + probe.sampleDeckMetrics( + deckSample({ fps: 60, cpuTimePerFrame: 4, framesRedrawn: 60 }), + ); + probe.sampleDeckMetrics( + deckSample({ fps: 30, cpuTimePerFrame: 8, framesRedrawn: 30 }), + ); + + const report = probe.stop(0); + + expect(report.deck.samples).toBe(2); + expect(report.deck.fps).toBe(45); + expect(report.deck.cpuTimePerFrame).toBe(6); + expect(report.deck.framesRedrawn).toBe(90); + }); + + it("snapshots deck metrics objects (deck mutates one instance in place)", () => { + const probe = probeAt([0, 1000]); + probe.start(0); + const reused = deckSample({ fps: 60 }); + probe.sampleDeckMetrics(reused); + // Deck updates the same object for the next second. + reused.fps = 1; + probe.sampleDeckMetrics(reused); + + const report = probe.stop(0); + + expect(report.deck.fps).toBe((60 + 1) / 2); + }); + + it("ignores records outside a capture window", () => { + const probe = probeAt([0, 1000]); + probe.recordRebuild(50); + probe.sampleDeckMetrics(deckSample({ fps: 10 })); + + probe.start(0); + const report = probe.stop(0); + + expect(report.rebuild.count).toBe(0); + expect(report.deck.samples).toBe(0); + expect(report.rebuild.meanMs).toBe(0); + }); + + it("resets accumulated data on restart", () => { + const probe = probeAt([0, 1000, 2000, 3000]); + probe.start(0); + probe.recordRebuild(5); + probe.stop(0); + + probe.start(0); + const report = probe.stop(0); + + expect(report.rebuild.count).toBe(0); + }); + + it("tracks the zoom envelope from start through stop", () => { + const probe = probeAt([0, 1000]); + probe.start(2); + probe.recordZoom(1.5); + probe.recordZoom(3); + + const report = probe.stop(2.5); + + expect(report.camera).toEqual({ + initialZoom: 2, + finalZoom: 2.5, + minZoom: 1.5, + maxZoom: 3, + }); + }); + + it("resets the zoom envelope on restart", () => { + const probe = probeAt([0, 1000, 2000, 3000]); + probe.start(0); + probe.recordZoom(-4); + probe.stop(0); + + probe.start(1); + const report = probe.stop(1); + + expect(report.camera).toEqual({ + initialZoom: 1, + finalZoom: 1, + minZoom: 1, + maxZoom: 1, + }); + }); + + it("summarises rAF frame intervals and counts hitches", () => { + const loop = manualFrameLoop(); + const probe = new RenderMetricsProbe({ clock: () => 0, frameLoop: loop }); + probe.start(0); + + // Timestamps: steady 16.7ms cadence with one 100ms stall in the middle. + // Intervals: [16.7, 16.7, 100, 16.7] -> one hitch, max 100. + for (const timestamp of [0, 16.7, 33.4, 133.4, 150.1]) { + loop.fire(timestamp); + } + + const report = probe.stop(0); + + expect(report.frames.count).toBe(4); + expect(report.frames.hitchCount).toBe(1); + expect(report.frames.maxMs).toBeCloseTo(100, 5); + expect(report.frames.p50Ms).toBeCloseTo(16.7, 5); + expect(report.frames.p99Ms).toBeCloseTo(100, 5); + expect(report.frames.meanMs).toBeCloseTo((16.7 * 3 + 100) / 4, 5); + }); + + it("lists the worst hitches with capture-relative end timestamps", () => { + const loop = manualFrameLoop(); + // Capture starts at clock 1000; rAF timestamps share that timeline. + const probe = new RenderMetricsProbe({ + clock: () => 1000, + frameLoop: loop, + }); + probe.start(0); + + // Intervals: [50, 16, 80, 16] ending at 1050, 1066, 1146, 1162. + for (const timestamp of [1000, 1050, 1066, 1146, 1162]) { + loop.fire(timestamp); + } + + const report = probe.stop(0); + + expect(report.frames.hitchCount).toBe(2); + expect(report.frames.worst).toEqual([ + { atMs: 146, durationMs: 80 }, + { atMs: 50, durationMs: 50 }, + ]); + }); + + it("stops the frame loop on stop and resets intervals on restart", () => { + const loop = manualFrameLoop(); + const probe = new RenderMetricsProbe({ clock: () => 0, frameLoop: loop }); + probe.start(0); + loop.fire(0); + loop.fire(20); + probe.stop(0); + + // The pending callback was cancelled; a late fire records nothing. + expect(loop.cancelled.length).toBe(1); + loop.fire(1000); + + probe.start(0); + const report = probe.stop(0); + expect(report.frames.count).toBe(0); + }); + + it("reports zero frame stats without a frame loop (non-browser)", () => { + const probe = new RenderMetricsProbe({ clock: () => 0, frameLoop: null }); + probe.start(0); + + const report = probe.stop(0); + + expect(report.frames).toEqual({ + count: 0, + meanMs: 0, + p50Ms: 0, + p95Ms: 0, + p99Ms: 0, + maxMs: 0, + hitchCount: 0, + worst: [], + }); + expect(report.longTasks).toEqual({ + available: false, + count: 0, + totalMs: 0, + maxMs: 0, + worst: [], + }); + expect(report.gpu).toEqual({ + available: false, + samples: 0, + disjointCount: 0, + meanMs: 0, + p50Ms: 0, + p95Ms: 0, + maxMs: 0, + worst: [], + }); + }); + + it("summarises GPU frame samples capture-relative with disjoints", () => { + const probe = new RenderMetricsProbe({ clock: () => 3000 }); + probe.start(0); + probe.noteGpuAvailability(true); + + // Submit times on the shared timeline (capture start = 3000). + probe.recordGpuFrame(3100, 4); + probe.recordGpuFrame(3200, 80); + probe.recordGpuFrame(3300, 6); + probe.recordGpuDisjoint(); + + const report = probe.stop(0); + + expect(report.gpu.available).toBe(true); + expect(report.gpu.samples).toBe(3); + expect(report.gpu.disjointCount).toBe(1); + expect(report.gpu.meanMs).toBe(30); + expect(report.gpu.p50Ms).toBe(6); + expect(report.gpu.maxMs).toBe(80); + expect(report.gpu.worst[0]).toEqual({ atMs: 200, durationMs: 80 }); + }); + + it("drops GPU queries submitted before the capture started", () => { + const probe = new RenderMetricsProbe({ clock: () => 3000 }); + probe.start(0); + probe.noteGpuAvailability(true); + + // A query left in flight by an earlier session, delivered on this + // capture's first poll: its submit time predates the capture. + probe.recordGpuFrame(2000, 12); + probe.recordGpuFrame(3100, 4); + + const report = probe.stop(0); + + expect(report.gpu.samples).toBe(1); + expect(report.gpu.worst[0]).toEqual({ atMs: 100, durationMs: 4 }); + }); + + it("ignores GPU records outside a capture window and resets on restart", () => { + const probe = new RenderMetricsProbe({ clock: () => 0 }); + probe.recordGpuFrame(10, 5); + probe.noteGpuAvailability(true); + + probe.start(0); + probe.noteGpuAvailability(true); + probe.recordGpuFrame(1, 5); + probe.stop(0); + + probe.start(0); + const report = probe.stop(0); + + expect(report.gpu.samples).toBe(0); + expect(report.gpu.available).toBe(false); + }); + + it("summarises long tasks capture-relative and disconnects on stop", () => { + const source = manualLongTaskSource(); + const probe = new RenderMetricsProbe({ + clock: () => 2000, + longTaskSource: source, + }); + probe.start(0); + + // Entries arrive on the shared performance timeline (start = 2000). + source.emit(2100, 60); + source.emit(2500, 130); + + const report = probe.stop(0); + + expect(report.longTasks.available).toBe(true); + expect(report.longTasks.count).toBe(2); + expect(report.longTasks.totalMs).toBe(190); + expect(report.longTasks.maxMs).toBe(130); + expect(report.longTasks.worst).toEqual([ + { atMs: 500, durationMs: 130 }, + { atMs: 100, durationMs: 60 }, + ]); + expect(source.disconnected()).toBe(true); + }); + + it("ignores long tasks delivered outside a capture window", () => { + const source = manualLongTaskSource(); + const probe = new RenderMetricsProbe({ + clock: () => 0, + longTaskSource: source, + }); + probe.start(0); + probe.stop(0); + + // The observer callback may straggle after disconnect; nothing records. + source.emit(10, 60); + + probe.start(0); + const report = probe.stop(0); + expect(report.longTasks.count).toBe(0); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/render-metrics.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/render-metrics.ts new file mode 100644 index 00000000000..b0a3b24fd2d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/render-metrics.ts @@ -0,0 +1,479 @@ +/** + * Render-cost capture for the scene: Deck's once-per-second stats plus our + * own timing of each layer rebuild span, summarised into one report. + * + * A rebuild span covers everything the scene does synchronously to refresh + * the layer set: layer construction, label rebuild, and the `setProps` call. + * It deliberately does not stop at `setProps` -- that call only stores props + * and schedules a redraw (measured at ~microseconds), while Deck performs + * the deferred diffing and attribute regeneration inside its next draw, + * which surfaces in `updateAttributesTime` / `cpuTimePerFrame` here. + * Recording is gated on {@link capturing} so an idle probe costs one branch + * per rebuild. + * + * While capturing, the probe also runs its own `requestAnimationFrame` loop + * and records the interval between consecutive callbacks. A mean fps over + * the whole window hides exactly the thing a user feels -- isolated long + * frames -- so the report carries the interval distribution (p50/p95/p99/max) + * and a count of hitches (intervals above {@link HITCH_THRESHOLD_MS}). + * + * For attribution, the report also lists the worst hitches WITH their + * capture-relative timestamps, alongside the browser's `longtask` entries + * over the same timeline. A hitch that coincides with a long task is + * main-thread JS (GC, attribute regeneration, a slow pack); a hitch with no + * long task under it points off-thread (GPU/compositor back-pressure). + */ +import type { DeckProps } from "@deck.gl/core"; + +/** Deck's per-second stats object (not re-exported from the package root). */ +export type DeckMetrics = Parameters>[0]; + +export interface RebuildStats { + readonly count: number; + readonly meanMs: number; + readonly p95Ms: number; + readonly maxMs: number; +} + +/** + * A frame interval at least this long (two 60 Hz vsync periods, i.e. at + * least one whole missed frame) reads as a felt hitch. + */ +export const HITCH_THRESHOLD_MS = 33.4; + +/** How many of the worst hitches / long tasks the report lists individually. */ +const WORST_SPAN_LIMIT = 5; + +/** One attributable stall: when it happened (capture-relative) and how long. */ +export interface TimedSpan { + readonly atMs: number; + readonly durationMs: number; +} + +/** Main-thread frame cadence over the capture (rAF inter-frame intervals). */ +export interface FrameStats { + /** Intervals observed (one fewer than rAF callbacks delivered). */ + readonly count: number; + readonly meanMs: number; + readonly p50Ms: number; + readonly p95Ms: number; + readonly p99Ms: number; + readonly maxMs: number; + /** Intervals above {@link HITCH_THRESHOLD_MS} -- the "felt" stalls. */ + readonly hitchCount: number; + /** + * The worst hitches (up to {@link WORST_SPAN_LIMIT}, longest first). + * `atMs` is when the long frame ENDED, relative to capture start -- + * line these up with `longTasks.worst` to attribute them. + */ + readonly worst: readonly TimedSpan[]; +} + +/** + * Browser `longtask` performance entries observed during the capture: + * main-thread tasks over 50 ms. Chromium-only — Firefox and Safari have + * never shipped the Long Tasks API, so `available` distinguishes "the API + * is missing" from the meaningful "supported, and zero long tasks fired". + */ +export interface LongTaskStats { + /** False = the Long Tasks API does not exist here; counts are vacuous. */ + readonly available: boolean; + readonly count: number; + readonly totalMs: number; + readonly maxMs: number; + /** Worst tasks (up to {@link WORST_SPAN_LIMIT}, longest first); `atMs` is the task START. */ + readonly worst: readonly TimedSpan[]; +} + +/** + * Per-frame GPU draw time from `EXT_disjoint_timer_query_webgl2` (see + * `gpu-frame-timer.ts`): the GPU-side execution time of each Deck redraw, + * excluding compositing/present. `atMs` on `worst` entries is the frame's + * SUBMIT time, so they line up with `frames.worst` / `longTasks.worst`. + */ +export interface GpuStats { + /** False = the timer extension is unavailable (or nothing drew). */ + readonly available: boolean; + /** Completed timer queries (the last 1-2 frames' results never arrive). */ + readonly samples: number; + /** GPU resets/context churn that discarded in-flight queries. */ + readonly disjointCount: number; + readonly meanMs: number; + readonly p50Ms: number; + readonly p95Ms: number; + readonly maxMs: number; + readonly worst: readonly TimedSpan[]; +} + +/** + * Injectable `longtask` observation so tests can emit entries manually. + * `observe` starts delivery and returns the disconnect function. + */ +export interface LongTaskSource { + readonly observe: ( + onEntry: (startTimeMs: number, durationMs: number) => void, + ) => () => void; +} + +function defaultLongTaskSource(): LongTaskSource | null { + if ( + typeof PerformanceObserver === "undefined" || + !PerformanceObserver.supportedEntryTypes.includes("longtask") + ) { + return null; + } + return { + observe: (onEntry) => { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + onEntry(entry.startTime, entry.duration); + } + }); + observer.observe({ type: "longtask", buffered: false }); + return () => { + observer.disconnect(); + }; + }, + }; +} + +/** Injectable `requestAnimationFrame` pair so tests can drive frames manually. */ +export interface FrameLoop { + readonly schedule: (callback: (timestampMs: number) => void) => number; + readonly cancel: (handle: number) => void; +} + +function defaultFrameLoop(): FrameLoop | null { + // Non-browser environments (unit tests) simply report zero frame stats. + if (typeof requestAnimationFrame !== "function") { + return null; + } + return { + schedule: (callback) => requestAnimationFrame(callback), + cancel: (handle) => { + cancelAnimationFrame(handle); + }, + }; +} + +/** Deck's per-second samples, aggregated over the capture window. */ +export interface DeckStatsSummary { + readonly samples: number; + /** Mean frames per second across samples. */ + readonly fps: number; + /** Mean CPU time per rendered frame (ms). */ + readonly cpuTimePerFrame: number; + /** Mean GPU time per rendered frame (ms; 0 where the GPU timer is unavailable). */ + readonly gpuTimePerFrame: number; + /** Mean time spent in Deck.setProps per sample-second (ms). */ + readonly setPropsTime: number; + /** Mean time spent updating layer attributes per sample-second (ms). */ + readonly updateAttributesTime: number; + /** Total frames actually redrawn during the capture. */ + readonly framesRedrawn: number; +} + +/** + * Camera zoom over the capture (deck log2 units), so runs are comparable: + * fps at a zoomed-in viewport (fill-rate bound) and at fit-to-content are + * different benchmarks. + */ +export interface CameraStats { + readonly initialZoom: number; + readonly finalZoom: number; + readonly minZoom: number; + readonly maxZoom: number; +} + +export interface RenderCaptureReport { + readonly durationMs: number; + readonly camera: CameraStats; + readonly frames: FrameStats; + readonly longTasks: LongTaskStats; + readonly gpu: GpuStats; + readonly rebuild: RebuildStats; + readonly deck: DeckStatsSummary; +} + +/** Everything the probe touches outside its own state, injectable for tests. */ +export interface ProbeDependencies { + readonly clock?: () => number; + readonly frameLoop?: FrameLoop | null; + readonly longTaskSource?: LongTaskSource | null; +} + +function mean(values: readonly number[]): number { + if (values.length === 0) { + return 0; + } + let sum = 0; + for (const value of values) { + sum += value; + } + return sum / values.length; +} + +/** The longest spans first, capped at {@link WORST_SPAN_LIMIT}. */ +function worstSpans(spans: readonly TimedSpan[]): TimedSpan[] { + return [...spans] + .sort((left, right) => right.durationMs - left.durationMs) + .slice(0, WORST_SPAN_LIMIT); +} + +/** Nearest-rank percentile; `fraction` in (0, 1]. */ +function percentile(values: readonly number[], fraction: number): number { + if (values.length === 0) { + return 0; + } + const sorted = [...values].sort((left, right) => left - right); + const rank = Math.min( + sorted.length - 1, + Math.max(0, Math.ceil(fraction * sorted.length) - 1), + ); + // rank is clamped to [0, sorted.length - 1] above, so the index is always in bounds. + return sorted[rank]!; +} + +export class RenderMetricsProbe { + #capturing = false; + #startedAt = 0; + #rebuildDurationsMs: number[] = []; + #deckSamples: DeckMetrics[] = []; + #initialZoom = 0; + #minZoom = 0; + #maxZoom = 0; + #frameIntervalsMs: number[] = []; + /** Parallel to {@link #frameIntervalsMs}: the rAF timestamp ending each interval. */ + #frameEndsMs: number[] = []; + #frameHandle: number | null = null; + #lastFrameAt: number | null = null; + #longTaskSpans: TimedSpan[] = []; + #disconnectLongTasks: (() => void) | null = null; + #gpuSpans: TimedSpan[] = []; + #gpuDisjointCount = 0; + #gpuAvailable = false; + + readonly #clock: () => number; + readonly #frameLoop: FrameLoop | null; + readonly #longTaskSource: LongTaskSource | null; + + constructor(dependencies: ProbeDependencies = {}) { + this.#clock = dependencies.clock ?? (() => performance.now()); + this.#frameLoop = + dependencies.frameLoop === undefined + ? defaultFrameLoop() + : dependencies.frameLoop; + this.#longTaskSource = + dependencies.longTaskSource === undefined + ? defaultLongTaskSource() + : dependencies.longTaskSource; + } + + get capturing(): boolean { + return this.#capturing; + } + + start(initialZoom: number): void { + this.#capturing = true; + this.#startedAt = this.#clock(); + this.#rebuildDurationsMs = []; + this.#deckSamples = []; + this.#initialZoom = initialZoom; + this.#minZoom = initialZoom; + this.#maxZoom = initialZoom; + this.#frameIntervalsMs = []; + this.#frameEndsMs = []; + this.#lastFrameAt = null; + this.#longTaskSpans = []; + this.#gpuSpans = []; + this.#gpuDisjointCount = 0; + this.#gpuAvailable = false; + this.#scheduleFrameProbe(); + this.#observeLongTasks(); + } + + #scheduleFrameProbe(): void { + if (this.#frameLoop === null) { + return; + } + this.#frameHandle = this.#frameLoop.schedule((timestampMs) => { + if (!this.#capturing) { + return; + } + if (this.#lastFrameAt !== null) { + this.#frameIntervalsMs.push(timestampMs - this.#lastFrameAt); + this.#frameEndsMs.push(timestampMs); + } + this.#lastFrameAt = timestampMs; + this.#scheduleFrameProbe(); + }); + } + + #observeLongTasks(): void { + if (this.#longTaskSource === null) { + return; + } + // Long-task entries and rAF timestamps share the performance timeline, + // so both are made capture-relative against the same start instant. + this.#disconnectLongTasks = this.#longTaskSource.observe( + (startTimeMs, durationMs) => { + if (this.#capturing) { + this.#longTaskSpans.push({ + atMs: startTimeMs - this.#startedAt, + durationMs, + }); + } + }, + ); + } + + recordRebuild(elapsedMs: number): void { + if (this.#capturing) { + this.#rebuildDurationsMs.push(elapsedMs); + } + } + + /** Fold a zoom observation into the capture's min/max envelope. */ + recordZoom(zoom: number): void { + if (this.#capturing) { + this.#minZoom = Math.min(this.#minZoom, zoom); + this.#maxZoom = Math.max(this.#maxZoom, zoom); + } + } + + sampleDeckMetrics(metrics: DeckMetrics): void { + if (this.#capturing) { + // Deck mutates one metrics object in place between callbacks. + this.#deckSamples.push({ ...metrics }); + } + } + + /** Whether the GPU timer extension answered on this device (see gpu-frame-timer.ts). */ + noteGpuAvailability(available: boolean): void { + if (this.#capturing) { + this.#gpuAvailable = available; + } + } + + /** + * One completed GPU frame query. `submittedAtMs` is the shared-timeline + * instant the frame was SUBMITTED (results arrive frames later; the + * sample must line up with the frame that produced it). Queries submitted + * BEFORE the capture started are dropped: a query left in flight when the + * previous capture ended can sit unresolved across an idle gap and only + * be delivered on this capture's first poll, and it describes a frame + * from outside this window. + */ + recordGpuFrame(submittedAtMs: number, durationMs: number): void { + if (this.#capturing && submittedAtMs >= this.#startedAt) { + this.#gpuSpans.push({ + atMs: submittedAtMs - this.#startedAt, + durationMs, + }); + } + } + + /** A disjoint event discarded the in-flight GPU queries. */ + recordGpuDisjoint(): void { + if (this.#capturing) { + this.#gpuDisjointCount += 1; + } + } + + stop(finalZoom: number): RenderCaptureReport { + this.recordZoom(finalZoom); + this.#capturing = false; + if (this.#frameHandle !== null) { + this.#frameLoop?.cancel(this.#frameHandle); + this.#frameHandle = null; + } + + if (this.#disconnectLongTasks !== null) { + this.#disconnectLongTasks(); + this.#disconnectLongTasks = null; + } + + const rebuilds = this.#rebuildDurationsMs; + const samples = this.#deckSamples; + const intervals = this.#frameIntervalsMs; + const longTasks = this.#longTaskSpans; + + let framesRedrawn = 0; + for (const sample of samples) { + framesRedrawn += sample.framesRedrawn; + } + + const hitches: TimedSpan[] = []; + for (const [index, interval] of intervals.entries()) { + if (interval > HITCH_THRESHOLD_MS) { + hitches.push({ + atMs: this.#frameEndsMs[index]! - this.#startedAt, + durationMs: interval, + }); + } + } + + let longTaskTotalMs = 0; + let longTaskMaxMs = 0; + for (const task of longTasks) { + longTaskTotalMs += task.durationMs; + longTaskMaxMs = Math.max(longTaskMaxMs, task.durationMs); + } + + const gpuDurations = this.#gpuSpans.map((span) => span.durationMs); + + return { + durationMs: this.#clock() - this.#startedAt, + camera: { + initialZoom: this.#initialZoom, + finalZoom, + minZoom: this.#minZoom, + maxZoom: this.#maxZoom, + }, + frames: { + count: intervals.length, + meanMs: mean(intervals), + p50Ms: percentile(intervals, 0.5), + p95Ms: percentile(intervals, 0.95), + p99Ms: percentile(intervals, 0.99), + maxMs: intervals.length === 0 ? 0 : Math.max(...intervals), + hitchCount: hitches.length, + worst: worstSpans(hitches), + }, + longTasks: { + available: this.#longTaskSource !== null, + count: longTasks.length, + totalMs: longTaskTotalMs, + maxMs: longTaskMaxMs, + worst: worstSpans(longTasks), + }, + gpu: { + available: this.#gpuAvailable, + samples: gpuDurations.length, + disjointCount: this.#gpuDisjointCount, + meanMs: mean(gpuDurations), + p50Ms: percentile(gpuDurations, 0.5), + p95Ms: percentile(gpuDurations, 0.95), + maxMs: gpuDurations.length === 0 ? 0 : Math.max(...gpuDurations), + worst: worstSpans(this.#gpuSpans), + }, + rebuild: { + count: rebuilds.length, + meanMs: mean(rebuilds), + p95Ms: percentile(rebuilds, 0.95), + maxMs: rebuilds.length === 0 ? 0 : Math.max(...rebuilds), + }, + deck: { + samples: samples.length, + fps: mean(samples.map((sample) => sample.fps)), + cpuTimePerFrame: mean(samples.map((sample) => sample.cpuTimePerFrame)), + gpuTimePerFrame: mean(samples.map((sample) => sample.gpuTimePerFrame)), + setPropsTime: mean(samples.map((sample) => sample.setPropsTime)), + updateAttributesTime: mean( + samples.map((sample) => sample.updateAttributesTime), + ), + framesRedrawn, + }, + }; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/scene.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/scene.ts new file mode 100644 index 00000000000..5520510f0d2 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/scene.ts @@ -0,0 +1,577 @@ +/** + * Owns the Deck.gl instance and drives it imperatively from the worker handle's subscribe + * stream: structure/position events rebuild the layer set off React, and the camera lives + * in a field (never React state), so settling and pan/zoom never trigger a render. + * React mounts and disposes Scene; Scene owns all runtime state. + * + * Parameterized over node identity (see {@link "./handle"}): the entity + * lifecycle mounts a `Scene` on an `EntityWorkerConnection`, the + * type lifecycle a `Scene` on a `TypeWorkerConnection`. + */ +import { Deck, OrthographicView } from "@deck.gl/core"; + +import { + buildPlaced, + clusterBubbleLayer, + clusterEntityLayers, + updatePlaced, +} from "../clusters"; +import { communityLayer } from "../community"; +import { edgeArrowLayer } from "../edge-arrows"; +import { edgeLayer } from "../edges"; +import { flatDotsLayer } from "../flat-dots"; +import { IconAtlas } from "../gpu/icon-atlas"; +import { LabelCharacterSet } from "../label-character-set"; +import { clusterLabelLayer, edgeLabelLayer, labelTexts } from "../labels"; +import { selectionOverlayLayers } from "../selection"; +import { leafTypeIconLayers, typeIconLayer } from "../type-icons"; +import { SceneCamera } from "./camera"; +import { GpuFrameTimer } from "./gpu-frame-timer"; +import { defaultLabelPolicy, HubLabels } from "./hub-labels"; +import { SceneInteractions } from "./interactions"; +import { layerKindOf } from "./layer-kinds"; +import { NodeIcons } from "./node-icons"; +import { RenderMetricsProbe } from "./render-metrics"; +import { ICON_ZOOM_BUCKETS_PER_UNIT } from "./view-state"; + +import type { PositionsFrame, StructureFrame } from "../../frames"; +import type { PlacedCluster } from "../clusters"; +import type { WorkerEvent } from "../frame-connection"; +import type { SceneCallbacks } from "./callbacks"; +import type { ZoomBucketChanges } from "./camera"; +import type { SceneHandle } from "./handle"; +import type { LabelPolicy } from "./hub-labels"; +import type { LayerKind } from "./layer-kinds"; +import type { RenderCaptureReport } from "./render-metrics"; +import type { Layer } from "@deck.gl/core"; +import type { Device } from "@luma.gl/core"; + +export type { + ClusterHover, + EntityHover, + EntityLabel, + EntitySelection, + FlatEdgeHover, + HighwayHover, + NodeHover, + NodeLabel, + NodeSelection, + SceneCallbacks, +} from "./callbacks"; +export type { LabelPolicy } from "./hub-labels"; +export type { RenderCaptureReport } from "./render-metrics"; + +export interface SceneOptions { + /** Which dots get an always-on label; omitted fields keep the entity-hub defaults. */ + readonly labelPolicy?: Partial; +} + +interface BuildLabelLayersConfig { + readonly structure: StructureFrame; + readonly positions: PositionsFrame; + readonly zoom: number; + readonly positionTick: number; + readonly characterSet: readonly string[]; +} + +function buildLabelLayers({ + structure, + positions, + zoom, + positionTick, + characterSet, +}: BuildLabelLayersConfig): Layer[] { + const result: Layer[] = []; + const edgeLabels = edgeLabelLayer({ + positions, + zoom, + positionVersion: positionTick, + characterSet, + }); + if (edgeLabels) { + result.push(edgeLabels); + } + const clusterLabels = clusterLabelLayer({ + structure, + positions, + zoom, + characterSet, + }); + if (clusterLabels) { + result.push(clusterLabels); + } + + return result; +} + +export class Scene< + NodeId extends string, + NodeIndex extends number, + EdgeIndex extends number, +> { + readonly #deck: Deck; + /** + * Scene-owned canvas, removed explicitly on {@link dispose}. Deck only + * removes an internally-created canvas from the DOM once its ASYNC device + * init has attached it (`_setDevice`); a Deck finalized before that -- + * e.g. a filter change recreating the worker moments after mount -- + * orphans its canvas in the container, stacking dead `deckgl-overlay` + * canvases that break pointer math for the live one underneath. + */ + readonly #canvas: HTMLCanvasElement; + readonly #handle: SceneHandle; + readonly #unsubscribe: () => void; + + #callbacks: SceneCallbacks; + #dataLayers: Layer[] = []; + #labelLayers: Layer[] = []; + + /** Rasterised type-icon atlas. Async rasters bump version and re-push layers. */ + readonly #iconAtlas: IconAtlas; + /** The Deck GPU device, captured once it initialises; the atlas texture is built on it. */ + #device: Device | undefined; + /** Scrim + ego overlay, pushed above data + labels: dims the field, redraws the ego bright. */ + #overlayLayers: Layer[] = []; + #positionTick = 0; + + #firstStructureSeen = false; + /** + * Set by {@link dispose}. Async work (worker round-trips, icon rasters) + * checks it before touching the finalized Deck or the disposed handle. + */ + #disposed = false; + /** Persistent cluster-bubble set: rebuilt on structure, positions mutated in place. */ + #placed: PlacedCluster[] = []; + + readonly #camera: SceneCamera; + readonly #interactions: SceneInteractions; + readonly #hubLabels: HubLabels; + readonly #nodeIcons: NodeIcons; + /** Grow-only glyph set shared by the label layers (see label-character-set.ts). */ + readonly #labelCharacters = new LabelCharacterSet(); + + /** Render-cost capture (dev harness benchmark); idle unless a capture is running. */ + readonly #renderMetrics = new RenderMetricsProbe(); + /** Per-frame GPU draw time for the capture (idle outside one). */ + readonly #gpuTimer = new GpuFrameTimer( + (submittedAtMs, durationMs) => + this.#renderMetrics.recordGpuFrame(submittedAtMs, durationMs), + () => this.#renderMetrics.recordGpuDisjoint(), + ); + + /** Layer kinds hidden via {@link setHiddenLayerKinds} (GPU-cost bisection). */ + #hiddenLayerKinds: ReadonlySet = new Set(); + /** True while inside a timed rebuild, so nested rebuild paths count once. */ + #rebuildTimingActive = false; + + constructor( + container: HTMLDivElement, + handle: SceneHandle, + callbacks: SceneCallbacks, + options: SceneOptions = {}, + ) { + this.#handle = handle; + this.#callbacks = callbacks; + // Async icon rasters bump the atlas version and trigger a layer re-push + // so ready icons appear on the next frame. + this.#iconAtlas = new IconAtlas(() => this.#refreshDataLayers()); + + this.#camera = new SceneCamera({ + container, + handle, + deck: () => this.#deck, + afterViewStateApplied: (changes) => this.#afterViewStateApplied(changes), + }); + + this.#interactions = new SceneInteractions({ + handle, + deck: () => this.#deck, + callbacks: () => this.#callbacks, + placed: () => this.#placed, + zoom: () => this.#camera.zoom, + isDisposed: () => this.#disposed, + refreshDataLayers: () => this.#refreshDataLayers(), + focusCamera: (x, y) => this.#camera.focusOn(x, y), + zoomToBubble: (placed) => this.#camera.zoomToBubble(placed), + }); + + this.#hubLabels = new HubLabels({ + handle, + deck: () => this.#deck, + callbacks: () => this.#callbacks, + zoom: () => this.#camera.zoom, + labelPolicy: { ...defaultLabelPolicy, ...options.labelPolicy }, + }); + + this.#nodeIcons = new NodeIcons({ + handle, + callbacks: () => this.#callbacks, + iconAtlas: this.#iconAtlas, + }); + + // Pre-style to what Deck's own sizing would apply, so the canvas fills + // the container even before (or without) device init. + this.#canvas = document.createElement("canvas"); + Object.assign(this.#canvas.style, { + position: "absolute", + inset: "0", + width: "100%", + height: "100%", + }); + container.appendChild(this.#canvas); + + this.#deck = new Deck({ + parent: container, + canvas: this.#canvas, + views: new OrthographicView({ id: "main", controller: true }), + controller: true, + viewState: this.#camera.viewState, + // Capture the GPU device so the icon atlas can build its texture on it; re-push once it is + // available in case a structure frame (and its icon layer) arrived before init completed. + onDeviceInitialized: (device) => { + this.#device = device; + this.#refreshDataLayers(); + }, + // Deck aggregates render stats once per second; the probe stores them + // only while a capture is running. + _onMetrics: (metrics) => { + this.#renderMetrics.sampleDeckMetrics(metrics); + }, + // GPU frame timing (capture-gated): bracket each redraw with a timer + // query; results arrive frames later via the timer's poll. + onBeforeRender: ({ gl }) => { + if (this.#renderMetrics.capturing) { + this.#gpuTimer.frameBegin(gl); + this.#renderMetrics.noteGpuAvailability( + this.#gpuTimer.available === true, + ); + } + }, + onAfterRender: () => { + this.#gpuTimer.frameEnd(); + }, + // Dev-harness bisection: drop whole layer kinds from every pass so a + // pinned-camera bench can attribute GPU fill cost layer by layer. + layerFilter: ({ layer }) => + this.#hiddenLayerKinds.size === 0 || + !this.#hiddenLayerKinds.has(layerKindOf(layer.id)), + getCursor: ({ isDragging, isHovering }) => { + if (isDragging) { + return "grabbing"; + } + return isHovering ? "pointer" : "grab"; + }, + onViewStateChange: ({ viewState }) => + this.#camera.applyViewState(viewState), + // A pan begins: hover cards are anchored to moving graph geometry, so hide them until the + // next hover rather than re-projecting React overlays on every drag frame. + onDragStart: () => this.#interactions.onDragStart(), + onDragEnd: () => this.#interactions.onDragEnd(), + onClick: (info) => this.#interactions.handleClick(info), + onHover: (info) => { + this.#interactions.handleHover(info); + }, + layers: [], + }); + + this.#camera.scheduleViewport(); + this.#unsubscribe = handle.subscribe((event) => this.#handleEvent(event)); + } + + /** Refresh the interaction callbacks without re-mounting Deck. */ + setCallbacks(callbacks: SceneCallbacks): void { + this.#callbacks = callbacks; + } + + zoomBy(delta: number): void { + this.#camera.zoomBy(delta); + } + + fitToContent(): void { + this.#camera.fitToContent(); + } + + /** + * Frame the graph's fill-heaviest bubble so the render bench can anchor + * its sweep on it (see SceneCamera.frameLargestBubble): the largest + * Louvain community on the flat tier, the largest leaf cluster bubble + * otherwise, fit-to-content when neither exists. + */ + frameLargestBubble(): void { + this.#camera.frameLargestBubble(); + } + + /** Begin a render-cost capture (deck stats + rebuild timings + zoom envelope). */ + startRenderCapture(): void { + this.#renderMetrics.start(this.#camera.zoom); + } + + /** End the capture started by {@link startRenderCapture} and summarise it. */ + stopRenderCapture(): RenderCaptureReport { + // Scoop whatever GPU queries have completed by now; the last frame or + // two may still be in flight and are deliberately dropped. + this.#gpuTimer.poll(); + return this.#renderMetrics.stop(this.#camera.zoom); + } + + /** + * Hide whole layer kinds from every render/picking pass (dev harness + * GPU-cost bisection; see PERFORMANCE.md section 7.1). + */ + setHiddenLayerKinds(kinds: readonly LayerKind[]): void { + this.#hiddenLayerKinds = new Set(kinds); + // The filter closure is stable; re-pushing the layers forces the redraw + // that re-evaluates it. + this.#pushLayers(); + } + + get renderCapturing(): boolean { + return this.#renderMetrics.capturing; + } + + dispose(): void { + this.#disposed = true; + this.#camera.dispose(); + this.#hubLabels.dispose(); + this.#unsubscribe(); + this.#iconAtlas.dispose(); + this.#deck.finalize(); + // Deck never removes an externally-provided canvas; and even for its own + // it fails to when finalized before async device init (see #canvas). + this.#canvas.remove(); + } + + #handleEvent(event: WorkerEvent): void { + const structure = this.#handle.getStructure(); + const positions = this.#handle.getPositions(); + if (!structure || !positions) { + return; + } + + if (event.kind === "structure") { + // Topology changed: rebuild the bubble set (new array identity -> Deck regenerates + // all bubble attributes). The paired position event does the layer build + push. + this.#placed = buildPlaced(structure, positions); + + // The cut can invalidate the selection / ego set; revalidate + re-query. + this.#interactions.onStructure(); + + // Structure changed (new dots, reorder, leaf open/close): recompute which dots label + + // their text. The paired position event below rebuilds the layer from this cached set. + this.#hubLabels.rebuild(); + + // Same gating for the per-dot type-icon keys (the only O(dots) icon-resolution scan). + this.#nodeIcons.rebuild(); + + return; + } + + this.#positionTick += 1; + this.#timedRebuild(() => { + updatePlaced(this.#placed, positions); + this.#dataLayers = this.#buildDataLayers(structure, positions); + this.#rebuildLabels(); + this.#overlayLayers = this.#buildOverlay(); + this.#pushLayers(); + }); + // The selected node may have moved this tick: refresh the tracked overlays. + this.#interactions.afterPositions(); + this.#hubLabels.schedule(); + if (!this.#firstStructureSeen) { + this.#firstStructureSeen = true; + this.#callbacks.onFirstStructure(); + } + } + + // #placed preserves bubble array identity across position frames + // (position-only updateTrigger). + #buildDataLayers( + structure: StructureFrame, + positions: PositionsFrame, + ): Layer[] { + const clusters = this.#handle.getClusters(); + const result: Layer[] = []; + // Layer order is draw order (later = on top). Hierarchical edges (feeders/highways) render + // under the faint container bubbles so they read through them with depth-opacity -- drawn over + // the bubbles they wash out and effectively vanish. Picking is decoupled from this order + // (see SceneInteractions): an edge under a bubble still wins a click/hover, while a dot -- + // drawn on top -- still wins over an edge passing under it. + const edges = edgeLayer(positions, structure.flatGraph !== undefined); + const edgeArrows = edgeArrowLayer(positions, this.#camera.zoom); + if (structure.flatGraph) { + result.push( + ...communityLayer(structure.flatGraph, clusters, positions.settled), + ); + result.push(...edges); + result.push(...edgeArrows); + result.push(...flatDotsLayer(structure.flatGraph, clusters)); + // Type icons sit on the dots (rendered after them). The device is required to build the atlas + // texture, so before it initialises the icons are simply absent (they appear on the re-push + // from onDeviceInitialized). The hierarchical leaf counterpart is in the other branch. + if (this.#device !== undefined) { + result.push( + ...typeIconLayer({ + graph: structure.flatGraph, + clusters, + atlas: this.#iconAtlas, + device: this.#device, + names: this.#nodeIcons.flatNames, + namesVersion: this.#nodeIcons.version, + positionTick: this.#positionTick, + zoom: this.#camera.iconBucket / ICON_ZOOM_BUCKETS_PER_UNIT, + zoomBucket: this.#camera.iconBucket, + }), + ); + } + } else { + result.push(...edges); + result.push( + clusterBubbleLayer( + this.#placed, + this.#positionTick, + this.#interactions.keepFullClusters(), + this.#interactions.highlightTick, + ), + ); + result.push(...edgeArrows); + result.push( + ...clusterEntityLayers({ + structure, + positions, + clusters, + positionTick: this.#positionTick, + highlightedEntities: this.#interactions.highlightedNodeKeys, + }), + ); + // Type icons sit on the leaf dots (rendered after them), one IconLayer per open leaf. Gated on + // the device the same way as the flat tier (the atlas texture needs it; absent until init). + if (this.#device !== undefined) { + result.push( + ...leafTypeIconLayers({ + structure, + positions, + clusters, + atlas: this.#iconAtlas, + device: this.#device, + namesByLeaf: this.#nodeIcons.leafNames, + namesVersion: this.#nodeIcons.version, + positionTick: this.#positionTick, + zoom: this.#camera.iconBucket / ICON_ZOOM_BUCKETS_PER_UNIT, + zoomBucket: this.#camera.iconBucket, + }), + ); + } + } + return result; + } + + // Selection overlay above labels; empty data when unselected keeps layer + // ids stable. + #buildOverlay(): Layer[] { + return selectionOverlayLayers( + this.#interactions.selectedGeometry(), + this.#positionTick, + ); + } + + /** Camera moved: run the bucket-gated rebuilds and re-project the HTML overlays. */ + #afterViewStateApplied(changes: ZoomBucketChanges): void { + // Label layers rebuild on zoom bucket changes; a pure pan reprojects + // via viewState with no rebuild (and records no rebuild span). + if ( + changes.labelColorBucketChanged || + changes.labelEligibilityChanged || + changes.iconEligibilityChanged + ) { + this.#timedRebuild(() => { + if (changes.labelColorBucketChanged) { + this.#rebuildLabels(); + } + if (changes.labelEligibilityChanged) { + this.#hubLabels.rebuild(); + } + if (changes.iconEligibilityChanged) { + this.#refreshDataLayers(); + } else if (changes.labelColorBucketChanged) { + this.#pushLayers(); + } + }); + } + // HTML overlays use projected screen coords, so they must re-project on + // every camera move (pan included) or they freeze under the sliding canvas. + this.#interactions.afterPositions(); + this.#hubLabels.schedule(); + } + + // Rebuild + push the data layers against the current frames (e.g. after a selection change + // while the layout is settled, when no position event would otherwise refresh the ring). + #refreshDataLayers(): void { + // Reachable after dispose via async icon rasters and ego resolutions; + // setProps on a finalized Deck throws. + if (this.#disposed) { + return; + } + const structure = this.#handle.getStructure(); + const positions = this.#handle.getPositions(); + if (!structure || !positions) { + return; + } + this.#timedRebuild(() => { + this.#dataLayers = this.#buildDataLayers(structure, positions); + this.#overlayLayers = this.#buildOverlay(); + this.#pushLayers(); + }); + } + + /** + * Run a synchronous layer-rebuild span under the metrics probe. Spans + * nest (a zoom-bucket rebuild funnels into {@link #refreshDataLayers}); + * only the outermost records, so each rebuild is counted once, in full. + */ + #timedRebuild(rebuild: () => void): void { + if (!this.#renderMetrics.capturing || this.#rebuildTimingActive) { + rebuild(); + return; + } + this.#rebuildTimingActive = true; + const startMs = performance.now(); + try { + rebuild(); + } finally { + this.#rebuildTimingActive = false; + this.#renderMetrics.recordRebuild(performance.now() - startMs); + this.#renderMetrics.recordZoom(this.#camera.zoom); + } + } + + #pushLayers(): void { + this.#deck.setProps({ + layers: [ + ...this.#dataLayers, + ...this.#labelLayers, + ...this.#overlayLayers, + ], + }); + } + + #rebuildLabels(): void { + const structure = this.#handle.getStructure(); + const positions = this.#handle.getPositions(); + if (!structure || !positions) { + return; + } + + // Cluster + edge labels only. The hub-label overlay is HTML (see HubLabels), and the + // entity-label layer rides the current #positionTick so it tracks the settling layout + // (live SAB reads) -- this method only fires on zoom / structure. + this.#labelLayers = buildLabelLayers({ + structure, + positions, + zoom: this.#camera.zoom, + positionTick: this.#positionTick, + characterSet: this.#labelCharacters.extend( + labelTexts(structure, positions), + ), + }); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/view-state.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/view-state.ts new file mode 100644 index 00000000000..4a7454a3188 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/scene/view-state.ts @@ -0,0 +1,136 @@ +/** + * Deck view-state helpers: reading the zoom out of a loosely-typed view + * state, the coarse zoom buckets that gate expensive rebuilds, and the + * quantised viewport snapshot sent to the worker. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- deck.gl view state is loosely typed (target/zoom plus transition metadata passed through verbatim). +export type ViewState = Record; + +// zoomX/zoomY only, never scalar `zoom`: OrthographicView works in zoomX/zoomY internally; +// a stray `zoom` makes transitions compute their start from an inconsistent value and jerk. +export const INITIAL_VIEW_STATE: ViewState = { + target: [0, 0, 0], + zoomX: 0, + zoomY: 0, + minZoom: -12, + maxZoom: 24, +}; + +/** + * Label eligibility rebuild gate. + * + * @defaultValue 4 buckets per zoom unit. Higher values reduce rebuild frequency but delay label + * appearance when zooming in. + */ +const LABEL_ZOOM_BUCKETS_PER_UNIT = 4; +/** + * Icon visibility gate. + * + * @defaultValue 8 buckets per zoom unit. Higher values cut icon-layer rebuilds but make icon + * fade-in/out coarser. + */ +export const ICON_ZOOM_BUCKETS_PER_UNIT = 8; +/** + * Label colour rebuild gate. + * + * @defaultValue 24 buckets per zoom unit. Higher values reduce colour-tier churn at the cost of + * slower colour transitions. + */ +const LABEL_COLOR_ZOOM_BUCKETS_PER_UNIT = 24; +/** + * Worker viewport zoom quantisation. + * + * @defaultValue 16 buckets per zoom unit. Higher values mean fewer worker viewport messages + * at the cost of coarser LOD zoom resolution. + */ +const WORKER_VIEWPORT_ZOOM_BUCKETS_PER_UNIT = 16; +/** + * Worker viewport update rate limit. + * + * @defaultValue 20 fps. Lower values reduce worker load at the cost of laggier LOD response. + */ +const WORKER_VIEWPORT_MAX_FPS = 20; +export const WORKER_VIEWPORT_MIN_INTERVAL_MS = 1000 / WORKER_VIEWPORT_MAX_FPS; + +export function viewStateZoom(viewState: ViewState): number { + const { zoom, zoomX } = viewState; + if (typeof zoomX === "number") { + return zoomX; + } + if (typeof zoom === "number") { + return zoom; + } + if (Array.isArray(zoom) && typeof zoom[0] === "number") { + return zoom[0]; + } + return 0; +} + +export function labelZoomBucket(zoom: number): number { + return Math.floor(zoom * LABEL_ZOOM_BUCKETS_PER_UNIT); +} + +export function iconZoomBucket(zoom: number): number { + return Math.floor(zoom * ICON_ZOOM_BUCKETS_PER_UNIT); +} + +export function labelColorZoomBucket(zoom: number): number { + return Math.round(zoom * LABEL_COLOR_ZOOM_BUCKETS_PER_UNIT); +} + +function workerViewportZoom(zoom: number): number { + return ( + Math.round(zoom * WORKER_VIEWPORT_ZOOM_BUCKETS_PER_UNIT) / + WORKER_VIEWPORT_ZOOM_BUCKETS_PER_UNIT + ); +} + +export class WorkerViewportSnapshot { + zoom = 0; + readonly center: [number, number] = [0, 0]; + centerX = 0; + centerY = 0; + width = 0; + height = 0; + + update(container: HTMLDivElement, viewState: ViewState): void { + const rect = container.getBoundingClientRect(); + const zoom = workerViewportZoom(viewStateZoom(viewState)); + const scale = 2 ** zoom; + // Pan only affects hierarchical LOD when bubble centres cross meaningful screen-space thresholds. + // Quantise to ~32px buckets so erratic drag does not spam equivalent LOD probes. + const centerBucketWorld = 32 / Math.max(scale, 1e-6); + // INITIAL_VIEW_STATE and all callers set target to a numeric [x, y, z] tuple. + const centerX = viewState.target[0] as number; + const centerY = viewState.target[1] as number; + this.zoom = zoom; + this.centerX = Math.round(centerX / centerBucketWorld) * centerBucketWorld; + this.centerY = Math.round(centerY / centerBucketWorld) * centerBucketWorld; + this.center[0] = this.centerX; + this.center[1] = this.centerY; + this.width = Math.round(rect.width); + this.height = Math.round(rect.height); + } + + copyFrom(other: WorkerViewportSnapshot): void { + this.zoom = other.zoom; + this.centerX = other.centerX; + this.centerY = other.centerY; + this.center[0] = other.centerX; + this.center[1] = other.centerY; + this.width = other.width; + this.height = other.height; + } + + equals(other: WorkerViewportSnapshot | null): boolean { + return ( + other !== null && + this.zoom === other.zoom && + this.centerX === other.centerX && + this.centerY === other.centerY && + this.width === other.width && + this.height === other.height + ); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/selection.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/selection.ts new file mode 100644 index 00000000000..37d7dd7a257 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/selection.ts @@ -0,0 +1,134 @@ +/** + * Selection ring over a picked entity dot. Position is read live from the + * SAB so the ring rides a settling layout with no rebuild round-trip. + */ +import { ScatterplotLayer } from "@deck.gl/layers"; + +import { graphColors } from "../visual-style"; +import { + FLAT_HEADER_BYTES, + FLAT_RADIUS_BYTE_OFFSET, + FLAT_RECORD_BYTES, + leafNodeX, + leafNodeY, +} from "../worker/buffers/position-buffer"; + +import type { PositionsFrame, StructureFrame } from "../frames"; +import type { Position } from "../geometry"; +import type { ClusterId } from "../ids"; +import type { ClusterReference } from "./frame-connection"; +import type { Layer } from "@deck.gl/core"; + +/** A selected node dot, tracked by the buffer + index the pick resolved to. */ +export interface Selection { + readonly nodeId: NodeId; + readonly layoutId: ClusterId; + /** + * Render index into the layout's buffer. Stable for a hierarchical leaf (fixed node set); + * for the flat buffer it is the live record index, valid until the buffer reorders -- the + * Scene drops the selection when a structure frame shows a different node + * at this buffer index. + */ + readonly localIndex: number; +} + +/** World position + radius of a node, read live from its SAB / structure. */ +export interface SelectionGeometry extends Position { + readonly radius: number; +} + +/** + * World position + radius of the selected node, or null if its layout is gone. + * Flat buffers store world-space records directly; hierarchical leaves are local + * to their leaf origin (offset added here from the cluster positions frame). + */ +export function nodeGeometry( + layoutId: ClusterId, + localIndex: number, + cluster: ClusterReference, + structure: StructureFrame, + positions: PositionsFrame, +): SelectionGeometry | null { + if (cluster.flatCapacity !== undefined) { + const floats = new Float32Array(cluster.versionView.buffer); + const base = (FLAT_HEADER_BYTES + localIndex * FLAT_RECORD_BYTES) / 4; + const x = floats[base]; + const y = floats[base + 1]; + if (x === undefined || y === undefined) { + return null; + } + return { x, y, radius: floats[base + FLAT_RADIUS_BYTE_OFFSET / 4] ?? 0 }; + } + + const layer = structure.entityLayers.find( + (entry) => entry.layoutId === layoutId, + ); + if (!layer) { + return null; + } + const originX = positions.clusterPositions[layer.leafClusterIndex * 2] ?? 0; + const originY = + positions.clusterPositions[layer.leafClusterIndex * 2 + 1] ?? 0; + return { + x: originX + leafNodeX(cluster.positions, localIndex), + y: originY + leafNodeY(cluster.positions, localIndex), + radius: layer.radius, + }; +} + +/** Screen-space ring at a world-space anchor. Not pickable (must not eat the dot it rings). */ +function ringLayer( + id: string, + data: readonly SelectionGeometry[], + radiusPixels: number, + lineWidth: number, + color: readonly [number, number, number, number], + filled: boolean, + positionTick: number, +): Layer { + return new ScatterplotLayer({ + id, + data, + getPosition: (datum) => [datum.x, datum.y], + getRadius: radiusPixels, + radiusUnits: "pixels", + billboard: true, + radiusScale: 1, + stroked: true, + filled, + getFillColor: color, + getLineColor: color, + lineWidthUnits: "pixels", + getLineWidth: lineWidth, + pickable: false, + updateTriggers: { getPosition: positionTick }, + }); +} + +/** Selection ring layers. Empty data when nothing is selected (stable layer set). */ +export function selectionOverlayLayers( + selected: SelectionGeometry | null, + positionTick: number, +): Layer[] { + const data = selected ? [selected] : []; + return [ + ringLayer( + "selection-halo", + data, + 16, + 2, + graphColors.selectionHalo, + false, + positionTick, + ), + ringLayer( + "selection-ring", + data, + 10, + 2, + graphColors.selection, + false, + positionTick, + ), + ]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/type-icons.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/type-icons.ts new file mode 100644 index 00000000000..ba76a68e893 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/type-icons.ts @@ -0,0 +1,227 @@ +/** + * Type-icon render via `IconLayer`, reading the same SAB as the dots + * (binary attributes, stride/offset). Icons fade in once the dot has enough + * screen presence (soft LOD, coarse-bucket accessor refresh). + */ +import { IconLayer } from "@deck.gl/layers"; + +import { + FLAT_HEADER_BYTES, + FLAT_RADIUS_BYTE_OFFSET, + FLAT_RECORD_BYTES, + leafPositionAttribute, +} from "../worker/buffers/position-buffer"; + +import type { + PositionsFrame, + RenderFlatGraph, + StructureFrame, +} from "../frames"; +import type { ClusterId } from "../ids"; +import type { ClusterReference } from "./frame-connection"; +import type { IconAtlas } from "./gpu/icon-atlas"; +import type { Layer } from "@deck.gl/core"; +import type { Device } from "@luma.gl/core"; + +/** Icon diameter as a fraction of the dot diameter, leaving visible type-colour padding. */ +const ICON_TO_DOT_DIAMETER = 0.55; +const ICON_MIN_SCREEN_DIAMETER = 18; +const ICON_FADE_PX = 10; + +function iconAlpha(screenDiameter: number): number { + const progress = Math.min( + 1, + Math.max( + 0, + (screenDiameter - ICON_MIN_SCREEN_DIAMETER + ICON_FADE_PX) / ICON_FADE_PX, + ), + ); + return Math.round(235 * progress); +} + +interface TypeIconLayerParams { + readonly graph: RenderFlatGraph; + readonly clusters: Map; + readonly atlas: IconAtlas; + /** The GPU device the atlas texture must be built on (the Deck instance's device). */ + readonly device: Device; + /** Per-render-index atlas key, or null when the entity has no icon (index-aligned with flat buffer records). */ + readonly names: readonly (string | null)[]; + /** Version of {@link names}; combined with the atlas version drives the getIcon trigger. */ + readonly namesVersion: number; + /** Bumped every position frame; drives the position/size triggers (same as the dots). */ + readonly positionTick: number; + /** Current view zoom, quantized by Scene before this layer is rebuilt. */ + readonly zoom: number; + /** Drives icon visibility/color accessors when the coarse zoom LOD bucket changes. */ + readonly zoomBucket: number; +} + +export function typeIconLayer({ + graph, + clusters, + atlas, + device, + names, + namesVersion, + positionTick, + zoom, + zoomBucket, +}: TypeIconLayerParams): Layer[] { + const cluster = clusters.get(graph.layoutId); + if (!cluster) { + return []; + } + const floats = new Float32Array(cluster.versionView.buffer); + const scale = 2 ** zoom; + const radiusAt = (index: number): number => { + const recordBase = + (FLAT_HEADER_BYTES + index * FLAT_RECORD_BYTES) / + Float32Array.BYTES_PER_ELEMENT; + return ( + floats[ + recordBase + FLAT_RADIUS_BYTE_OFFSET / Float32Array.BYTES_PER_ELEMENT + ] ?? 0 + ); + }; + return [ + new IconLayer({ + id: "flat-type-icons", + data: { + length: graph.count, + attributes: { + getPosition: { + value: floats, + size: 2, + stride: FLAT_RECORD_BYTES, + offset: FLAT_HEADER_BYTES, + }, + // getSize reads dot radius; sizeScale converts radius to icon diameter in common units. + getSize: { + value: floats, + size: 1, + stride: FLAT_RECORD_BYTES, + offset: FLAT_HEADER_BYTES + FLAT_RADIUS_BYTE_OFFSET, + }, + }, + }, + iconAtlas: atlas.getTexture(device), + iconMapping: atlas.getMapping(), + // size = radius * 2 * ICON_TO_DOT_DIAMETER = icon diameter; in `common` units it tracks the + // node-contained dot mark rather than acting like a fixed UI glyph. + sizeUnits: "common", + sizeScale: 2 * ICON_TO_DOT_DIAMETER, + getIcon: (_: unknown, info: { index: number }) => { + const key = names[info.index]; + if (key === null || key === undefined || !atlas.has(key)) { + return ""; + } + return iconAlpha(radiusAt(info.index) * 2 * scale) > 0 ? key : ""; + }, + // Cells are pre-coloured (white silhouettes / full-colour emoji): draw them as-is. + getColor: (_: unknown, info: { index: number }) => [ + 255, + 255, + 255, + iconAlpha(radiusAt(info.index) * 2 * scale), + ], + billboard: true, + pickable: false, + updateTriggers: { + getPosition: positionTick, + getSize: positionTick, + // A names change OR a newly-ready async raster (atlas.version) must re-evaluate icons. + getIcon: `${namesVersion}:${atlas.version}:${zoomBucket}`, + getColor: zoomBucket, + }, + }), + ]; +} + +interface LeafTypeIconLayersParams { + readonly structure: StructureFrame; + readonly positions: PositionsFrame; + readonly clusters: Map; + readonly atlas: IconAtlas; + /** The GPU device the atlas texture must be built on (the Deck instance's device). */ + readonly device: Device; + /** Per open leaf, per-local-index atlas key (or null); index-aligned with the leaf SAB records. */ + readonly namesByLeaf: ReadonlyMap; + /** Version of {@link namesByLeaf}; with the atlas version drives the getIcon trigger. */ + readonly namesVersion: number; + /** Bumped every position frame; drives the position trigger (same as the dots). */ + readonly positionTick: number; + /** Current view zoom, quantized by Scene before this layer is rebuilt. */ + readonly zoom: number; + /** Drives icon visibility/color accessors when the coarse zoom LOD bucket changes. */ + readonly zoomBucket: number; +} + +/** + * Hierarchical-tier type icons: one layer per open leaf. Each leaf shares one + * radius, so the soft-LOD is all-or-nothing per leaf (cheaper than a per-dot fade). + */ +export function leafTypeIconLayers({ + structure, + positions, + clusters, + atlas, + device, + namesByLeaf, + namesVersion, + positionTick, + zoom, + zoomBucket, +}: LeafTypeIconLayersParams): Layer[] { + const scale = 2 ** zoom; + const clusterPositions = positions.clusterPositions; + const layers: Layer[] = []; + for (const layer of structure.entityLayers) { + const cluster = clusters.get(layer.layoutId); + const names = namesByLeaf.get(layer.layoutId); + if (!cluster || !names) { + continue; + } + // Uniform leaf-dot radius -> one screen diameter for the whole leaf, so the fade is all-or-none. + const alpha = iconAlpha(layer.radius * 2 * scale); + if (alpha <= 0) { + continue; + } + const originX = clusterPositions[layer.leafClusterIndex * 2] ?? 0; + const originY = clusterPositions[layer.leafClusterIndex * 2 + 1] ?? 0; + layers.push( + new IconLayer({ + id: `leaf-type-icons:${layer.layoutId}`, + data: { + length: layer.count, + attributes: { + getPosition: leafPositionAttribute(cluster.versionView.buffer), + }, + }, + // oxfmt-ignore + modelMatrix: [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + originX, originY, 0, 1, + ], + iconAtlas: atlas.getTexture(device), + iconMapping: atlas.getMapping(), + sizeUnits: "common", + getSize: layer.radius * 2 * ICON_TO_DOT_DIAMETER, + getIcon: (_: unknown, info: { index: number }) => { + const key = names[info.index]; + return key !== null && key !== undefined && atlas.has(key) ? key : ""; + }, + getColor: [255, 255, 255, alpha], + billboard: true, + pickable: false, + updateTriggers: { + getPosition: positionTick, + getIcon: `${namesVersion}:${atlas.version}:${zoomBucket}`, + }, + }), + ); + } + return layers; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/type-worker-connection.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/type-worker-connection.ts new file mode 100644 index 00000000000..e03d18b72d1 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/type-worker-connection.ts @@ -0,0 +1,167 @@ +/** + * The type-graph lifecycle's worker connection: boots `INIT_TYPE` and + * implements {@link SceneHandle} on top of + * {@link FrameConnection}'s frame stream. + * + * Node keys are {@link TypeId}s, resolved to {@link VersionedUrl}s against + * the append-only `TYPE_ID_TABLE` the worker publishes (the type-graph + * analogue of the entity id-map SAB). Edge identity: bezier segment ids are + * indices into the store's edge table, mirrored per structure frame as + * {@link StructureFrame.typeEdges}, so a hovered segment resolves to its + * source / target / link type without a worker round-trip. + */ +import { + flatRecordJoinKey, + FrameConnection, + PendingRequests, +} from "./frame-connection"; + +import type { ClusterId, TypeId } from "../ids"; +import type { TypeSchemaEntry } from "../worker/protocol"; +import type { + IngestTypeEdge, + IngestTypeNode, +} from "../worker/type-graph/protocol"; +import type { + FrameConnectionConfig, + LifecycleMessage, +} from "./frame-connection"; +import type { FlatEdgePick, NodeEgo, SceneHandle } from "./scene/handle"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +/** The public surface the type presentation drives Deck.gl from. */ +export interface TypeWorkerHandle extends SceneHandle< + VersionedUrl, + TypeId, + TypeId +> { + ingestTypes( + nodes: readonly IngestTypeNode[], + edges: readonly IngestTypeEdge[], + linkTypeSchemas: readonly TypeSchemaEntry[], + ): void; +} + +export class TypeWorkerConnection + extends FrameConnection + implements TypeWorkerHandle +{ + /** + * The TypeId -> VersionedUrl join table, appended from TYPE_ID_TABLE + * messages (interning is append-only; each message carries the new tail). + */ + readonly #typeUrls: VersionedUrl[] = []; + + /** Correlates async ego queries (ego-highlight) with their replies. */ + readonly #egoRequests = new PendingRequests(); + + constructor(connectionConfig: FrameConnectionConfig) { + super(connectionConfig); + this.send({ + type: "INIT_TYPE", + config: this.withDebugFlag(connectionConfig.config), + }); + } + + resolveNodeId( + layoutId: ClusterId, + recordIndex: number, + ): VersionedUrl | undefined { + const typeId = this.nodeKeyAt(layoutId, recordIndex); + return typeId === undefined ? undefined : this.#typeUrls[typeId]; + } + + nodeKeyToId(nodeKey: TypeId): VersionedUrl | undefined { + return this.#typeUrls[nodeKey]; + } + + nodeKeyAt(layoutId: ClusterId, recordIndex: number): TypeId | undefined { + const cluster = this.getClusters().get(layoutId); + if (!cluster || recordIndex < 0) { + return undefined; + } + // The type lifecycle only ever publishes the flat interleaved buffer; + // records reorder as types stream, so read the join key off the record. + return flatRecordJoinKey(cluster, recordIndex) as TypeId | undefined; + } + + /** An edge here is a link *type* between two type nodes, not a node itself. */ + resolveFlatEdge(edgeId: TypeId): FlatEdgePick | null { + const edge = this.getStructure()?.typeEdges?.[edgeId]; + if (!edge) { + return null; + } + + const source = this.#typeUrls[edge.source]; + const target = this.#typeUrls[edge.target]; + const linkType = this.#typeUrls[edge.linkTypeId]; + return source === undefined || + target === undefined || + linkType === undefined + ? null + : { kind: "edge", source, target, linkType }; + } + + queryEgo(nodeKey: TypeId): Promise> { + return new Promise((resolve) => { + this.send({ + type: "QUERY_TYPE_EGO", + requestId: this.#egoRequests.open((typeIds) => { + // No hierarchical tier: every neighbour is an individually + // rendered node, so the collapsed-cluster set is always empty. + resolve({ nodeKeys: typeIds, clusterIds: [] }); + }), + typeId: nodeKey, + }); + }); + } + + setHighlight(nodeKeys: readonly TypeId[]): void { + this.send({ + type: "SET_TYPE_HIGHLIGHT", + typeIds: nodeKeys, + }); + } + + /** No hierarchical tier to pin; the scene calls this on deselect regardless. */ + setPinned(): void {} + + /** No aggregated highways in the type lifecycle; nothing to expand. */ + queryHighwayLinks(): Promise { + return Promise.resolve([]); + } + + ingestTypes( + nodes: readonly IngestTypeNode[], + edges: readonly IngestTypeEdge[], + linkTypeSchemas: readonly TypeSchemaEntry[], + ): void { + this.send({ type: "INGEST_TYPES", nodes, edges, linkTypeSchemas }); + } + + /** Also resolves in-flight queries so awaiters don't hang after teardown. */ + override dispose(): void { + super.dispose(); + this.#egoRequests.settleAll([]); + } + + protected handleLifecycleMessage(message: LifecycleMessage): void { + switch (message.type) { + case "TYPE_ID_TABLE": + // startId always equals the current length (append-only interning, + // in-order message delivery); splice guards against a re-sent tail. + this.#typeUrls.splice( + message.startId, + message.urls.length, + ...message.urls, + ); + break; + case "TYPE_EGO_RESULT": + this.#egoRequests.settle(message.requestId, message.typeIds); + break; + default: + // Entity-lifecycle replies never arrive on an INIT_TYPE worker. + break; + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/render/use-graph-worker.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/use-graph-worker.ts new file mode 100644 index 00000000000..393a94f1a31 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/render/use-graph-worker.ts @@ -0,0 +1,84 @@ +/** + * Creates and disposes an {@link EntityWorkerConnection} on mount/unmount, + * forwards schema registration when the worker is ready, and applies config + * changes live (the worker re-tunes and re-lays out; it is not recreated). + */ +import { useEffect, useRef, useState } from "react"; + +import { defaultVizConfig } from "../config"; +import { EntityWorkerConnection } from "./entity-worker-connection"; + +import type { VizConfig } from "../config"; +import type { PropertySchemaEntry, TypeSchemaEntry } from "../worker/protocol"; +import type { EntityWorkerHandle } from "./entity-worker-connection"; + +interface UseGraphWorkerOptions { + /** + * Must be referentially stable across unrelated renders (module constant + * or memoized): a new reference is treated as a config change and applied + * to the live worker, forcing a re-layout. Changing it does NOT recreate + * the worker; ingested entities survive. + */ + readonly config?: VizConfig; + readonly typeSchemas: readonly TypeSchemaEntry[]; + readonly propertySchemas: readonly PropertySchemaEntry[]; + /** Changing this tears down and recreates the worker (clean-slate reset). */ + readonly resetKey?: string | number; +} + +interface UseGraphWorkerResult { + /** Undefined until the connection is created in the mount effect (client only). */ + readonly handle: EntityWorkerHandle | undefined; + readonly ready: boolean; + readonly error: string | undefined; +} + +export function useGraphWorker({ + config = defaultVizConfig, + typeSchemas, + propertySchemas, + resetKey, +}: UseGraphWorkerOptions): UseGraphWorkerResult { + const [connection, setConnection] = useState< + EntityWorkerConnection | undefined + >(undefined); + const [ready, setReady] = useState(false); + const [error, setError] = useState(undefined); + + // The latest config, read by the mount effect without being one of its + // dependencies: config changes are applied live below, never by recreation. + const configRef = useRef(config); + configRef.current = config; + + useEffect(() => { + const created = new EntityWorkerConnection({ + config: configRef.current, + onReady: () => setReady(true), + onError: setError, + }); + setConnection(created); + return () => { + created.dispose(); + setConnection(undefined); + setReady(false); + setError(undefined); + }; + }, [resetKey]); + + // Live config updates: the connection no-ops when `config` is the + // reference it already applied (e.g. this effect re-firing on the ready + // flip), so only real changes reach the worker. + useEffect(() => { + if (connection && ready) { + connection.updateConfig(config); + } + }, [connection, ready, config]); + + useEffect(() => { + if (connection && ready && typeSchemas.length > 0) { + connection.registerTypes(typeSchemas, propertySchemas); + } + }, [connection, ready, typeSchemas, propertySchemas]); + + return { handle: connection, ready, error }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/build-graph.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/build-graph.test.ts new file mode 100644 index 00000000000..64696c42cc4 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/build-graph.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, it } from "vitest"; + +import { ANYTHING_NODE_URL, buildTypeGraph } from "./build-graph"; + +import type { SpecialEntityTypeRecord } from "../../../../shared/entity-types-context/shared/context-types"; +import type { + EntityType, + EntityTypeWithMetadata, + VersionedUrl, +} from "@blockprotocol/type-system"; + +const url = (slug: string): VersionedUrl => + `https://hash.ai/@test/types/entity-type/${slug}/v/1` as VersionedUrl; + +interface TypeSpec { + readonly slug: string; + readonly title: string; + readonly icon?: string; + readonly inverseTitle?: string; + readonly parents?: readonly VersionedUrl[]; + /** linkTypeId -> destination ids (null = unconstrained). */ + readonly links?: Record; +} + +/** Minimal EntityTypeWithMetadata: buildTypeGraph reads only the schema. */ +function makeType(spec: TypeSpec): EntityTypeWithMetadata { + const links: NonNullable = {}; + for (const [linkTypeId, destinations] of Object.entries(spec.links ?? {})) { + links[linkTypeId as VersionedUrl] = { + type: "array", + items: destinations + ? { oneOf: destinations.map(($ref) => ({ $ref })) as never } + : {}, + }; + } + + const schema: Partial = { + kind: "entityType", + $id: url(spec.slug), + title: spec.title, + icon: spec.icon, + inverse: spec.inverseTitle ? { title: spec.inverseTitle } : undefined, + allOf: spec.parents?.map(($ref) => ({ $ref })) as EntityType["allOf"], + links, + }; + + return { schema: schema as EntityType } as EntityTypeWithMetadata; +} + +function specialLookup( + linkTypeUrls: readonly VersionedUrl[], +): Record { + const lookup: Record = {}; + for (const linkUrl of linkTypeUrls) { + lookup[linkUrl] = { isFile: false, isImage: false, isLink: true }; + } + return lookup; +} + +describe("buildTypeGraph", () => { + const hasFriend = makeType({ + slug: "has-friend", + title: "Has Friend", + inverseTitle: "Friend Of", + links: {}, + }); + const person = makeType({ + slug: "person", + title: "Person", + icon: "👤", + links: { [hasFriend.schema.$id]: [url("person")] }, + }); + + it("maps non-link types to loaded nodes and link types to edges", () => { + const graph = buildTypeGraph({ + types: [person, hasFriend], + allEntityTypes: [person, hasFriend], + isSpecialEntityTypeLookup: specialLookup([hasFriend.schema.$id]), + }); + + expect(graph.nodes).toEqual([ + expect.objectContaining({ + url: person.schema.$id, + title: "Person", + icon: "👤", + isLoaded: true, + }), + ]); + expect(graph.edges).toEqual([ + { + sourceUrl: person.schema.$id, + targetUrl: person.schema.$id, + linkTypeUrl: hasFriend.schema.$id, + }, + ]); + expect(graph.linkTypeSchemas).toEqual([ + expect.objectContaining({ + url: hasFriend.schema.$id, + title: "Has Friend", + inverseTitle: "Friend Of", + }), + ]); + expect(graph.displayInfoByUrl.get(hasFriend.schema.$id)).toEqual( + expect.objectContaining({ kind: "linkType" }), + ); + }); + + it("inherits parent links, deduped by base URL with the child override winning", () => { + const employs = makeType({ slug: "employs", title: "Employs" }); + const org = makeType({ + slug: "org", + title: "Org", + links: { [employs.schema.$id]: [url("person")] }, + }); + const company = makeType({ + slug: "company", + title: "Company", + parents: [org.schema.$id], + // Same base URL as the parent's link (same id here): must not double. + links: { [employs.schema.$id]: [url("person")] }, + }); + + const graph = buildTypeGraph({ + types: [company, person, employs, org], + allEntityTypes: [company, org, person, employs, hasFriend], + isSpecialEntityTypeLookup: specialLookup([ + employs.schema.$id, + hasFriend.schema.$id, + ]), + }); + + const companyEdges = graph.edges.filter( + (edge) => edge.sourceUrl === company.schema.$id, + ); + expect(companyEdges).toEqual([ + { + sourceUrl: company.schema.$id, + targetUrl: person.schema.$id, + linkTypeUrl: employs.schema.$id, + }, + ]); + + // The child node carries its ancestor refs (colour families). + const companyNode = graph.nodes.find( + (node) => node.url === company.schema.$id, + ); + expect(companyNode?.allOfRefs).toEqual([org.schema.$id]); + }); + + it("targets the Anything node for unconstrained destinations", () => { + const relatesTo = makeType({ slug: "relates-to", title: "Relates To" }); + const thing = makeType({ + slug: "thing", + title: "Thing", + links: { [relatesTo.schema.$id]: null }, + }); + + const graph = buildTypeGraph({ + types: [thing, relatesTo], + allEntityTypes: [thing, relatesTo], + isSpecialEntityTypeLookup: specialLookup([relatesTo.schema.$id]), + }); + + expect(graph.edges).toEqual([ + { + sourceUrl: thing.schema.$id, + targetUrl: ANYTHING_NODE_URL, + linkTypeUrl: relatesTo.schema.$id, + }, + ]); + const anythingNode = graph.nodes.find( + (node) => node.url === ANYTHING_NODE_URL, + ); + expect(anythingNode).toEqual( + expect.objectContaining({ title: "Anything", isLoaded: false }), + ); + expect(graph.displayInfoByUrl.get(ANYTHING_NODE_URL)?.kind).toBe( + "anything", + ); + }); + + it("marks out-of-view destinations as frontier nodes, titled from context when known", () => { + const owns = makeType({ slug: "owns", title: "Owns" }); + const pet = makeType({ slug: "pet", title: "Pet" }); + const remoteUrl = + "https://elsewhere.example/@other/types/entity-type/remote-widget/v/3" as VersionedUrl; + const owner = makeType({ + slug: "owner", + title: "Owner", + links: { [owns.schema.$id]: [pet.schema.$id, remoteUrl] }, + }); + + const graph = buildTypeGraph({ + // Pet is NOT displayed but IS in the loaded context; the remote url is neither. + types: [owner, owns], + allEntityTypes: [owner, owns, pet], + isSpecialEntityTypeLookup: specialLookup([owns.schema.$id]), + }); + + expect(graph.nodes).toEqual([ + expect.objectContaining({ url: owner.schema.$id, isLoaded: true }), + expect.objectContaining({ + url: pet.schema.$id, + title: "Pet", + isLoaded: false, + }), + expect.objectContaining({ + url: remoteUrl, + title: "Remote Widget", + isLoaded: false, + }), + ]); + }); + + it("materializes a link-type destination as a node and walks its own links", () => { + const endorses = makeType({ slug: "endorses", title: "Endorses" }); + const submittedBy = makeType({ + slug: "submitted-by", + title: "Submitted By", + links: { [endorses.schema.$id]: [url("person")] }, + }); + const claim = makeType({ + slug: "claim", + title: "Claim", + links: { [endorses.schema.$id]: [submittedBy.schema.$id] }, + }); + + const graph = buildTypeGraph({ + types: [claim, person, submittedBy, endorses], + allEntityTypes: [claim, person, submittedBy, endorses, hasFriend], + isSpecialEntityTypeLookup: specialLookup([ + endorses.schema.$id, + submittedBy.schema.$id, + hasFriend.schema.$id, + ]), + }); + + // The targeted link type becomes a LOADED node (its schema is local)... + expect( + graph.nodes.find((node) => node.url === submittedBy.schema.$id), + ).toEqual(expect.objectContaining({ isLoaded: true })); + // ...and its own outgoing links are walked. + expect(graph.edges).toContainEqual({ + sourceUrl: submittedBy.schema.$id, + targetUrl: person.schema.$id, + linkTypeUrl: endorses.schema.$id, + }); + }); + + it("dedupes identical (source, link, destination) triples", () => { + const knows = makeType({ slug: "knows", title: "Knows" }); + const contact = makeType({ + slug: "contact", + title: "Contact", + // Duplicate destination in oneOf. + links: { [knows.schema.$id]: [url("person"), url("person")] }, + }); + + const graph = buildTypeGraph({ + types: [contact, person, knows], + allEntityTypes: [contact, person, knows, hasFriend], + isSpecialEntityTypeLookup: specialLookup([ + knows.schema.$id, + hasFriend.schema.$id, + ]), + }); + + expect( + graph.edges.filter((edge) => edge.sourceUrl === contact.schema.$id), + ).toHaveLength(1); + }); + + it("expands a frontier type whose schema is in the loaded context", () => { + const owns = makeType({ slug: "owns", title: "Owns" }); + const chases = makeType({ slug: "chases", title: "Chases" }); + const mouse = makeType({ slug: "mouse", title: "Mouse" }); + const pet = makeType({ + slug: "pet", + title: "Pet", + links: { [chases.schema.$id]: [mouse.schema.$id] }, + }); + const owner = makeType({ + slug: "owner", + title: "Owner", + links: { [owns.schema.$id]: [pet.schema.$id] }, + }); + + const lookup = specialLookup([owns.schema.$id, chases.schema.$id]); + const baseParams = { + types: [owner, owns], + allEntityTypes: [owner, owns, pet, chases, mouse], + isSpecialEntityTypeLookup: lookup, + }; + + // Unexpanded: Pet is a frontier dead-end, its Chases link invisible. + const before = buildTypeGraph(baseParams); + expect( + before.nodes.find((node) => node.url === pet.schema.$id)?.isLoaded, + ).toBe(false); + expect(before.edges.some((edge) => edge.sourceUrl === pet.schema.$id)).toBe( + false, + ); + + // Expanded: Pet loads from the context (no fetch) and walks onward. + const after = buildTypeGraph({ + ...baseParams, + expandedUrls: new Set([pet.schema.$id]), + }); + expect( + after.nodes.find((node) => node.url === pet.schema.$id)?.isLoaded, + ).toBe(true); + expect(after.edges).toContainEqual({ + sourceUrl: pet.schema.$id, + targetUrl: mouse.schema.$id, + linkTypeUrl: chases.schema.$id, + }); + // The walk's new destination is itself a frontier node. + expect( + after.nodes.find((node) => node.url === mouse.schema.$id)?.isLoaded, + ).toBe(false); + }); + + it("expands a remote frontier type from a fetched schema, staying grey without one", () => { + const cites = makeType({ slug: "cites", title: "Cites" }); + const remoteUrl = + "https://elsewhere.example/@other/types/entity-type/paper/v/2" as VersionedUrl; + const article = makeType({ + slug: "article", + title: "Article", + links: { [cites.schema.$id]: [remoteUrl] }, + }); + + const lookup = specialLookup([cites.schema.$id]); + const baseParams = { + types: [article, cites], + allEntityTypes: [article, cites], + isSpecialEntityTypeLookup: lookup, + expandedUrls: new Set([remoteUrl]), + }; + + // Expanded but not yet fetched: remains a frontier node. + const withoutSchema = buildTypeGraph(baseParams); + expect( + withoutSchema.nodes.find((node) => node.url === remoteUrl)?.isLoaded, + ).toBe(false); + + // Once the fetch lands, the node loads with its real title and links. + const remoteSchema: EntityType = { + ...makeType({ + slug: "paper", + title: "Paper", + links: { [cites.schema.$id]: [article.schema.$id] }, + }).schema, + $id: remoteUrl, + }; + const withSchema = buildTypeGraph({ + ...baseParams, + fetchedSchemas: new Map([[remoteUrl, remoteSchema]]), + }); + expect(withSchema.nodes.find((node) => node.url === remoteUrl)).toEqual( + expect.objectContaining({ title: "Paper", isLoaded: true }), + ); + expect(withSchema.edges).toContainEqual({ + sourceUrl: remoteUrl, + targetUrl: article.schema.$id, + linkTypeUrl: cites.schema.$id, + }); + }); + + it("ignores property and data types", () => { + const graph = buildTypeGraph({ + types: [ + { + schema: { kind: "propertyType", $id: url("name") }, + } as never, + ], + allEntityTypes: [], + isSpecialEntityTypeLookup: {}, + }); + + expect(graph.nodes).toHaveLength(0); + expect(graph.edges).toHaveLength(0); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/build-graph.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/build-graph.ts new file mode 100644 index 00000000000..afada41b4c2 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/build-graph.ts @@ -0,0 +1,341 @@ +/** + * Pure mapping from the types page's ontology data to the type-graph worker's + * ingest payloads ({@link "../worker/type-graph/protocol"}). + * + * Nodes are entity types; LINK types are edges (`Person --Has Friend--> + * Person`), aggregated per (source, link type, destination) rather than drawn + * as intermediate nodes the way the old sigma view did. Two exceptions keep + * every relationship visible: + * + * - An unconstrained destination targets the one synthetic "Anything" node. + * - A link type that is itself a link DESTINATION is materialized as a node + * (otherwise the link-to-link reference would connect to nothing). + * + * A destination outside the displayed set still gets a node -- marked + * `isLoaded: false`, rendered in the frontier grey -- so filtered views show + * where their types point. Titles for those come from the entity-types + * context when known, falling back to the URL slug for remote types. + */ +import { extractBaseUrl } from "@blockprotocol/type-system"; +import { typedEntries } from "@local/advanced-types/typed-entries"; + +import type { SpecialEntityTypeRecord } from "../../../../shared/entity-types-context/shared/context-types"; +import type { TypeSchemaEntry } from "../worker/protocol"; +import type { + IngestTypeEdge, + IngestTypeNode, +} from "../worker/type-graph/protocol"; +import type { + DataTypeWithMetadata, + EntityType, + EntityTypeWithMetadata, + PropertyTypeWithMetadata, + VersionedUrl, +} from "@blockprotocol/type-system"; + +/** + * The synthetic node an unconstrained link destination points at. Not a real + * VersionedUrl: the worker treats node ids as opaque strings, and the bridge + * guards it out of type-open / fetch paths. + */ +export const ANYTHING_NODE_URL = "urn:hash:type-graph:anything" as VersionedUrl; + +/** What the bridge needs to present a node or edge label/card for a type. */ +export interface TypeDisplayInfo { + readonly title: string; + readonly icon?: string; + readonly kind: "entityType" | "linkType" | "anything"; + /** False for a referenced-but-not-displayed (frontier) type. */ + readonly isLoaded: boolean; +} + +export interface TypeGraph { + readonly nodes: readonly IngestTypeNode[]; + readonly edges: readonly IngestTypeEdge[]; + readonly linkTypeSchemas: readonly TypeSchemaEntry[]; + /** Presentation lookup for every node AND link type in the graph. */ + readonly displayInfoByUrl: ReadonlyMap; +} + +interface BuildTypeGraphParams { + /** The (possibly filtered) types the page displays. */ + readonly types: readonly ( + | DataTypeWithMetadata + | EntityTypeWithMetadata + | PropertyTypeWithMetadata + )[]; + /** Every loaded entity type (the entity-types context), for ancestor/destination lookups. */ + readonly allEntityTypes: readonly EntityTypeWithMetadata[] | null; + readonly isSpecialEntityTypeLookup: Record< + VersionedUrl, + SpecialEntityTypeRecord + > | null; + /** + * Frontier types the user expanded: when referenced by an edge they become + * loaded, walked nodes (their own links join the graph) instead of grey + * dead-ends -- provided a schema for them is known here or was fetched. + */ + readonly expandedUrls?: ReadonlySet; + /** Schemas fetched on demand for expanded types outside the loaded context. */ + readonly fetchedSchemas?: ReadonlyMap; +} + +/** Best-effort human title from a type URL slug, for remote types with no local schema. */ +function titleFromUrl(url: VersionedUrl): string { + const slug = /\/types\/entity-type\/(?[^/]+)\//.exec(url)?.groups?.slug; + if (!slug) { + return url; + } + return slug + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +/** Self + transitive ancestors (self first), walking `allOf` through the known schemas. */ +function selfAndAncestors( + entityType: EntityType, + entityTypesById: ReadonlyMap, +): EntityType[] { + const chain: EntityType[] = []; + const visited = new Set(); + const queue = [entityType]; + + for (const current of queue) { + if (visited.has(current.$id)) { + continue; + } + visited.add(current.$id); + chain.push(current); + + for (const { $ref } of current.allOf ?? []) { + const parent = entityTypesById.get($ref); + if (parent) { + queue.push(parent); + } + } + } + + return chain; +} + +/** + * The type's own links plus inherited ones, deduped by base URL (the nearest + * declaration in the chain wins, mirroring inheritance override semantics). + */ +function inheritedLinks( + chain: readonly EntityType[], +): [VersionedUrl, NonNullable[VersionedUrl]][] { + const result: [ + VersionedUrl, + NonNullable[VersionedUrl], + ][] = []; + const seenBaseUrls = new Set(); + + for (const entityType of chain) { + for (const [linkTypeId, linkSchema] of typedEntries( + entityType.links ?? {}, + )) { + const baseUrl = extractBaseUrl(linkTypeId); + if (seenBaseUrls.has(baseUrl)) { + continue; + } + seenBaseUrls.add(baseUrl); + result.push([linkTypeId, linkSchema]); + } + } + + return result; +} + +const inheritedIcon = (chain: readonly EntityType[]): string | undefined => + chain.find(({ icon }) => !!icon)?.icon; + +export function buildTypeGraph({ + types, + allEntityTypes, + isSpecialEntityTypeLookup, + expandedUrls, + fetchedSchemas, +}: BuildTypeGraphParams): TypeGraph { + const entityTypesById = new Map(); + for (const [url, schema] of fetchedSchemas ?? []) { + entityTypesById.set(url, schema); + } + for (const { schema } of allEntityTypes ?? []) { + entityTypesById.set(schema.$id, schema); + } + for (const { schema } of types) { + if (schema.kind === "entityType") { + entityTypesById.set(schema.$id, schema); + } + } + + const isLinkType = (url: VersionedUrl): boolean => + isSpecialEntityTypeLookup?.[url]?.isLink ?? false; + + const nodesByUrl = new Map(); + const edges: IngestTypeEdge[] = []; + const edgeKeys = new Set(); + const linkSchemasByUrl = new Map(); + const displayInfoByUrl = new Map(); + + const toSchemaEntry = (schema: EntityType): TypeSchemaEntry => { + const chain = selfAndAncestors(schema, entityTypesById); + return { + url: schema.$id, + title: schema.title, + inverseTitle: schema.inverse?.title, + icon: inheritedIcon(chain), + allOfRefs: chain.slice(1).map((ancestor) => ancestor.$id), + }; + }; + + /** Add (or upgrade to loaded) a node; frontier nodes never downgrade a loaded one. */ + const addNode = (url: VersionedUrl, isLoaded: boolean): void => { + const existing = nodesByUrl.get(url); + if (existing && (existing.isLoaded || !isLoaded)) { + return; + } + + const schema = entityTypesById.get(url); + const entry: TypeSchemaEntry = schema + ? toSchemaEntry(schema) + : { url, title: titleFromUrl(url), allOfRefs: [] }; + + nodesByUrl.set(url, { ...entry, isLoaded }); + displayInfoByUrl.set(url, { + title: entry.title, + icon: entry.icon, + kind: isLinkType(url) ? "linkType" : "entityType", + isLoaded, + }); + }; + + const addEdge = (edge: IngestTypeEdge): void => { + const key = `${edge.sourceUrl}\u0000${edge.linkTypeUrl}\u0000${edge.targetUrl}`; + if (!edgeKeys.has(key)) { + edgeKeys.add(key); + edges.push(edge); + } + }; + + let anythingUsed = false; + + /** + * Node types whose outgoing links still need walking. Seeded with the + * displayed non-link types; link types materialized as nodes join the + * worklist so a link-to-a-link's own outgoing links stay visible too. + */ + const walkQueue: EntityType[] = []; + const queuedUrls = new Set(); + const enqueue = (schema: EntityType): void => { + if (!queuedUrls.has(schema.$id)) { + queuedUrls.add(schema.$id); + walkQueue.push(schema); + } + }; + + for (const { schema } of types) { + if (schema.kind !== "entityType") { + // Property and data types are not visualized (matching the old view). + continue; + } + if (isLinkType(schema.$id)) { + // Link types appear as edges (or as nodes only where they are link + // destinations); unused link types are simply absent. + continue; + } + addNode(schema.$id, true); + enqueue(schema); + } + + const displayedUrls = new Set(queuedUrls); + + for (const schema of walkQueue) { + const chain = selfAndAncestors(schema, entityTypesById); + + for (const [linkTypeId, destinationSchema] of inheritedLinks(chain)) { + const linkSchema = entityTypesById.get(linkTypeId); + if (!linkSchema) { + continue; + } + + if (!linkSchemasByUrl.has(linkTypeId)) { + linkSchemasByUrl.set(linkTypeId, toSchemaEntry(linkSchema)); + // Edge hover cards resolve the link type here even when it never + // becomes a node. + if (!displayInfoByUrl.has(linkTypeId)) { + displayInfoByUrl.set(linkTypeId, { + title: linkSchema.title, + icon: inheritedIcon(selfAndAncestors(linkSchema, entityTypesById)), + kind: "linkType", + isLoaded: true, + }); + } + } + + const destinationTypeIds = + "oneOf" in destinationSchema.items + ? destinationSchema.items.oneOf.map((dest) => dest.$ref) + : null; + + if (destinationTypeIds === null) { + anythingUsed = true; + addEdge({ + sourceUrl: schema.$id, + targetUrl: ANYTHING_NODE_URL, + linkTypeUrl: linkTypeId, + }); + continue; + } + + for (const destinationTypeId of destinationTypeIds) { + const destinationIsLink = isLinkType(destinationTypeId); + // In-view destinations are already loaded nodes; everything else + // (filtered-out, remote, or a link-to-a-link target) materializes as + // a frontier node so the edge has somewhere to land. A link type + // whose schema is loaded -- or any type the user expanded -- walks + // onward so ITS links stay visible. + if (!displayedUrls.has(destinationTypeId)) { + const destinationLoaded = + (destinationIsLink || + (expandedUrls?.has(destinationTypeId) ?? false)) && + entityTypesById.has(destinationTypeId); + addNode(destinationTypeId, destinationLoaded); + if (destinationLoaded) { + enqueue(entityTypesById.get(destinationTypeId)!); + } + } + addEdge({ + sourceUrl: schema.$id, + targetUrl: destinationTypeId, + linkTypeUrl: linkTypeId, + }); + } + } + } + + if (anythingUsed) { + nodesByUrl.set(ANYTHING_NODE_URL, { + url: ANYTHING_NODE_URL, + title: "Anything", + allOfRefs: [], + // Frontier styling (grey) fits "no constraint" visually; the bridge + // excludes this url from open/fetch actions. + isLoaded: false, + }); + displayInfoByUrl.set(ANYTHING_NODE_URL, { + title: "Anything", + kind: "anything", + isLoaded: false, + }); + } + + return { + nodes: [...nodesByUrl.values()], + edges, + linkTypeSchemas: [...linkSchemasByUrl.values()], + displayInfoByUrl, + }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/hover-card.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/hover-card.tsx new file mode 100644 index 00000000000..81d8062b656 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/hover-card.tsx @@ -0,0 +1,241 @@ +/** + * Hover / selection card for a type node, and the smaller card for a hovered + * link-type edge. Same shell + per-frame positioning pattern as the entity + * lifecycle's `entity-graph/hover-card.tsx`: a GPU-transformed click-through + * wrapper moves every pan frame, the memoized body lays out once per subject. + */ +import { Box, Divider, Typography } from "@mui/material"; +import { keyframes } from "@mui/system"; +import { memo } from "react"; + +import { EntityOrTypeIcon } from "@hashintel/design-system"; + +import { Button } from "../../../../shared/ui/button"; + +import type { Position } from "../geometry"; +import type { TypeDisplayInfo } from "./build-graph"; + +const rise = keyframes` + from { opacity: 0; transform: translateY(3px); } + to { opacity: 1; transform: translateY(0); } +`; + +const KIND_LABELS: Record = { + entityType: "Entity Type", + linkType: "Link Type", + anything: "Any entity type", +}; + +interface TypeHoverCardProps extends Position { + readonly display: TypeDisplayInfo; + /** + * When set, the card is "pinned" (a selection, not a hover): it renders an + * Open action that calls this. Must be referentially stable across pans, or + * the memoized body re-renders every frame. + */ + readonly onOpen?: () => void; +} + +type TypeHoverCardBodyProps = Omit; + +const TypeHoverCardBodyComponent = ({ + display, + onOpen, +}: TypeHoverCardBodyProps) => ( + ({ + position: "relative", + minWidth: 190, + maxWidth: 280, + bgcolor: onOpen ? palette.blue[10] : palette.common.white, + border: `1px solid ${onOpen ? palette.blue[30] : palette.gray[20]}`, + borderRadius: "8px", + boxShadow: boxShadows.md, + animation: `${rise} 130ms cubic-bezier(0.22, 1, 0.36, 1)`, + "@media (prefers-reduced-motion: reduce)": { animation: "none" }, + })} + > + + ({ + flexShrink: 0, + width: 32, + height: 32, + borderRadius: "7px", + bgcolor: palette.gray[10], + border: `1px solid ${palette.gray[20]}`, + display: "flex", + alignItems: "center", + justifyContent: "center", + })} + > + palette.gray[70]} + /> + + + palette.gray[90], + wordBreak: "break-word", + }} + > + {display.title} + + palette.gray[70], + }} + > + {KIND_LABELS[display.kind]} + {display.kind !== "anything" && !display.isLoaded + ? " · not in view" + : ""} + + + + + {onOpen ? ( + <> + palette.gray[20] }} /> + + + ) : null} + +); + +const TypeHoverCardBody = memo(TypeHoverCardBodyComponent); + +export const TypeHoverCard = ({ + display, + x, + y, + onOpen, +}: TypeHoverCardProps) => ( +
+ +
+); + +interface EdgeHoverCardProps extends Position { + readonly linkTypeTitle: string; + readonly sourceTitle: string; + readonly targetTitle: string; +} + +const EdgeHoverCardBodyComponent = ({ + linkTypeTitle, + sourceTitle, + targetTitle, +}: Omit) => ( + ({ + minWidth: 170, + maxWidth: 280, + px: 1.75, + py: 1.25, + bgcolor: palette.common.white, + border: `1px solid ${palette.gray[20]}`, + borderRadius: "8px", + boxShadow: boxShadows.md, + animation: `${rise} 130ms cubic-bezier(0.22, 1, 0.36, 1)`, + "@media (prefers-reduced-motion: reduce)": { animation: "none" }, + })} + > + palette.gray[90], + }} + > + {linkTypeTitle} + + palette.gray[70], + }} + > + {sourceTitle} → {targetTitle} + + +); + +const EdgeHoverCardBody = memo(EdgeHoverCardBodyComponent); + +/** The hovered link-type edge's summary, anchored to the edge. */ +export const EdgeHoverCard = ({ + linkTypeTitle, + sourceTitle, + targetTitle, + x, + y, +}: EdgeHoverCardProps) => ( +
+ +
+); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/overlays.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/overlays.tsx new file mode 100644 index 00000000000..5af18198595 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/overlays.tsx @@ -0,0 +1,120 @@ +/** + * Leaf components for the type lifecycle's HTML overlays (hover / selection / + * edge cards, always-on labels). Each subscribes to exactly the + * {@link SceneOverlayStore} slice it renders, so the Scene's per-frame + * position re-emissions re-render only the one affected leaf -- never the + * bridge component that owns the worker (see the entity mirror, + * `entity-graph/overlays.tsx`). + */ +import { NodeLabelOverlay } from "../components/node-label-overlay"; +import { useOverlaySlice } from "../components/scene-overlay-store"; +import { EdgeHoverCard, TypeHoverCard } from "./hover-card"; + +import type { SceneOverlayStore } from "../components/scene-overlay-store"; +import type { TypeDisplayInfo } from "./build-graph"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +interface TypeOverlayContext { + readonly overlayStore: SceneOverlayStore; + readonly getDisplayInfo: (url: VersionedUrl) => TypeDisplayInfo | undefined; +} + +/** + * The transient hover card over a type dot. Suppressed while that same dot is + * pinned (selected) so the two cards don't stack. + */ +export const TypeHoverOverlay = ({ + overlayStore, + getDisplayInfo, +}: TypeOverlayContext) => { + const hover = useOverlaySlice(overlayStore.nodeHover); + const selection = useOverlaySlice(overlayStore.selection); + + if (hover === null || hover.nodeId === selection?.nodeId) { + return null; + } + + const display = getDisplayInfo(hover.nodeId); + if (display === undefined) { + return null; + } + + return ; +}; + +interface TypeSelectionOverlayProps extends TypeOverlayContext { + /** Open the selected type (undefined for the synthetic Anything node). */ + readonly onOpen?: () => void; +} + +/** + * The selected type's pinned card (with an Open action) that tracks the + * node's on-screen position through settle + pan/zoom. + */ +export const TypeSelectionOverlay = ({ + overlayStore, + getDisplayInfo, + onOpen, +}: TypeSelectionOverlayProps) => { + const selection = useOverlaySlice(overlayStore.selection); + + if (selection === null) { + return null; + } + + const display = getDisplayInfo(selection.nodeId); + if (display === undefined) { + return null; + } + + return ( + + ); +}; + +/** The hovered link-type edge's card, naming the link and its endpoints. */ +export const TypeEdgeHoverOverlay = ({ + overlayStore, + getDisplayInfo, +}: TypeOverlayContext) => { + const edgeHover = useOverlaySlice(overlayStore.edgeHover); + + if (edgeHover === null) { + return null; + } + + const linkType = getDisplayInfo(edgeHover.linkType); + const source = getDisplayInfo(edgeHover.source); + const target = getDisplayInfo(edgeHover.target); + if (!linkType) { + return null; + } + + return ( + + ); +}; + +/** + * The always-on type labels, re-emitted by the Scene each frame with current + * on-screen positions so they track the camera + settling layout. + */ +export const TypeLabelsOverlay = ({ + overlayStore, +}: Pick) => { + const labels = useOverlaySlice(overlayStore.nodeLabels); + + return ; +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/use-frontier-expansion.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/use-frontier-expansion.ts new file mode 100644 index 00000000000..e62b84e1e2f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/use-frontier-expansion.ts @@ -0,0 +1,163 @@ +/** + * Frontier expansion for the TYPE graph: fetching the schemas of types that + * are referenced by an edge but not in the displayed set (filtered out, on + * another web, or hosted remotely), so the user can walk outward from the + * current view -- the type mirror of the entity graph's + * {@link "../entity-graph/use-frontier-expansion"}. + * + * Expansion is two state sets feeding {@link "./build-graph"}: + * + * - `expandedUrls`: types the user asked to expand. Build marks a referenced + * expanded type as loaded and walks its own links -- IF its schema is known. + * - `fetchedSchemas`: schemas retrieved here for expanded types outside the + * entity-types context, queried by exact versioned URL. A URL the graph + * store doesn't know (e.g. an unfetched remote type) stays a grey frontier + * node and is remembered as failed so it is not re-queried. + */ +import { useLazyQuery } from "@apollo/client"; +import { useCallback, useMemo, useRef, useState } from "react"; + +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; + +import { queryEntityTypesQuery } from "../../../../graphql/queries/ontology/entity-type.queries"; + +import type { + QueryEntityTypesQuery, + QueryEntityTypesQueryVariables, +} from "../../../../graphql/api-types.gen"; +import type { EntityType, VersionedUrl } from "@blockprotocol/type-system"; + +export interface TypeFrontierExpansion { + /** Types the user expanded (select or load-all), loaded or still fetching. */ + readonly expandedUrls: ReadonlySet; + /** Schemas fetched on demand, keyed by their versioned URL. */ + readonly fetchedSchemas: ReadonlyMap; + /** How many expansions are awaiting their schema fetch. */ + readonly pendingCount: number; + readonly error: string | undefined; + /** + * Expand frontier types: mark them expanded and fetch any whose schema is + * not already known. URLs already expanded, in flight, or previously not + * found are skipped. + */ + readonly expandTypes: (urls: readonly VersionedUrl[]) => void; +} + +interface UseTypeFrontierExpansionParams { + /** Whether a schema for this URL is already loaded client-side (no fetch needed). */ + readonly isSchemaKnown: (url: VersionedUrl) => boolean; +} + +export function useTypeFrontierExpansion({ + isSchemaKnown, +}: UseTypeFrontierExpansionParams): TypeFrontierExpansion { + const [expandedUrls, setExpandedUrls] = useState>( + new Set(), + ); + const [fetchedSchemas, setFetchedSchemas] = useState< + ReadonlyMap + >(new Map()); + const [pendingCount, setPendingCount] = useState(0); + const [error, setError] = useState(undefined); + + // Mutable mirrors of the gating sets: expansion must be idempotent from + // within one event (state updates are async), and fetch bookkeeping never + // needs to trigger a render by itself. + const expanded = useRef(new Set()); + const inFlight = useRef(new Set()); + const notFound = useRef(new Set()); + + const [queryEntityTypes] = useLazyQuery< + QueryEntityTypesQuery, + QueryEntityTypesQueryVariables + >(queryEntityTypesQuery, { fetchPolicy: "cache-first" }); + + const expandTypes = useCallback( + (urls: readonly VersionedUrl[]) => { + const newlyExpanded = urls.filter((url) => !expanded.current.has(url)); + if (newlyExpanded.length === 0) { + return; + } + + for (const url of newlyExpanded) { + expanded.current.add(url); + } + setExpandedUrls(new Set(expanded.current)); + + const toFetch = newlyExpanded.filter( + (url) => + !isSchemaKnown(url) && + !inFlight.current.has(url) && + !notFound.current.has(url), + ); + if (toFetch.length === 0) { + return; + } + + for (const url of toFetch) { + inFlight.current.add(url); + } + setPendingCount((count) => count + toFetch.length); + + void queryEntityTypes({ + variables: { + request: { + filter: { + any: toFetch.map((url) => ({ + equal: [{ path: ["versionedUrl"] }, { parameter: url }], + })), + }, + temporalAxes: currentTimeInstantTemporalAxes, + }, + }, + }) + .then((response) => { + const returned = response.data?.queryEntityTypes.entityTypes ?? []; + + setFetchedSchemas((previous) => { + const next = new Map(previous); + for (const { schema } of returned) { + next.set(schema.$id, schema); + } + return next; + }); + + const returnedUrls = new Set( + returned.map(({ schema }) => schema.$id), + ); + for (const url of toFetch) { + if (!returnedUrls.has(url)) { + // Unknown to this instance's graph store -- leave the node a + // grey frontier dot rather than re-querying forever. + notFound.current.add(url); + } + } + + if (response.error) { + setError(response.error.message); + } + }) + .catch((fetchError: Error) => { + setError(fetchError.message); + }) + .finally(() => { + for (const url of toFetch) { + inFlight.current.delete(url); + } + setPendingCount((count) => count - toFetch.length); + }); + }, + [isSchemaKnown, queryEntityTypes], + ); + + return useMemo( + () => ({ + expandedUrls, + fetchedSchemas, + pendingCount, + error, + expandTypes, + }), + [expandedUrls, fetchedSchemas, pendingCount, error, expandTypes], + ); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/use-worker.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/use-worker.ts new file mode 100644 index 00000000000..7e9d9c8089a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/use-worker.ts @@ -0,0 +1,72 @@ +/** + * Creates and disposes a {@link TypeWorkerConnection} on mount/unmount and + * applies config changes live -- the type-lifecycle mirror of + * {@link "../render/use-graph-worker"}. No schema registration step: type + * schemas ride the ingest batches themselves. + */ +import { useEffect, useRef, useState } from "react"; + +import { defaultVizConfig } from "../config"; +import { TypeWorkerConnection } from "../render/type-worker-connection"; + +import type { VizConfig } from "../config"; +import type { TypeWorkerHandle } from "../render/type-worker-connection"; + +interface UseTypeGraphWorkerOptions { + /** + * Must be referentially stable across unrelated renders: a new reference is + * applied to the live worker as a config change (re-tune + re-layout), not + * a recreation. + */ + readonly config?: VizConfig; + /** Changing this tears down and recreates the worker (clean-slate reset). */ + readonly resetKey?: string | number; +} + +interface UseTypeGraphWorkerResult { + /** Undefined until the connection is created in the mount effect (client only). */ + readonly handle: TypeWorkerHandle | undefined; + readonly ready: boolean; + readonly error: string | undefined; +} + +export function useTypeGraphWorker({ + config = defaultVizConfig, + resetKey, +}: UseTypeGraphWorkerOptions): UseTypeGraphWorkerResult { + const [connection, setConnection] = useState< + TypeWorkerConnection | undefined + >(undefined); + const [ready, setReady] = useState(false); + const [error, setError] = useState(undefined); + + // The latest config, read by the mount effect without being one of its + // dependencies: config changes are applied live below, never by recreation. + const configRef = useRef(config); + configRef.current = config; + + useEffect(() => { + const created = new TypeWorkerConnection({ + config: configRef.current, + onReady: () => setReady(true), + onError: setError, + }); + setConnection(created); + return () => { + created.dispose(); + setConnection(undefined); + setReady(false); + setError(undefined); + }; + }, [resetKey]); + + // Live config updates: the connection no-ops when `config` is the reference + // it already applied, so only real changes reach the worker. + useEffect(() => { + if (connection && ready) { + connection.updateConfig(config); + } + }, [connection, ready, config]); + + return { handle: connection, ready, error }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/visualizer.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/visualizer.tsx new file mode 100644 index 00000000000..3c4e5482f08 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/type-graph/visualizer.tsx @@ -0,0 +1,280 @@ +/** + * Production bridge from the types page's ontology data to the type-graph + * worker lifecycle (`INIT_TYPE`) and overlay UI -- the type mirror of + * `entity-graph/visualizer.tsx`, and the drop-in replacement for the old + * sigma-based type graph. + * + * It maps schemas to nodes/edges (`build-graph.ts`), feeds them to the worker + * (`use-worker.ts`), and renders the HTML overlays. The Scene's per-frame + * position reports flow through the {@link SceneOverlayStore} into leaf + * overlay components, so this component itself re-renders only when the type + * data changes. + */ +import { Box } from "@mui/material"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; + +import { useEntityTypesContextRequired } from "../../../../shared/entity-types-context/hooks/use-entity-types-context-required"; +import { FrontierControls } from "../components/frontier-controls"; +import { GraphStatusOverlay } from "../components/graph-status-overlay"; +import { SceneOverlayStore } from "../components/scene-overlay-store"; +import { GraphVisualizer } from "../graph-visualizer"; +import { useSimulationPause } from "../interactivity/use-simulation-pause"; +import { ANYTHING_NODE_URL, buildTypeGraph } from "./build-graph"; +import { + TypeEdgeHoverOverlay, + TypeHoverOverlay, + TypeLabelsOverlay, + TypeSelectionOverlay, +} from "./overlays"; +import { useTypeFrontierExpansion } from "./use-frontier-expansion"; +import { useTypeGraphWorker } from "./use-worker"; + +import type { NodeSelection } from "../render/scene/callbacks"; +import type { LabelPolicy } from "../render/scene/scene"; +import type { + DataTypeWithMetadata, + EntityTypeWithMetadata, + PropertyTypeWithMetadata, + VersionedUrl, +} from "@blockprotocol/type-system"; + +interface TypeGraphVisualizerProps { + readonly types: readonly ( + | DataTypeWithMetadata + | EntityTypeWithMetadata + | PropertyTypeWithMetadata + )[]; + /** Open a type's page/slide, wired to the selection card's Open action. */ + readonly onTypeClick: (typeId: VersionedUrl) => void; +} + +/** + * Label every node (the entity policy labels only hubs): at ontology scale + * dots are meaningless without names. HubLabels' collision culling still + * applies, so dense areas thin out rather than overlap. + */ +const TYPE_LABEL_POLICY: Partial = { + minRadius: 0, + maxCount: Number.POSITIVE_INFINITY, + minScreenDiameter: 0, +}; + +export const TypeGraphVisualizer: React.FC = memo( + ({ types, onTypeClick }) => { + const { entityTypes, isSpecialEntityTypeLookup } = + useEntityTypesContextRequired(); + + const knownSchemaUrls = useMemo( + () => new Set((entityTypes ?? []).map(({ schema }) => schema.$id)), + [entityTypes], + ); + const isSchemaKnown = useCallback( + (url: VersionedUrl) => knownSchemaUrls.has(url), + [knownSchemaUrls], + ); + + const frontier = useTypeFrontierExpansion({ isSchemaKnown }); + + const graph = useMemo( + () => + buildTypeGraph({ + types, + allEntityTypes: entityTypes, + isSpecialEntityTypeLookup, + expandedUrls: frontier.expandedUrls, + fetchedSchemas: frontier.fetchedSchemas, + }), + [ + types, + entityTypes, + isSpecialEntityTypeLookup, + frontier.expandedUrls, + frontier.fetchedSchemas, + ], + ); + + /** Frontier nodes still expandable (the synthetic Anything node is not). */ + const frontierUrls = useMemo( + () => + [...graph.displayInfoByUrl] + .filter( + ([url, info]) => + !info.isLoaded && + info.kind !== "anything" && + !frontier.expandedUrls.has(url), + ) + .map(([url]) => url), + [graph, frontier.expandedUrls], + ); + + // The displayed type SET is the worker's source identity: ingest is + // additive (no retract), so a different set -- a filter change -- must + // recreate the worker for a clean re-ingest. Same set, richer context + // (e.g. the entity-types context finishing its load) keeps the worker and + // re-ingests idempotently. + const resetKey = useMemo( + () => + types + .map(({ schema }) => schema.$id) + .sort() + .join("|"), + [types], + ); + + const { handle, ready, error } = useTypeGraphWorker({ resetKey }); + + useEffect(() => { + if (handle && ready && graph.nodes.length > 0) { + handle.ingestTypes(graph.nodes, graph.edges, graph.linkTypeSchemas); + } + }, [handle, ready, graph]); + + const containerRef = useSimulationPause({ + handle, + ready, + occluded: false, + }); + + const [overlayStore] = useState( + () => new SceneOverlayStore(), + ); + // A recreated worker gets a fresh Scene: clear the old scene's overlay + // reports so nothing stale lingers over the new canvas. + useEffect( + () => () => { + overlayStore.reset(); + }, + [overlayStore, handle], + ); + + const getDisplayInfo = useCallback( + (url: VersionedUrl) => graph.displayInfoByUrl.get(url), + [graph], + ); + + const resolveNodeLabel = useCallback( + (url: VersionedUrl) => graph.displayInfoByUrl.get(url)?.title, + [graph], + ); + + const resolveNodeIcon = useCallback( + (url: VersionedUrl) => graph.displayInfoByUrl.get(url)?.icon ?? null, + [graph], + ); + + // Reads the live slice so the handler stays referentially stable while + // the selection tracks pan frames. + const openSelectedType = useCallback(() => { + const selection = overlayStore.selection.getValue(); + + if (selection && selection.nodeId !== ANYTHING_NODE_URL) { + onTypeClick(selection.nodeId); + } + }, [overlayStore, onTypeClick]); + + const { expandTypes } = frontier; + + // Selecting a frontier node expands it: fetch its schema (if needed) and + // walk its links into the graph on the rebuild that follows. Loaded nodes + // just select, and therefore no expansion churn. + const handleNodeSelect = useCallback( + (selection: NodeSelection | null) => { + overlayStore.selection.setValue(selection); + if (selection && selection.nodeId !== ANYTHING_NODE_URL) { + const info = graph.displayInfoByUrl.get(selection.nodeId); + if (info && !info.isLoaded) { + expandTypes([selection.nodeId]); + } + } + }, + [overlayStore, expandTypes, graph], + ); + + const expandAllFrontier = useCallback(() => { + expandTypes(frontierUrls); + }, [expandTypes, frontierUrls]); + + if (error) { + return ( + + + + ); + } + + if (!handle) { + return ( + + + + ); + } + + if (ready && graph.nodes.length === 0) { + return ( + + + + ); + } + + return ( + + + } + onNodeHover={overlayStore.nodeHover.setValue} + onNodeSelect={handleNodeSelect} + onEdgeHover={overlayStore.edgeHover.setValue} + resolveNodeLabel={resolveNodeLabel} + resolveNodeIcon={resolveNodeIcon} + onNodeLabels={overlayStore.nodeLabels.setValue} + labelPolicy={TYPE_LABEL_POLICY} + /> + 0} + fetchedCount={frontier.fetchedSchemas.size} + totalToFetch={frontier.fetchedSchemas.size + frontier.pendingCount} + error={frontier.error} + noun="type" + onFetchCompleteFrontier={expandAllFrontier} + /> + + + + + + ); + }, +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/visual-style.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/visual-style.ts new file mode 100644 index 00000000000..e02d88bc352 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/visual-style.ts @@ -0,0 +1,39 @@ +import { hslToRgb } from "./math/color"; + +import type { Color } from "./frames"; + +export const graphCanvasBackground = "#F7FAFC"; + +/** + * Named graph mark colours. These are presentation semantics, not a generic palette: + * - frontier / fallback recede behind query-root entities, + * - grouping marks stay translucent so topology remains legible, + * - selection is the only saturated product-blue state, + * - edge support colours are neutral so typed edge hues carry the data. + */ +export const graphColors = { + frontier: [116, 130, 148, 150], + frontierHalo: [72, 136, 216, 92], + fallbackEntity: [126, 142, 160, 220], + collapsedEdge: [112, 126, 143, 180], + fanOutEdge: [128, 142, 158, 88], + selection: [7, 117, 227, 255], + selectionHalo: [72, 179, 244, 44], + clusterStroke: [255, 255, 255, 58], + edgeUnderlay: [255, 255, 255, 76], + edgeLabelText: [55, 67, 79, 255], + edgeLabelBackground: [255, 255, 255, 230], +} as const satisfies Record; + +export function colorWithAlpha( + color: readonly [number, number, number], + alpha: number, +): Color { + return [color[0], color[1], color[2], alpha]; +} + +/** Community hulls are looser than hierarchy bubbles, so they use lower saturation/alpha. */ +export function communityColorForId(id: number): Color { + const hue = ((id * 0.618033988749895) % 1) * 360; + return colorWithAlpha(hslToRgb(hue, 0.34, 0.66), 38); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/bench-fixtures.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/bench-fixtures.ts new file mode 100644 index 00000000000..52e5934e54b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/bench-fixtures.ts @@ -0,0 +1,388 @@ +/** + * Deterministic synthetic-graph builders for layout benchmarks. + * + * Nodes and links share one contiguous {@link benchEntityId} index space: + * node indices are `[0, nodeCount)`, and link indices continue from + * `nodeCount`. Builders that draw independent randomness (edge pairs, hub + * leaves) offset their seed from the caller's `seed` so their draws do not + * correlate with `buildIngestEntities`'s. Every builder is a pure function + * of its shape and seed, so a bench replays the identical graph across runs. + */ +import { entityIdFromComponents } from "@blockprotocol/type-system"; + +import { EntityIndex, TypeSetId } from "../ids"; +import { mulberry32 } from "../math/random"; +import { Column } from "./collections/column"; +import { LinkStore } from "./entity-graph/store/link"; +import { radiusForDegree } from "./entity-style"; + +import type { ForceEdge, ForceNode } from "./layout/force-simulation"; +import type { + CapturedLayoutFixture, + IngestEntity, + TypeSchemaEntry, +} from "./protocol"; +import type { + EntityId, + EntityUuid, + LinkData, + VersionedUrl, + WebId, +} from "@blockprotocol/type-system"; + +const WEB_ID = "11111111-1111-4111-8111-111111111111" as WebId; + +/** + * Builds a deterministic {@link EntityId} from a contiguous index; node + * indices are `[0, nodeCount)` and link indices continue from `nodeCount`. + */ +export function benchEntityId(index: number): EntityId { + const uuid = + `22222222-2222-4222-8222-${index.toString(16).padStart(12, "0")}` as EntityUuid; + return entityIdFromComponents(WEB_ID, uuid); +} + +export function benchNodeTypeUrl(typeIndex: number): VersionedUrl { + return `https://example.com/types/entity-type/bench-node-${typeIndex}/v/1` as VersionedUrl; +} + +export const BENCH_LINK_TYPE_URL = + "https://example.com/types/entity-type/bench-link/v/1" as VersionedUrl; + +/** Knobs shaping a synthetic graph. `linkCount` links point mostly at the first `hubCount` nodes. */ +export interface GraphShape { + readonly nodeCount: number; + readonly linkCount: number; + /** Distinct node entity types (each node is assigned one). */ + readonly typeCount: number; + /** High-degree hubs that most links point into (skews the degree distribution, like real data). */ + readonly hubCount: number; + /** Fraction [0,1] of nodes that are query roots; the rest are frontier nodes. */ + readonly rootFraction: number; + readonly seed: number; +} + +/** Type schemas for `typeCount` flat (parentless) node types plus the one link type. */ +export function benchTypeSchemas(typeCount: number): TypeSchemaEntry[] { + const schemas: TypeSchemaEntry[] = []; + for (let typeIndex = 0; typeIndex < typeCount; typeIndex++) { + schemas.push({ + url: benchNodeTypeUrl(typeIndex), + title: `Bench Node ${typeIndex}`, + allOfRefs: [], + }); + } + schemas.push({ + url: BENCH_LINK_TYPE_URL, + title: "Bench Link", + inverseTitle: "Bench Link Of", + allOfRefs: [], + }); + return schemas; +} + +/** Pick a link target with a hub bias: ~70% of links point at one of the first `hubCount` nodes. */ +function pickTarget( + random: () => number, + nodeCount: number, + hubCount: number, +): number { + if (hubCount > 0 && random() < 0.7) { + return Math.floor(random() * hubCount) % nodeCount; + } + return Math.floor(random() * nodeCount); +} + +/** + * `nodeCount` node entities followed by `linkCount` link entities + * with hub-biased endpoints. + */ +export function buildIngestEntities(shape: GraphShape): IngestEntity[] { + const { nodeCount, linkCount, typeCount, hubCount, rootFraction, seed } = + shape; + const random = mulberry32(seed); + const entities: IngestEntity[] = []; + const rootCutoff = Math.round(nodeCount * rootFraction); + + for (let nodeIndex = 0; nodeIndex < nodeCount; nodeIndex++) { + const typeIndex = Math.floor(random() * Math.max(1, typeCount)); + entities.push({ + entityId: benchEntityId(nodeIndex), + entityTypeIds: [benchNodeTypeUrl(typeIndex)], + isLink: false, + isRoot: nodeIndex < rootCutoff, + }); + } + + for (let linkIndex = 0; linkIndex < linkCount; linkIndex++) { + const left = Math.floor(random() * nodeCount); + let right = pickTarget(random, nodeCount, hubCount); + if (right === left) { + right = (right + 1) % nodeCount; + } + // Synthetic endpoints only: left/right ids are always interned node + // EntityIds, satisfying LinkData for bench ingest. + const linkData = { + leftEntityId: benchEntityId(left), + rightEntityId: benchEntityId(right), + } as LinkData; + entities.push({ + entityId: benchEntityId(nodeCount + linkIndex), + entityTypeIds: [BENCH_LINK_TYPE_URL], + isLink: true, + isRoot: false, + linkData, + }); + } + + return entities; +} + +/** Undirected edge index pairs (local indices `[0, nodeCount)`), hub-biased, deduplicated. */ +function buildEdgePairs(shape: GraphShape): [number, number][] { + const { nodeCount, linkCount, hubCount, seed } = shape; + // Offset the seed so edge pairs draw a different stream than buildIngestEntities. + const random = mulberry32(seed + 0x9e3779b9); + const seen = new Set(); + const pairs: [number, number][] = []; + for (let linkIndex = 0; linkIndex < linkCount; linkIndex++) { + const left = Math.floor(random() * nodeCount); + let right = pickTarget(random, nodeCount, hubCount); + if (right === left) { + right = (right + 1) % nodeCount; + } + const lo = Math.min(left, right); + const hi = Math.max(left, right); + const key = lo * nodeCount + hi; + if (seen.has(key)) { + continue; + } + seen.add(key); + pairs.push([lo, hi]); + } + return pairs; +} + +/** + * Force-layout graph. Radii scale with degree, start positions are spread + * via golden-angle so nodes are not stacked on the origin. + */ +export function buildForceGraph(shape: GraphShape): { + readonly nodes: ForceNode[]; + readonly edges: ForceEdge[]; +} { + const pairs = buildEdgePairs(shape); + const degree = new Int32Array(shape.nodeCount); + // left/right are local indices from buildEdgePairs, always in [0, nodeCount). + for (const [left, right] of pairs) { + degree[left]! += 1; + degree[right]! += 1; + } + + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + const nodes: ForceNode[] = []; + for (let nodeIndex = 0; nodeIndex < shape.nodeCount; nodeIndex++) { + const distance = 20 * Math.sqrt(nodeIndex + 1); + const angle = nodeIndex * goldenAngle; + nodes.push({ + id: String(nodeIndex), + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + radius: radiusForDegree(degree[nodeIndex]!), + }); + } + + const edges: ForceEdge[] = pairs.map(([left, right]) => ({ + source: String(left), + target: String(right), + weight: 1, + })); + + return { nodes, edges }; +} + +/** One near-coincident super-hub to graft onto a cloud: leaf count + hub centre. */ +export interface CoincidentHubSpec { + readonly leaves: number; + readonly x: number; + readonly y: number; +} + +/** + * A {@link buildForceGraph} cloud plus one or more super-hubs, each with `leaves` + * degree-1 leaves initialised near-coincident with its hub centre. This reproduces + * the production pathology where a hub's worth of near-coincident leaves reach the + * overlap-resolution phase as one tight pile-up. It remains the stress fixture for + * the majorization engine's projection + hub floors. The extra node/edge ids continue the cloud's + * index space. + */ +export function buildForceGraphWithCoincidentHubs( + shape: GraphShape, + hubs: readonly CoincidentHubSpec[], +): { + readonly nodes: ForceNode[]; + readonly edges: ForceEdge[]; +} { + const { nodes, edges } = buildForceGraph(shape); + const random = mulberry32(shape.seed + 0x5bd1e995); + + let nextIndex = shape.nodeCount; + for (const hub of hubs) { + const hubIndex = nextIndex; + nextIndex += 1 + hub.leaves; + + nodes.push({ + id: String(hubIndex), + x: hub.x, + y: hub.y, + radius: radiusForDegree(hub.leaves), + }); + for (let leaf = 0; leaf < hub.leaves; leaf++) { + const leafIndex = hubIndex + 1 + leaf; + // ~40px from the shared centre, mutually overlapping (the real hub geometry). + const angle = random() * Math.PI * 2; + const distance = 40 * random(); + nodes.push({ + id: String(leafIndex), + x: hub.x + Math.cos(angle) * distance, + y: hub.y + Math.sin(angle) * distance, + radius: radiusForDegree(1), + }); + edges.push({ + source: String(hubIndex), + target: String(leafIndex), + weight: 1, + }); + } + } + + return { nodes, edges }; +} + +/** Builds a force graph with one near-coincident super-hub centred at the origin. */ +export function buildForceGraphWithCoincidentHub( + shape: GraphShape, + hubLeaves: number, +): { + readonly nodes: ForceNode[]; + readonly edges: ForceEdge[]; +} { + return buildForceGraphWithCoincidentHubs(shape, [ + { leaves: hubLeaves, x: 0, y: 0 }, + ]); +} + +/** + * The user's real graph shape, as a deterministic fixture: a ~700-node sparse + * background cloud plus TWO ~150-leaf near-coincident super-hubs (~1000 nodes + * total). This is the primary relayout-motion gate: two dense hubs whose spokes + * are geometrically infeasible at the plain ideal edge length, embedded in a + * cloud sparse enough that the hubs dominate the drawing. + */ +export function buildRealShapeFixture(seed = 7_001): { + readonly nodes: ForceNode[]; + readonly edges: ForceEdge[]; +} { + const cloudCount = 700; + const shape: GraphShape = { + nodeCount: cloudCount, + linkCount: Math.round(cloudCount * 1.5), + typeCount: 1, + hubCount: 6, + rootFraction: 1, + seed, + }; + // Hub centres sit inside the seeded cloud (radius ~20·√700 ≈ 530), far enough + // apart that the two piles start as distinct near-coincident clumps. + return buildForceGraphWithCoincidentHubs(shape, [ + { leaves: 150, x: -220, y: -40 }, + { leaves: 150, x: 240, y: 60 }, + ]); +} + +/** + * Replays a {@link CapturedLayoutFixture} as layout-engine inputs: real node + * ids, radii, and (unless `scrambleSeed` is set) the captured live positions, + * so warm-relayout scenarios replay from the exact geometry that was + * captured. When `scrambleSeed` is set, positions are re-seeded from a + * deterministic phyllotaxis scatter instead, for testing cold layout on the + * same topology. The captured Louvain labels ride along in `communities` + * when their length matches `nodes`, for metrics that want the partition + * that was on screen at capture time. + */ +export function forceGraphFromCapturedFixture( + fixture: CapturedLayoutFixture, + options: { readonly scrambleSeed?: number } = {}, +): { + readonly nodes: ForceNode[]; + readonly edges: ForceEdge[]; + /** Captured Louvain label per node (parallel to `nodes`; -1 = unassigned). */ + readonly communities: number[]; +} { + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + const scramble = + options.scrambleSeed === undefined + ? undefined + : mulberry32(options.scrambleSeed); + const nodes: ForceNode[] = fixture.nodes.map((node, index) => { + if (!scramble) { + return { id: node.id, x: node.x, y: node.y, radius: node.radius }; + } + // Deterministic phyllotaxis scatter (the same cold-start shape the synthetic + // builders use), with the golden-angle sequence jittered by the seed so two + // scrambles of the same capture differ. + const distance = 20 * Math.sqrt(index + 1); + const angle = index * goldenAngle + scramble() * Math.PI * 2; + return { + id: node.id, + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + radius: node.radius, + }; + }); + return { + nodes, + edges: fixture.edges.map((edge) => ({ + source: edge.source, + target: edge.target, + weight: edge.weight, + })), + // Mismatched community arrays are treated as unassigned (-1) so replay + // still runs; metrics that need Louvain labels must validate lengths + // upstream. + communities: + fixture.communities.length === nodes.length + ? [...fixture.communities] + : nodes.map(() => -1), + }; +} + +/** Link store and entity index column. Entity indices are the contiguous range `[0, nodeCount)`. */ +export function buildCommunityInputs(shape: GraphShape): { + readonly entityIdxs: Column; + readonly links: LinkStore; +} { + const entityIdxs = new Column( + Int32Array, + Math.max(1, shape.nodeCount), + ); + for (let nodeIndex = 0; nodeIndex < shape.nodeCount; nodeIndex++) { + entityIdxs.push(EntityIndex(nodeIndex)); + } + + const links = new LinkStore(); + const pairs = buildEdgePairs(shape); + const linkTypeIdx = TypeSetId(0); + for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) { + // pairIndex iterates pairs.length, so each index is defined. + const [left, right] = pairs[pairIndex]!; + links.insert( + EntityIndex(left), + EntityIndex(right), + linkTypeIdx, + EntityIndex(shape.nodeCount + pairIndex), + ); + } + + return { entityIdxs, links }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/entity-id-buffer.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/entity-id-buffer.test.ts new file mode 100644 index 00000000000..dcd6481070b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/entity-id-buffer.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { entityIdFromComponents } from "@blockprotocol/type-system"; + +import { EntityIdBuffer } from "./entity-id-buffer"; + +import type { DraftId, EntityUuid, WebId } from "@blockprotocol/type-system"; + +const webIdA = "11111111-1111-4111-8111-111111111111" as WebId; +const entityUuidA = "22222222-2222-4222-8222-222222222222" as EntityUuid; +const webIdB = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" as WebId; +const entityUuidB = "12345678-90ab-4cde-8f01-234567890abc" as EntityUuid; +const draftId = "33333333-3333-4333-8333-333333333333" as DraftId; + +describe("EntityIdBuffer", () => { + it("round-trips a non-draft EntityId", () => { + const buffer = new EntityIdBuffer(4); + const id = entityIdFromComponents(webIdA, entityUuidA); + buffer.setId(2, id); + expect(buffer.readId(2)).toBe(id); + }); + + it("round-trips a draft EntityId (draftId slot used)", () => { + const buffer = new EntityIdBuffer(4); + const id = entityIdFromComponents(webIdA, entityUuidA, draftId); + buffer.setId(0, id); + expect(buffer.readId(0)).toBe(id); + }); + + it("keeps entries independent by EntityIdx", () => { + const buffer = new EntityIdBuffer(8); + const first = entityIdFromComponents(webIdA, entityUuidA); + const second = entityIdFromComponents(webIdB, entityUuidB); + buffer.setId(0, first); + buffer.setId(7, second); + expect(buffer.readId(0)).toBe(first); + expect(buffer.readId(7)).toBe(second); + }); + + it("overwriting an EntityIdx with a non-draft id clears a prior draftId", () => { + const buffer = new EntityIdBuffer(2); + buffer.setId(1, entityIdFromComponents(webIdA, entityUuidA, draftId)); + const plain = entityIdFromComponents(webIdB, entityUuidB); + buffer.setId(1, plain); + expect(buffer.readId(1)).toBe(plain); + }); + + it("grows in place to fit a higher EntityIdx, preserving earlier entries", () => { + const buffer = new EntityIdBuffer(2, undefined, 8); + const first = entityIdFromComponents(webIdA, entityUuidA); + buffer.setId(0, first); + expect(buffer.capacity).toBe(2); + + buffer.ensureCapacity(6); // within maxCapacity 8 → grows in place, no re-publish + expect(buffer.capacity).toBeGreaterThanOrEqual(6); + + const later = entityIdFromComponents(webIdB, entityUuidB); + buffer.setId(5, later); // beyond the original capacity, within the grown one + expect(buffer.readId(5)).toBe(later); + expect(buffer.readId(0)).toBe(first); // earlier entry survived the grow + }); + + it("re-allocates + re-publishes past maxCapacity, copying existing entries", () => { + let republished: SharedArrayBuffer | ArrayBuffer | null = null; + const onRepublish = (raw: SharedArrayBuffer | ArrayBuffer) => { + republished = raw; + }; + const buffer = new EntityIdBuffer(2, onRepublish, 4); + const first = entityIdFromComponents(webIdA, entityUuidA); + buffer.setId(0, first); + + buffer.ensureCapacity(10); // past maxCapacity 4 → re-allocate + re-publish + expect(buffer.capacity).toBeGreaterThanOrEqual(10); + expect(republished).toBe(buffer.raw); // the new buffer reached the publisher + expect(buffer.readId(0)).toBe(first); // survived the re-allocation copy + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/entity-id-buffer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/entity-id-buffer.ts new file mode 100644 index 00000000000..0f82c513e89 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/entity-id-buffer.ts @@ -0,0 +1,55 @@ +/** + * SharedArrayBuffer-backed `EntityIdx -> EntityId` map. The worker writes; + * the main thread reads. Each record is {@link ENTITY_ID_BYTES} bytes + * (webId | entityUuid | draftId, 16 bytes each; an all-zero draftId slot + * means the entity is not a draft), preceded by a 4-byte version header. + * See {@link "../entity-id-codec"} for the exact byte-level codec. + * + * No-version contract: unlike the position buffers, reads here do not check + * the version header. That is safe because a record is written the instant its + * EntityIdx is interned -- strictly before any frame referencing that index is + * published -- and once written it never changes. The main thread can only ask + * about indices it learned from a published frame, so it never reads an + * unwritten or torn record. + */ +import { + ENTITY_ID_BYTES, + ID_HEADER_BYTES, + decodeEntityId, + encodeEntityId, +} from "../entity-id-codec"; +import { GrowableBuffer, type RepublishHandler } from "./growable-buffer"; + +import type { EntityId } from "@blockprotocol/type-system"; + +/** Default growable ceiling (entities). Reserves address space, not committed memory. */ +const ENTITY_ID_MAX_CAPACITY = 262_144; + +export class EntityIdBuffer extends GrowableBuffer { + #bytes!: Uint8Array; + + constructor( + capacity: number, + republish?: RepublishHandler, + maxCapacity: number = ENTITY_ID_MAX_CAPACITY, + ) { + super(ID_HEADER_BYTES, ENTITY_ID_BYTES, capacity, maxCapacity, republish); + this.bindRecordViews(this.raw); + } + + protected override bindRecordViews( + raw: SharedArrayBuffer | ArrayBuffer, + ): void { + this.#bytes = new Uint8Array(raw, ID_HEADER_BYTES); + } + + /** Writes the EntityId for an EntityIdx into the shared buffer. Must complete before any frame referencing that index is published. */ + setId(entityIdx: number, entityId: EntityId): void { + encodeEntityId(this.#bytes, entityIdx, entityId); + } + + /** Reads the EntityId for an EntityIdx. Safe on any index present in a published frame (see no-version contract in file header). */ + readId(entityIdx: number): EntityId { + return decodeEntityId(this.#bytes, entityIdx); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/growable-buffer.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/growable-buffer.bench.ts new file mode 100644 index 00000000000..c83a17c735a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/growable-buffer.bench.ts @@ -0,0 +1,101 @@ +/** + * Shared-buffer write + growth costs. Two things matter here: the per-commit + * cost of writing an entire flat-tier buffer (position + radius + colour + + * entityIdx for every node, which `#writeFlatStyle` / `#buildFlatRenderEdges` + * pay on every commit) and the cost of GROWING a GPU buffer, which is always a + * re-allocate + full memcpy because `FlatGraphBuffer` is non-resizable. + * + * The three flat-buffer cases quantify that growth: `presized` is the floor, + * `geometric` mirrors production (`flatCapacityFor` in entity-graph/worker.ts + * over-allocates 1.5x so growth is amortized), and `fixed-step` is the naive + * grow-to-exact-count path production deliberately avoids -- the gap between the + * last two is the payoff of the 1.5x slack, and the reason a test should lock it in. + * + * Run: `cd apps/hash-frontend && ../../node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer/worker/buffers/growable-buffer.bench.ts` + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { Column } from "../collections/column"; +import { EntityPositionBuffer, FlatGraphBuffer } from "./position-buffer"; + +import type { RepublishHandler } from "./growable-buffer"; + +const SIZES: readonly number[] = [1_000, 10_000, 50_000]; + +const RGBA: readonly [number, number, number, number] = [10, 20, 30, 255]; + +const noopRepublish: RepublishHandler = () => { + // The benchmark discards the re-published buffer; only the copy cost matters. +}; + +/** Write a full set of `count` records (the shape of a flat-tier commit). */ +function writeAllRecords(buffer: FlatGraphBuffer, count: number): void { + for (let index = 0; index < count; index++) { + buffer.setPosition(index, index * 1.5, index * 0.5); + buffer.setRadius(index, 4); + buffer.setColor(index, RGBA); + buffer.setEntityIdx(index, index); + } + buffer.setCount(count); + buffer.commit(); +} + +/** Mirrors `flatCapacityFor` in entity-graph/worker.ts (1.5x geometric slack). */ +function flatCapacityFor(count: number): number { + return Math.max(count + 64, Math.ceil(count * 1.5)); +} + +for (const size of SIZES) { + describe(`flat buffer (${size} nodes)`, () => { + // Buffer sized up front: the steady-state per-commit write, no growth. + bench("presized: write all records + commit", () => { + const buffer = new FlatGraphBuffer(size, noopRepublish); + writeAllRecords(buffer, size); + }); + + // Production path: grow with 1.5x geometric slack, so the number of + // re-allocations is O(log N) and total copied bytes stay O(N). + bench("geometric growth (1.5x, production path)", () => { + const buffer = new FlatGraphBuffer(1_024, noopRepublish); + let capacity = 1_024; + while (capacity < size) { + capacity = flatCapacityFor(capacity); + buffer.ensureCapacity(capacity); + } + writeAllRecords(buffer, size); + }); + + // Naive path production avoids: grow to the exact live count in 1024-record + // steps, so every step re-allocates + memcpies the whole buffer -> O(N^2). + bench("fixed-step growth (1024, naive)", () => { + const buffer = new FlatGraphBuffer(1_024, noopRepublish); + for (let filled = 1_024; filled < size; filled += 1_024) { + buffer.ensureCapacity(Math.min(size, filled + 1_024)); + } + writeAllRecords(buffer, size); + }); + }); + + describe(`leaf position buffer (${size} nodes)`, () => { + // Per-tick position write for a settling leaf layout: this runs every frame. + bench("setPosition x N + commit", () => { + const buffer = new EntityPositionBuffer(size); + for (let index = 0; index < size; index++) { + buffer.setPosition(index, index * 1.5, index * 0.5); + } + buffer.commit(); + }); + }); + + describe(`column growth (${size} pushes)`, () => { + // Geometric-growth push path (entity/link columns, adjacency-free). + bench("Column.push from 1024", () => { + const column = new Column(Float32Array, 1_024); + for (let index = 0; index < size; index++) { + column.push(index * 0.25); + } + }); + }); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/growable-buffer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/growable-buffer.ts new file mode 100644 index 00000000000..a74f29d8d0b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/growable-buffer.ts @@ -0,0 +1,156 @@ +/* eslint-disable no-bitwise */ +/** + * Growable SharedArrayBuffer primitives. + * + * {@link GrowableBuffer} owns the grow-or-republish dance: grow in place + * when possible (ES2024 growable SharedArrayBuffer), otherwise re-allocate, + * copy, re-bind views, and notify the main thread. Subclasses describe + * their record layout and re-bind views via {@link GrowableBuffer.bindRecordViews}. + */ + +/** SharedArrayBuffer is available at all (cross-origin isolation present). */ +export const sharedBufferAvailable = typeof SharedArrayBuffer !== "undefined"; + +/** Whether SharedArrayBuffers can grow in place (ES2024 growable SharedArrayBuffer). */ +export const growableSharedBuffer = + sharedBufferAvailable && + typeof SharedArrayBuffer.prototype.grow === "function"; + +/** + * Allocate a buffer that can grow in place up to `maxByteLength`. The ceiling + * reserves address space, not committed memory. Pass `resizable: false` for + * GPU-uploaded buffers (WebGL rejects views over a resizable ArrayBuffer). + */ +export function makeGrowableBuffer( + byteLength: number, + maxByteLength: number, + resizable = true, +): SharedArrayBuffer | ArrayBuffer { + if (resizable && growableSharedBuffer) { + return new SharedArrayBuffer(byteLength, { maxByteLength }); + } + if (sharedBufferAvailable) { + return new SharedArrayBuffer(byteLength); + } + return new ArrayBuffer(byteLength); +} + +/** Grow `raw` in place to at least `byteLength`. Returns false when it can't. */ +export function growBuffer( + raw: SharedArrayBuffer | ArrayBuffer, + byteLength: number, +): boolean { + if (byteLength <= raw.byteLength) { + return true; + } + if (!growableSharedBuffer || !(raw instanceof SharedArrayBuffer)) { + return false; + } + if (byteLength > raw.maxByteLength) { + return false; + } + raw.grow(byteLength); + return true; +} + +/** Invoked when a buffer was re-allocated (the main thread must swap to the new buffer). */ +export type RepublishHandler = ( + raw: SharedArrayBuffer | ArrayBuffer, + capacity: number, +) => void; + +/** Guard: overflow without a republish handler is a misconfiguration. */ +const throwOnUnhandledRepublish: RepublishHandler = () => { + throw new Error( + "GrowableBuffer overflowed its maxByteLength but no republish handler was provided, so the re-allocated buffer cannot reach the main thread.", + ); +}; + +/** + * SharedArrayBuffer-backed store with a `[version:int32]` header followed by + * fixed-size records, that grows on demand. Subclasses define their record + * layout via {@link bindRecordViews}. Pass `resizable: false` for GPU-uploaded + * buffers that must re-allocate instead of growing in place. + */ +export abstract class GrowableBuffer { + /** The raw buffer; reassigned (and re-published) when it must be re-allocated. */ + raw: SharedArrayBuffer | ArrayBuffer; + protected readonly headerBytes: number; + protected readonly recordBytes: number; + #version: Int32Array; + readonly #republish: RepublishHandler; + readonly #resizable: boolean; + + protected constructor( + headerBytes: number, + recordBytes: number, + capacity: number, + maxCapacity: number, + republish: RepublishHandler = throwOnUnhandledRepublish, + resizable = true, + ) { + this.headerBytes = headerBytes; + this.recordBytes = recordBytes; + this.#republish = republish; + this.#resizable = resizable; + const cap = Math.max(1, capacity); + const maxCap = Math.max(cap, maxCapacity); + this.raw = makeGrowableBuffer( + headerBytes + cap * recordBytes, + headerBytes + maxCap * recordBytes, + resizable, + ); + this.#version = new Int32Array(this.raw, 0, 1); + // NB: cannot call bindRecordViews() here, subclass fields aren't initialised yet. + // The subclass constructor calls it once after super(). + } + + /** Records the buffer currently holds (grows as it does). */ + get capacity(): number { + return (this.raw.byteLength - this.headerBytes) / this.recordBytes; + } + + /** Ensure room for `needed` records, growing or re-allocating as necessary. */ + ensureCapacity(needed: number): void { + const neededBytes = this.headerBytes + needed * this.recordBytes; + if (growBuffer(this.raw, neededBytes)) { + return; + } + // Geometric growth amortises re-allocations when in-place grow is unavailable. + const growTo = Math.max(needed, this.capacity * 2); + const growToBytes = this.headerBytes + growTo * this.recordBytes; + const next = makeGrowableBuffer( + growToBytes, + Math.max(growToBytes, this.raw.byteLength * 2), + this.#resizable, + ); + new Uint8Array(next).set(new Uint8Array(this.raw)); + this.raw = next; + this.#version = new Int32Array(this.raw, 0, 1); + this.bindRecordViews(this.raw); + this.#republish(this.raw, growTo); + } + + /** + * Publish a buffer snapshot. The worker (subclass caller) must finish all record writes before + * calling this. Atomics.store makes the version visible; Atomics.notify wakes main-thread + * Atomics.waitAsync watchers. Version wraps at 2^31 (`| 0`); consumers compare for change, not magnitude. + */ + commit(): void { + if (sharedBufferAvailable) { + Atomics.store(this.#version, 0, (this.#version[0]! + 1) | 0); + Atomics.notify(this.#version, 0); + } else { + this.#version[0] = (this.#version[0]! + 1) | 0; + } + } + + /** + * Re-create the record-field views over `raw`. Called by the subclass constructor + * (once, after super()) and again on every re-publish. Subclass fields are not + * live during {@link GrowableBuffer}'s own constructor, so bind there must wait. + */ + protected abstract bindRecordViews( + raw: SharedArrayBuffer | ArrayBuffer, + ): void; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/position-buffer.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/position-buffer.test.ts new file mode 100644 index 00000000000..876df0841fa --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/position-buffer.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { + FLAT_COLOR_BYTE_OFFSET, + FLAT_HEADER_BYTES, + FLAT_RECORD_BYTES, + FlatGraphBuffer, +} from "./position-buffer"; + +const SLOTS = FLAT_RECORD_BYTES / 4; + +describe("FlatGraphBuffer", () => { + it("round-trips position, radius, colour, and entityIdx in one record", () => { + const buffer = new FlatGraphBuffer(4); + buffer.setPosition(2, 12.5, -7.25); + buffer.setRadius(2, 9); + buffer.setColor(2, [10, 20, 30, 40]); + buffer.setEntityIdx(2, 12_345); + + const floats = new Float32Array(buffer.raw, FLAT_HEADER_BYTES); + const bytes = new Uint8Array(buffer.raw, FLAT_HEADER_BYTES); + const u32 = new Uint32Array(buffer.raw, FLAT_HEADER_BYTES); + + expect(floats[2 * SLOTS]).toBeCloseTo(12.5, 3); + expect(floats[2 * SLOTS + 1]).toBeCloseTo(-7.25, 3); + expect(floats[2 * SLOTS + 2]).toBeCloseTo(9, 3); + const colorBase = 2 * FLAT_RECORD_BYTES + FLAT_COLOR_BYTE_OFFSET; + expect([ + bytes[colorBase], + bytes[colorBase + 1], + bytes[colorBase + 2], + bytes[colorBase + 3], + ]).toEqual([10, 20, 30, 40]); + expect(u32[2 * SLOTS + 4]).toBe(12_345); + }); + + it("is NON-resizable so WebGL can upload its views", () => { + // The crux of the GPU-upload constraint: a resizable ArrayBuffer's views are rejected + // by bufferSubData, so the flat buffer must be fixed-size and grow by re-allocation. + const buffer = new FlatGraphBuffer(4); + if (buffer.raw instanceof SharedArrayBuffer) { + expect(buffer.raw.growable).toBe(false); + } else { + expect(buffer.raw.resizable).toBe(false); + } + }); + + it("grows by re-allocating + re-publishing, copying existing records", () => { + let republished: SharedArrayBuffer | ArrayBuffer | null = null; + const onRepublish = (raw: SharedArrayBuffer | ArrayBuffer) => { + republished = raw; + }; + const buffer = new FlatGraphBuffer(2, onRepublish); + buffer.setPosition(0, 1, 2); + buffer.setEntityIdx(0, 7); + expect(buffer.capacity).toBe(2); + + buffer.ensureCapacity(10); // non-resizable → always re-allocate + re-publish + expect(buffer.capacity).toBeGreaterThanOrEqual(10); + expect(republished).toBe(buffer.raw); // the new buffer reached the publisher + // The re-allocated buffer is itself non-resizable (still GPU-uploadable). + if (buffer.raw instanceof SharedArrayBuffer) { + expect(buffer.raw.growable).toBe(false); + } + + buffer.setPosition(9, 3, 4); // a record only the grown buffer can hold + const floats = new Float32Array(buffer.raw, FLAT_HEADER_BYTES); + const u32 = new Uint32Array(buffer.raw, FLAT_HEADER_BYTES); + expect(floats[9 * SLOTS]).toBeCloseTo(3, 3); + expect(floats[0 * SLOTS]).toBeCloseTo(1, 3); // record survived the re-allocation copy + expect(u32[0 * SLOTS + 4]).toBe(7); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/position-buffer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/position-buffer.ts new file mode 100644 index 00000000000..ea92fb821f7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/buffers/position-buffer.ts @@ -0,0 +1,241 @@ +/* eslint-disable no-bitwise */ +/** + * SharedArrayBuffer-backed position storage. + * + * Layout: `[version: int32] [x0, y0, x1, y1, ... : float32]` + * + * The worker fills positions and calls {@link PositionBuffer.commit}; the + * version counter (atomic + notify) lets the main thread detect changes + * via `Atomics.waitAsync` with zero copying. + */ + +import { + GrowableBuffer, + type RepublishHandler, + sharedBufferAvailable, +} from "./growable-buffer"; + +export class PositionBuffer { + /** The raw buffer backing positions. SharedArrayBuffer when available. */ + readonly raw: SharedArrayBuffer | ArrayBuffer; + /** Interleaved [x0, y0, x1, y1, ...]; fill directly, then {@link commit}. */ + readonly positions: Float32Array; + readonly #version: Int32Array; + + constructor(nodeCount: number) { + const byteLength = 4 + nodeCount * 2 * 4; + this.raw = sharedBufferAvailable + ? new SharedArrayBuffer(byteLength) + : new ArrayBuffer(byteLength); + this.#version = new Int32Array(this.raw, 0, 1); + this.positions = new Float32Array(this.raw, 4); + } + + /** + * Publish the current {@link positions}: bump the version counter so the main + * thread sees the change. Atomics.store makes the write visible and + * Atomics.notify wakes any Atomics.waitAsync watcher. + */ + commit(): void { + if (sharedBufferAvailable) { + Atomics.store(this.#version, 0, (this.#version[0]! + 1) | 0); + Atomics.notify(this.#version, 0); + } else { + this.#version[0] = (this.#version[0]! + 1) | 0; + } + } +} + +/** + * Per-leaf entity-dot buffer: interleaved records of `{ x:f32, y:f32, rgba:u8x4 }` + * (12 bytes each) after a 4-byte version header. + * + * Worker writes positions/colours then calls {@link EntityPositionBuffer.commit}; main thread + * reads via the version counter (`Atomics.waitAsync`). Recreated (not grown) when node count changes. + */ +export const LEAF_HEADER_BYTES = 4; +export const LEAF_RECORD_BYTES = 12; +/** Byte offset of the rgba colour within a record. */ +export const LEAF_COLOR_BYTE_OFFSET = 8; +/** Slots per record (4-byte aligned, so the float and byte views share the stride). */ +const LEAF_RECORD_SLOTS = LEAF_RECORD_BYTES / 4; + +export class EntityPositionBuffer { + readonly raw: SharedArrayBuffer | ArrayBuffer; + readonly #version: Int32Array; + /** Record fields as floats: record `i` -> [i*S]=x, [i*S+1]=y (S = LEAF_RECORD_SLOTS). */ + readonly #floats: Float32Array; + /** Record fields as bytes: record `i` colour at [i*LEAF_RECORD_BYTES + LEAF_COLOR_BYTE_OFFSET ..]. */ + readonly #bytes: Uint8Array; + + constructor(nodeCount: number) { + const byteLength = LEAF_HEADER_BYTES + nodeCount * LEAF_RECORD_BYTES; + this.raw = sharedBufferAvailable + ? new SharedArrayBuffer(byteLength) + : new ArrayBuffer(byteLength); + this.#version = new Int32Array(this.raw, 0, 1); + this.#floats = new Float32Array(this.raw, LEAF_HEADER_BYTES); + this.#bytes = new Uint8Array(this.raw, LEAF_HEADER_BYTES); + } + + setPosition(index: number, x: number, y: number): void { + const base = index * LEAF_RECORD_SLOTS; + this.#floats[base] = x; + this.#floats[base + 1] = y; + } + + setColor( + index: number, + color: readonly [number, number, number, number], + ): void { + const offset = index * LEAF_RECORD_BYTES + LEAF_COLOR_BYTE_OFFSET; + this.#bytes[offset] = color[0]; + this.#bytes[offset + 1] = color[1]; + this.#bytes[offset + 2] = color[2]; + this.#bytes[offset + 3] = color[3]; + } + + commit(): void { + if (sharedBufferAvailable) { + Atomics.store(this.#version, 0, (this.#version[0]! + 1) | 0); + Atomics.notify(this.#version, 0); + } else { + this.#version[0] = (this.#version[0]! + 1) | 0; + } + } +} + +/** Local x of leaf node `index`, from a records-region view (`new Float32Array(raw, 4)`). */ +export function leafNodeX(records: Float32Array, index: number): number { + return records[index * LEAF_RECORD_SLOTS] ?? 0; +} +/** Local y of leaf node `index`, from a records-region view (`new Float32Array(raw, 4)`). */ +export function leafNodeY(records: Float32Array, index: number): number { + return records[index * LEAF_RECORD_SLOTS + 1] ?? 0; +} + +/** Deck binary `getPosition` attribute over a leaf buffer's raw bytes (interleaved, stride-3). */ +export function leafPositionAttribute(raw: SharedArrayBuffer | ArrayBuffer): { + readonly value: Float32Array; + readonly size: 2; + readonly stride: number; + readonly offset: number; +} { + return { + value: new Float32Array(raw), + size: 2, + stride: LEAF_RECORD_BYTES, + offset: LEAF_HEADER_BYTES, + }; +} + +/** Deck binary `getFillColor` attribute over a leaf buffer's raw bytes (normalized rgba). */ +export function leafColorAttribute(raw: SharedArrayBuffer | ArrayBuffer): { + readonly value: Uint8Array; + readonly size: 4; + readonly stride: number; + readonly offset: number; + readonly normalized: true; +} { + return { + value: new Uint8Array(raw), + size: 4, + stride: LEAF_RECORD_BYTES, + offset: LEAF_HEADER_BYTES + LEAF_COLOR_BYTE_OFFSET, + normalized: true, + }; +} + +/** + * Flat-tier interleaved shared buffer. Header plus fixed-size records: + * + * `[version:i32][count:u32]` then count records of + * `{ x:f32, y:f32, radius:f32, rgba:u8x4, entityIdx:u32 }` (20 bytes each) + * + * `entityIdx` is the join key mapping a rendered record back to its entity. + */ +export const FLAT_HEADER_BYTES = 8; +export const FLAT_RECORD_BYTES = 20; +/** Byte offsets of `radius` / `rgba` / `entityIdx` within a record. */ +export const FLAT_RADIUS_BYTE_OFFSET = 8; +export const FLAT_COLOR_BYTE_OFFSET = 12; +export const FLAT_ENTITYIDX_BYTE_OFFSET = 16; +/** Slots per record (4-byte aligned, so the float and u32 views share the stride). */ +const FLAT_RECORD_SLOTS = FLAT_RECORD_BYTES / 4; + +/** + * Growable, interleaved store for the flat-tier graph. Non-resizable + * (GPU-uploaded); outgrowing capacity re-allocates via + * {@link GrowableBuffer.ensureCapacity}. + * + * Worker writes records, sets count, then calls {@link GrowableBuffer.commit}. Main thread + * reads count + records after observing a version bump; re-allocation triggers republish. + */ +export class FlatGraphBuffer extends GrowableBuffer { + /** `count` header slot. */ + #count!: Uint32Array; + /** Record fields as floats: record `i` -> [i*S]=x, [i*S+1]=y, [i*S+2]=radius + * (S = FLAT_RECORD_SLOTS). */ + #floats!: Float32Array; + /** Record fields as bytes: record `i` colour at [i*FLAT_RECORD_BYTES + 12 .. +15]. */ + #bytes!: Uint8Array; + /** Record fields as u32: record `i` entityIdx at [i*S + 4]. */ + #u32!: Uint32Array; + + constructor(capacity: number, republish?: RepublishHandler) { + // resizable: false, this buffer's bytes are uploaded to the GPU. + super( + FLAT_HEADER_BYTES, + FLAT_RECORD_BYTES, + capacity, + capacity, + republish, + false, + ); + this.bindRecordViews(this.raw); + } + + /** Re-point field views at the (re-allocated) buffer. */ + protected override bindRecordViews( + raw: SharedArrayBuffer | ArrayBuffer, + ): void { + this.#count = new Uint32Array(raw, 4, 1); + this.#floats = new Float32Array(raw, FLAT_HEADER_BYTES); + this.#bytes = new Uint8Array(raw, FLAT_HEADER_BYTES); + this.#u32 = new Uint32Array(raw, FLAT_HEADER_BYTES); + } + + get count(): number { + return this.#count[0]!; + } + + setCount(value: number): void { + this.#count[0] = value; + } + + setPosition(index: number, x: number, y: number): void { + const base = index * FLAT_RECORD_SLOTS; + this.#floats[base] = x; + this.#floats[base + 1] = y; + } + + setRadius(index: number, radius: number): void { + this.#floats[index * FLAT_RECORD_SLOTS + 2] = radius; + } + + /** The join key: which entity this record is (paired with the EntityId map SAB). */ + setEntityIdx(index: number, entityIdx: number): void { + this.#u32[index * FLAT_RECORD_SLOTS + 4] = entityIdx; + } + + setColor( + index: number, + color: readonly [number, number, number, number], + ): void { + const offset = index * FLAT_RECORD_BYTES + FLAT_COLOR_BYTE_OFFSET; + this.#bytes[offset] = color[0]; + this.#bytes[offset + 1] = color[1]; + this.#bytes[offset + 2] = color[2]; + this.#bytes[offset + 3] = color[3]; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/bitset.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/bitset.test.ts new file mode 100644 index 00000000000..459fb6de0d0 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/bitset.test.ts @@ -0,0 +1,38 @@ +/** + * BitSet tests, focused on the scratch-reuse contract (`clear`) that the + * render-side planners lean on. + */ +import { describe, expect, it } from "vitest"; + +import { BitSet } from "./bitset"; + +describe("BitSet clear", () => { + it("removes every member and resets cardinality", () => { + const set = BitSet.empty(64); + set.add(0); + set.add(31); + set.add(63); + expect(set.cardinality).toBe(3); + + set.clear(); + + expect(set.cardinality).toBe(0); + expect(set.has(0)).toBe(false); + expect(set.has(31)).toBe(false); + expect(set.has(63)).toBe(false); + expect([...set.members()]).toEqual([]); + }); + + it("keeps the word storage for allocation-free reuse", () => { + const set = BitSet.empty(64); + set.add(40); + const wordsBefore = set.words; + + set.clear(); + set.add(7); + + expect(set.words).toBe(wordsBefore); + expect(set.has(7)).toBe(true); + expect(set.cardinality).toBe(1); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/bitset.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/bitset.ts new file mode 100644 index 00000000000..8c0977780c7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/bitset.ts @@ -0,0 +1,150 @@ +/* eslint-disable no-bitwise */ +/* eslint-disable no-param-reassign */ +/* eslint-disable operator-assignment */ +/** + * Fixed-universe bit set over a Uint32Array word store. + * + * Operations (or, and, intersectionCount, jaccard) are all O(words) + * where words = ceil(universe / 32). + */ + +// eslint-disable-next-line id-length +function popcount(n: number): number { + n = n - ((n >>> 1) & 0x55555555); + n = (n & 0x33333333) + ((n >>> 2) & 0x33333333); + n = (n + (n >>> 4)) & 0x0f0f0f0f; + return (n * 0x01010101) >>> 24; +} + +export class BitSet { + #words: Uint32Array; + #cardinality: number; + + private constructor(words: Uint32Array, cardinality: number) { + this.#words = words; + this.#cardinality = cardinality; + } + + static empty(universeSize: number): BitSet { + const wordCount = Math.ceil(universeSize / 32) || 1; + + return new BitSet(new Uint32Array(wordCount), 0); + } + + static fromBit(universeSize: number, bit: T): BitSet { + const set = BitSet.empty(universeSize); + set.add(bit); + + return set; + } + + get words(): Uint32Array { + return this.#words; + } + + get cardinality(): number { + return this.#cardinality; + } + + has(bit: T): boolean { + const word = bit >>> 5; + const mask = 1 << (bit & 31); + return word < this.#words.length && (this.#words[word]! & mask) !== 0; + } + + #grow(): void { + if (this.#words.buffer.resizable) { + this.#words.buffer.resize(this.#words.buffer.byteLength * 2); + this.#words = new Uint32Array(this.#words.buffer); + } else { + const newWords = new Uint32Array(this.#words.buffer.byteLength * 2); + newWords.set(this.#words); + this.#words = newWords; + } + } + + add(bit: T): void { + const word = bit >>> 5; + const mask = 1 << (bit & 31); + while (word >= this.#words.length) { + this.#grow(); + } + + if ((this.#words[word]! & mask) === 0) { + this.#words[word]! |= mask; + this.#cardinality++; + } + } + + /** Remove every member; keeps the allocated word capacity for reuse. */ + clear(): void { + this.#words.fill(0); + this.#cardinality = 0; + } + + /** Union of this set and other. Allocates a new BitSet; does not mutate either operand. O(words). */ + or(other: BitSet): BitSet { + const len = Math.max(this.#words.length, other.#words.length); + const result = new Uint32Array(len); + let cardinality = 0; + + for (let index = 0; index < len; index++) { + const word = (this.#words[index] ?? 0) | (other.#words[index] ?? 0); + result[index] = word; + cardinality += popcount(word); + } + + return new BitSet(result, cardinality); + } + + /** Intersection of this set and other. Allocates a new BitSet; does not mutate either operand. O(words). */ + and(other: BitSet): BitSet { + const len = Math.min(this.#words.length, other.#words.length); + const result = new Uint32Array(len); + let cardinality = 0; + + for (let index = 0; index < len; index++) { + const word = this.#words[index]! & other.#words[index]!; + result[index] = word; + cardinality += popcount(word); + } + + return new BitSet(result, cardinality); + } + + /** Count of bits set in both this and other, without allocating. */ + intersectionCount(other: BitSet): number { + const len = Math.min(this.#words.length, other.#words.length); + let count = 0; + + for (let index = 0; index < len; index++) { + count += popcount(this.#words[index]! & other.#words[index]!); + } + + return count; + } + + /** Jaccard similarity: |A ∩ B| / |A ∪ B|. Returns 1 if both empty. */ + jaccard(other: BitSet): number { + const intersection = this.intersectionCount(other); + const union = this.#cardinality + other.#cardinality - intersection; + return union === 0 ? 1 : intersection / union; + } + + /** Iterate over set bit positions. */ + *members(): IterableIterator { + for (let word = 0; word < this.#words.length; word++) { + let bits = this.#words[word]!; + while (bits !== 0) { + const lsb = bits & -bits; + // Bit index is always < universe size; T is the branded index type for that universe. + yield ((word << 5) + Math.log2(lsb)) as T; + bits ^= lsb; + } + } + } + + clone(): BitSet { + return new BitSet(new Uint32Array(this.#words), this.#cardinality); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/column.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/column.test.ts new file mode 100644 index 00000000000..cb180c7bdda --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/column.test.ts @@ -0,0 +1,87 @@ +/** + * Column tests, focused on the windowed-scratch API (`resize`/`fill`/`clear`) + * and the backing choice — the contracts the render-side packers lean on. + */ +import { describe, expect, it } from "vitest"; + +import { Column } from "./column"; + +describe("Column windowed-scratch API", () => { + it("resize grows the window and preserves earlier contents across growth", () => { + const column = new Column(Int32Array, 4); + column.resize(4); + column.fill(7); + + // Force a capacity grow: contents written before must survive. + column.resize(64); + expect(column.length).toBe(64); + expect(column.capacity).toBeGreaterThanOrEqual(64); + expect(column.get(0)).toBe(7); + expect(column.get(3)).toBe(7); + }); + + it("clear keeps capacity and buffer identity (allocation-free reuse)", () => { + const column = new Column(Float32Array, 8); + column.resize(8); + const bufferBefore = column.buffer; + const capacityBefore = column.capacity; + + column.clear(); + expect(column.length).toBe(0); + + column.resize(8); + expect(column.buffer).toBe(bufferBefore); + expect(column.capacity).toBe(capacityBefore); + }); + + it("resize after clear re-exposes previous contents (documented persistence)", () => { + const column = new Column(Int32Array, 4); + column.resize(2); + column.set(0, 41); + column.set(1, 42); + + column.clear(); + column.resize(2); + + // Stamp-plane style usage depends on this: no implicit zeroing. + expect(column.get(0)).toBe(41); + expect(column.get(1)).toBe(42); + }); + + it("fill writes only the requested sub-range of the window", () => { + const column = new Column(Int32Array, 8); + column.resize(4); + column.fill(0); + column.fill(9, 1, 3); + + expect([...column.subarray()]).toEqual([0, 9, 9, 0]); + }); + + it("plain backing yields ArrayBuffer views even where SharedArrayBuffer exists", () => { + const plain = new Column(Float32Array, 4, { backing: "plain" }); + expect(plain.buffer).toBeInstanceOf(ArrayBuffer); + + // Growth must stay plain too (GPU upload paths see the grown buffer). + plain.resize(4096); + expect(plain.buffer).toBeInstanceOf(ArrayBuffer); + }); + + it("raw is the stable capacity view until growth invalidates it", () => { + const column = new Column(Int32Array, 8); + column.resize(4); + + // Zero-allocation contract: repeated reads return the SAME view object. + const rawBefore = column.raw; + expect(column.raw).toBe(rawBefore); + expect(rawBefore.length).toBe(column.capacity); + + // Writes through raw are writes to the column. + rawBefore[2] = 99; + expect(column.get(2)).toBe(99); + + // Growth re-points raw at the new buffer, contents carried over. + column.resize(64); + expect(column.raw).not.toBe(rawBefore); + expect(column.raw[2]).toBe(99); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/column.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/column.ts new file mode 100644 index 00000000000..15aec20fb97 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/column.ts @@ -0,0 +1,305 @@ +/** + * Growable typed-array columns with optional SharedArrayBuffer backing for + * worker/main-thread sharing or plain ArrayBuffer for GPU upload paths. + */ +type BackingBuffer = SharedArrayBuffer | ArrayBuffer; + +const sharedBufferAvailable = typeof SharedArrayBuffer !== "undefined"; + +export interface ColumnOptions { + /** + * Which buffer to allocate: + * + * - `"shared-if-available"` (default): SharedArrayBuffer when the platform + * has it, so workers and the main thread can read the same memory. + * - `"plain"`: always a regular ArrayBuffer. Use this for columns whose + * views are handed to GPU upload APIs (deck attributes, luma textures): + * WebGL entry points do not reliably accept SharedArrayBuffer-backed views + * across browsers. Also use plain when nothing reads the column cross-thread. + */ + readonly backing?: "shared-if-available" | "plain"; +} + +export type TypedArrayConstructor = { + new (buffer: BackingBuffer, byteOffset?: number, length?: number): T; + readonly BYTES_PER_ELEMENT: number; +}; + +export type TypedArray = + | Uint8Array + | Uint16Array + | Uint32Array + | Int8Array + | Int16Array + | Int32Array + | Float32Array + | Float64Array; + +/** + * Readonly view over a region of a typed array. + * + * Preserves the branded element type `T` and provides indexed access, + * iteration, and further sub-slicing without exposing mutation. + */ +export class ColumnView { + readonly #view: A; + + constructor(view: A) { + this.#view = view; + } + + get length(): number { + return this.#view.length; + } + + /** The underlying buffer (shared when SharedArrayBuffer is available). */ + get buffer(): BackingBuffer { + return this.#view.buffer as BackingBuffer; + } + + /** The underlying typed array backing this read-only slice. */ + get view(): A { + return this.#view; + } + + get(idx: number): T { + if (idx < 0 || idx >= this.#view.length) { + throw new RangeError( + `ColumnView: index ${idx} out of bounds [0, ${this.#view.length})`, + ); + } + + return this.#view[idx]! as T; + } + + /** Zero-copy sub-view. Shares the same underlying buffer. */ + subarray(start?: number, end?: number): ColumnView { + return new ColumnView(this.#view.subarray(start, end) as A); + } + + [Symbol.iterator](): ArrayIterator { + return this.#view[Symbol.iterator]() as ArrayIterator; + } +} + +/** + * Growable columnar storage using SharedArrayBuffer when available, + * falling back to ArrayBuffer otherwise. + * + * SharedArrayBuffer allows both the worker and main thread to read + * the same memory without serialization. The column grows by doubling + * capacity; when resizable shared buffers are available it resizes in place, + * otherwise it allocates and copies. Columns whose views feed GPU upload + * APIs must opt out of the shared backing ({@link ColumnOptions.backing}). + * + * Generic over typed array kind via the constructor parameter: + * + * const entities = new Column(Uint32Array, 4096); + * const positions = new Column(Float32Array, 4096); + */ +export class Column { + readonly #ctor: TypedArrayConstructor; + readonly #shared: boolean; + #buffer: BackingBuffer; + #view: A; + #length: number; + + constructor( + Ctor: TypedArrayConstructor, + initialCapacity: number, + options?: ColumnOptions, + ) { + this.#ctor = Ctor; + this.#shared = + sharedBufferAvailable && + (options?.backing ?? "shared-if-available") !== "plain"; + this.#buffer = this.#alloc(initialCapacity * Ctor.BYTES_PER_ELEMENT); + this.#view = new Ctor(this.#buffer); + this.#length = 0; + } + + #alloc(byteLength: number): BackingBuffer { + return this.#shared + ? new SharedArrayBuffer(byteLength) + : new ArrayBuffer(byteLength); + } + + get length(): number { + return this.#length; + } + + /** Allocated slots (≥ {@link length}); grows by doubling, never shrinks. */ + get capacity(): number { + return this.#view.length; + } + + /** The underlying buffer. SharedArrayBuffer when available. */ + get buffer(): BackingBuffer { + return this.#buffer; + } + + push(value: T): number { + if (this.#length >= this.#view.length) { + this.#grow(this.#length + 1); + } + + const idx = this.#length; + this.#view[idx] = value; + this.#length++; + + return idx; + } + + get(idx: number): T { + if (idx < 0 || idx >= this.#length) { + throw new RangeError( + `Column: index ${idx} out of bounds [0, ${this.#length})`, + ); + } + + return this.#view[idx]! as T; + } + + getOrDefault(idx: number): T { + if (idx < 0 || idx >= this.#length) { + return 0 as T; + } + + return this.#view[idx]! as T; + } + + set(idx: number, value: T): void { + if (idx < 0 || idx >= this.#length) { + throw new RangeError( + `Column: index ${idx} out of bounds [0, ${this.#length})`, + ); + } + + this.#view[idx] = value; + } + + /** Zero-copy view over the filled portion, or a sub-range of it. */ + subarray(start?: number, end?: number): ColumnView { + const filled = new this.#ctor(this.#buffer, 0, this.#length); + + return new ColumnView(filled.subarray(start, end) as A); + } + + /** + * The raw backing view over the FULL CAPACITY — zero-allocation access for + * hot loops that already bound their indices by {@link length}. Unlike + * {@link subarray}, no view object is created and no bounds are enforced; + * slots past the filled window are exposed. Prefer {@link subarray} + * wherever a correctly-sized view matters (GPU uploads, iteration). + * + * The reference is invalidated by anything that can grow the column + * ({@link push}, {@link resize}, {@link append}): re-read it afterwards. + */ + get raw(): A { + return this.#view; + } + + /** + * Reset the filled length to zero. Capacity (and buffer identity) are + * kept, so a column reused as per-frame scratch stops allocating once it + * has seen its high-water mark. + */ + clear(): void { + this.#length = 0; + } + + /** + * Set the filled length directly (random-access/scatter usage, where the + * window size is known up front rather than discovered by `push`). + * + * Growth preserves contents and is not automatically zeroed; slots re-exposed by growing the window after + * a `clear`/shrink keep whatever they last held. Callers that need a clean + * window must {@link fill} it. + */ + resize(length: number): void { + this.#ensureCapacity(length); + this.#length = length; + } + + /** Fill the filled window (or a sub-range of it) with `value`. */ + fill(value: T, start = 0, end = this.#length): void { + this.#view.fill(value, start, Math.min(end, this.#length)); + } + + /** + * Remove the first occurrence of `value` by swapping it with the last + * element and shrinking by one. O(n) scan, O(1) removal. Returns true + * if the value was found. + */ + swapRemove(value: T): boolean { + for (let i = 0; i < this.#length; i++) { + if (this.#view[i] === value) { + this.#length--; + if (i < this.#length) { + this.#view[i] = this.#view[this.#length]!; + } + return true; + } + } + return false; + } + + /** Append all elements from one or more columns. */ + append(...columns: Column[]): void { + let total = this.#length; + for (const column of columns) { + total += column.#length; + } + + this.#ensureCapacity(total); + for (const column of columns) { + this.#view.set(column.#view.subarray(0, column.#length), this.#length); + this.#length += column.#length; + } + } + + /** Copy the filled portion (or a sub-range) into a new independent Column. */ + slice(start?: number, end?: number): Column { + const source = this.subarray(start, end); + const col = new Column(this.#ctor, source.length); + for (const value of source) { + col.push(value); + } + return col; + } + + #ensureCapacity(needed: number): void { + if (needed <= this.#view.length) { + return; + } + this.#grow(needed); + } + + #grow(minCapacity: number): void { + let newCapacity = Math.max(1, this.#view.length * 2); + while (newCapacity < minCapacity) { + newCapacity *= 2; + } + const newByteLength = newCapacity * this.#ctor.BYTES_PER_ELEMENT; + + if ( + sharedBufferAvailable && + this.#buffer instanceof SharedArrayBuffer && + this.#buffer.growable && + this.#buffer.maxByteLength >= newByteLength + ) { + this.#buffer.grow(newByteLength); + this.#view = new this.#ctor(this.#buffer); + } else { + const next = this.#alloc(newByteLength); + const nextView = new this.#ctor(next); + nextView.set(this.#view); + this.#buffer = next; + this.#view = nextView; + } + } + + [Symbol.iterator](): ArrayIterator { + return this.subarray()[Symbol.iterator](); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/interner.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/interner.ts new file mode 100644 index 00000000000..d1ef9049f4a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/interner.ts @@ -0,0 +1,57 @@ +/** + * Bidirectional interner: assigns a stable integer index to each + * unique value, and supports reverse lookup by index. + */ +export class Interner { + readonly #map: Map; + readonly #values: In[]; + + constructor() { + this.#map = new Map(); + this.#values = []; + } + + tryIntern(value: In): [boolean, Out] { + const existing = this.#map.get(value); + if (existing !== undefined) { + return [false, existing]; + } + + // Indices are assigned sequentially from 0; Out is a branded number type. + const index = this.#values.length as Out; + this.#values.push(value); + this.#map.set(value, index); + + return [true, index]; + } + + /** Returns the stable index for value, assigning the next monotonic index on first sight. */ + intern(value: In): Out { + const [, index] = this.tryIntern(value); + return index; + } + + /** Looks up a previously interned value's index without inserting. */ + tryGet(value: In): Out | undefined { + return this.#map.get(value); + } + + /** + * Reverse lookup by index. + * + * @throws {Error} When idx is out of range or was never assigned. + */ + getValue(idx: Out): In { + const value = this.#values[idx]; + + if (value === undefined) { + throw new Error(`Interner: no value at index ${idx}`); + } + + return value; + } + + get size(): number { + return this.#values.length; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/position-scratch.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/position-scratch.test.ts new file mode 100644 index 00000000000..6ae42869e2b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/position-scratch.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { PositionScratch } from "./position-scratch"; + +describe("PositionScratch", () => { + it("stores and reads back coordinates by index", () => { + const scratch = new PositionScratch(); + scratch.reset(8); + + scratch.set(0, 1.5, -2.5); + scratch.set(7, 100, 200); + + expect(scratch.has(0)).toBe(true); + expect(scratch.x(0)).toBe(1.5); + expect(scratch.y(0)).toBe(-2.5); + expect(scratch.has(7)).toBe(true); + expect(scratch.x(7)).toBe(100); + expect(scratch.y(7)).toBe(200); + }); + + it("reports unset and out-of-range slots as absent", () => { + const scratch = new PositionScratch(); + scratch.reset(4); + + expect(scratch.has(2)).toBe(false); + // Beyond the reset capacity, including beyond the backing buffer. + expect(scratch.has(4)).toBe(false); + expect(scratch.has(1000)).toBe(false); + }); + + it("treats (0, 0) as a real position, not an empty slot", () => { + const scratch = new PositionScratch(); + scratch.reset(2); + + scratch.set(1, 0, 0); + + expect(scratch.has(1)).toBe(true); + expect(scratch.x(1)).toBe(0); + expect(scratch.y(1)).toBe(0); + }); + + it("reset clears previous entries while reusing capacity", () => { + const scratch = new PositionScratch(); + scratch.reset(4); + scratch.set(3, 9, 9); + + scratch.reset(4); + + expect(scratch.has(3)).toBe(false); + }); + + it("reset with a larger capacity grows without losing set/get semantics", () => { + const scratch = new PositionScratch(); + scratch.reset(2); + scratch.set(1, 5, 6); + + scratch.reset(16); + + expect(scratch.has(1)).toBe(false); + scratch.set(15, -1, -2); + expect(scratch.x(15)).toBe(-1); + expect(scratch.y(15)).toBe(-2); + }); + + it("shrinking reset keeps larger indices absent even though the buffer is bigger", () => { + const scratch = new PositionScratch(); + scratch.reset(16); + scratch.set(10, 1, 2); + + scratch.reset(4); + + // Index 10 is outside the active range now; stale data must not leak. + expect(scratch.has(10)).toBe(false); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/position-scratch.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/position-scratch.ts new file mode 100644 index 00000000000..184f1f2a131 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/position-scratch.ts @@ -0,0 +1,52 @@ +/** + * Dense index to (x, y) scratch map: two Float64Arrays, one per coordinate + * plane. + * + * Replaces `Map` in seeding passes that run per layout + * (re)build: flat buffers instead of a Map plus one tuple allocation per + * entry, and `reset` reuses the buffers across passes. Separate x/y planes + * rather than one interleaved buffer: measured faster for both random access + * and sequential sweeps, and half the slots to clear when only presence (x) + * must be invalidated. + * + * A NaN x-coordinate marks an empty slot (a legitimate position is never + * NaN), so presence needs no separate bit set -- and clearing touches only + * the x plane. + */ +export class PositionScratch { + #xs = new Float64Array(0); + #ys = new Float64Array(0); + + /** Slots in use: the capacity passed to the last {@link reset}. */ + #length = 0; + + /** Empty every slot and ensure room for indices below `capacity`. */ + reset(capacity: number): void { + if (this.#xs.length < capacity) { + this.#xs = new Float64Array(capacity); + this.#ys = new Float64Array(capacity); + } + this.#length = capacity; + // Only x carries the empty marker; y is always written alongside it. + this.#xs.fill(Number.NaN, 0, capacity); + } + + has(index: Index): boolean { + return index < this.#length && !Number.isNaN(this.#xs[index]!); + } + + /** The x coordinate of a slot {@link set} earlier; NaN when empty. */ + x(index: Index): number { + return this.#xs[index]!; + } + + /** The y coordinate of a slot {@link set} earlier; meaningful only when {@link has}. */ + y(index: Index): number { + return this.#ys[index]!; + } + + set(index: Index, x: number, y: number): void { + this.#xs[index] = x; + this.#ys[index] = y; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/readonly-sorted-set.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/readonly-sorted-set.test.ts new file mode 100644 index 00000000000..c574b4cf7b4 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/readonly-sorted-set.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { ReadonlySortedSet } from "./readonly-sorted-set"; + +const numericCmp = (a: number, b: number) => a - b; + +const setOf = (...values: number[]) => + new ReadonlySortedSet(values, numericCmp); + +describe("ReadonlySortedSet", () => { + describe("isSubsetOf", () => { + it("empty is a subset of everything", () => { + expect(setOf().isSubsetOf(setOf(1, 2, 3))).toBe(true); + }); + + it("empty is a subset of empty", () => { + expect(setOf().isSubsetOf(setOf())).toBe(true); + }); + + it("equal sets are subsets of each other", () => { + expect(setOf(1, 2, 3).isSubsetOf(setOf(1, 2, 3))).toBe(true); + }); + + it("proper subset with gaps in the superset", () => { + expect(setOf(1, 3, 5).isSubsetOf(setOf(1, 2, 3, 4, 5, 6))).toBe(true); + }); + + it("single element present", () => { + expect(setOf(3).isSubsetOf(setOf(1, 2, 3, 4))).toBe(true); + }); + + it("single element missing", () => { + expect(setOf(7).isSubsetOf(setOf(1, 2, 3, 4))).toBe(false); + }); + + it("rejects when one element is missing", () => { + expect(setOf(1, 2, 3).isSubsetOf(setOf(1, 3))).toBe(false); + }); + + it("rejects a superset", () => { + expect(setOf(1, 2, 3).isSubsetOf(setOf(1, 2))).toBe(false); + }); + + it("rejects disjoint sets", () => { + expect(setOf(1, 2).isSubsetOf(setOf(3, 4))).toBe(false); + }); + + it("handles duplicates in input (deduped by constructor)", () => { + expect(setOf(1, 1, 2, 2).isSubsetOf(setOf(1, 2, 3))).toBe(true); + }); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/readonly-sorted-set.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/readonly-sorted-set.ts new file mode 100644 index 00000000000..192b0f745c3 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/readonly-sorted-set.ts @@ -0,0 +1,80 @@ +/** + * Immutable sorted set built once from an iterable; supports O(n+m) subset + * checks via merge walk. + */ +function binarySearch( + sorted: readonly T[], + target: T, + compare: (lhs: T, rhs: T) => number, +): number { + let lo = 0; + let hi = sorted.length - 1; + + while (lo <= hi) { + // eslint-disable-next-line no-bitwise + const mid = (lo + hi) >>> 1; + const val = sorted[mid]!; + + const comparison = compare(val, target); + if (comparison === 0) { + return mid; + } + if (comparison < 0) { + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + return -1; +} + +/** Immutable, deduplicated, sorted set. Constructed once, then read-only. */ +export class ReadonlySortedSet { + readonly #items: readonly T[]; + readonly #compare: (lhs: T, rhs: T) => number; + + constructor(values: Iterable, compare: (lhs: T, rhs: T) => number) { + this.#items = [...new Set(values)].sort(compare); + this.#compare = compare; + } + + get items(): readonly T[] { + return this.#items; + } + + get size(): number { + return this.#items.length; + } + + has(value: T): boolean { + return binarySearch(this.#items, value, this.#compare) >= 0; + } + + /** True when this set is a subset of other. Runs in O(|this| + |other|) via sorted merge walk. */ + isSubsetOf(other: ReadonlySortedSet): boolean { + const lhs = this.#items; + const rhs = other.#items; + let lhsIdx = 0; + let rhsIdx = 0; + + while (lhsIdx < lhs.length && rhsIdx < rhs.length) { + const comparison = this.#compare(lhs[lhsIdx]!, rhs[rhsIdx]!); + + if (comparison === 0) { + lhsIdx++; + rhsIdx++; + } else if (comparison > 0) { + rhsIdx++; + } else { + return false; + } + } + + return lhsIdx === lhs.length; + } + + *[Symbol.iterator](): IterableIterator { + yield* this.#items; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/uniform-grid.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/uniform-grid.test.ts new file mode 100644 index 00000000000..4e8b5b04589 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/uniform-grid.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { UniformGrid } from "./uniform-grid"; + +/** Brute-force reference: map of "cx,cy" → ascending point indices. */ +function referenceBuckets( + x: number[], + y: number[], + cellSize: number, +): Map { + const buckets = new Map(); + for (let point = 0; point < x.length; point++) { + const key = `${Math.floor(x[point]! / cellSize)},${Math.floor( + y[point]! / cellSize, + )}`; + const bucket = buckets.get(key); + if (bucket) { + bucket.push(point); + } else { + buckets.set(key, [point]); + } + } + return buckets; +} + +function membersOf(grid: UniformGrid, cellX: number, cellY: number): number[] { + const bucket = grid.bucketAt(cellX, cellY); + if (bucket < 0) { + return []; + } + const members: number[] = []; + for ( + let index = grid.starts[bucket]!; + index < grid.starts[bucket + 1]!; + index++ + ) { + members.push(grid.order[index]!); + } + return members; +} + +describe("UniformGrid", () => { + it("matches a reference bucketing, members ascending by index", () => { + const count = 500; + const x: number[] = []; + const y: number[] = []; + // Deterministic pseudo-random points, including negative coordinates. + let seed = 12345; + const next = () => { + // eslint-disable-next-line no-bitwise -- LCG step needs the u32 wrap + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 0x100000000 - 0.5; + }; + for (let point = 0; point < count; point++) { + x.push(next() * 1000); + y.push(next() * 1000); + } + + const cellSize = 37.5; + const grid = new UniformGrid(); + grid.build(x, y, count, cellSize); + + const reference = referenceBuckets(x, y, cellSize); + let referenceTotal = 0; + for (const [key, expected] of reference) { + const [cellX, cellY] = key.split(",").map(Number) as [number, number]; + expect(membersOf(grid, cellX, cellY)).toEqual(expected); + referenceTotal += expected.length; + } + expect(referenceTotal).toBe(count); + expect(grid.bucketCount).toBe(reference.size); + }); + + it("returns -1 for empty cells", () => { + const grid = new UniformGrid(); + grid.build([0, 10], [0, 10], 2, 4); + expect(grid.bucketAt(1000, 1000)).toBe(-1); + expect(grid.bucketAt(-1000, 5)).toBe(-1); + }); + + it("stores per-point cell coordinates", () => { + const grid = new UniformGrid(); + grid.build([-7, 0, 9], [3, 0, -1], 3, 4); + expect(grid.cellXOf(0)).toBe(Math.floor(-7 / 4)); + expect(grid.cellYOf(0)).toBe(Math.floor(3 / 4)); + expect(grid.cellXOf(2)).toBe(Math.floor(9 / 4)); + expect(grid.cellYOf(2)).toBe(Math.floor(-1 / 4)); + expect(grid.bucketOfPoint(1)).toBe(grid.bucketAt(0, 0)); + }); + + it("reuses buffers across rebuilds without stale state", () => { + const grid = new UniformGrid(); + grid.build([0, 1, 2, 100], [0, 1, 2, 100], 4, 8); + expect(membersOf(grid, 0, 0)).toEqual([0, 1, 2]); + expect(membersOf(grid, 12, 12)).toEqual([3]); + + // Rebuild with fewer points elsewhere: old cells must be gone. + grid.build([50], [50], 1, 8); + expect(membersOf(grid, 0, 0)).toEqual([]); + expect(membersOf(grid, 12, 12)).toEqual([]); + expect(membersOf(grid, 6, 6)).toEqual([0]); + expect(grid.bucketCount).toBe(1); + }); + + it("handles an empty point set", () => { + const grid = new UniformGrid(); + grid.build([], [], 0, 10); + expect(grid.bucketCount).toBe(0); + expect(grid.bucketAt(0, 0)).toBe(-1); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/uniform-grid.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/uniform-grid.ts new file mode 100644 index 00000000000..487032dd124 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/collections/uniform-grid.ts @@ -0,0 +1,240 @@ +/* eslint-disable no-bitwise */ +/** + * Reusable uniform spatial hash grid over a 2-D point set. + * + * The classic building block for neighbourhood queries on point/disk sets + * (overlap detection, near-pair search, pile detection): points are binned + * into square cells of a caller-chosen size, and any pair closer than one + * cell size is guaranteed to land in the same or an adjacent cell, so a 3×3 + * cell scan enumerates every candidate pair. + * + * Design, driven by the layout engine's per-pass use (rebuilt up to hundreds + * of times per solve over 10⁴-10⁵ points): + * + * - Cell lookup is an open-addressed hash table (power-of-two capacity, + * linear probing, exact-match on the integer cell coordinates), not a + * `Map`: no string keys, no per-cell array allocations, + * no rehash-growth churn. Collisions are impossible to observe (probing + * compares the actual cell coordinates), unlike packed-key schemes that + * must reserve coordinate ranges. + * - Membership is a counting sort into one flat `order` array: bucket + * members are contiguous and ascend by point index, which callers rely on + * for deterministic pair enumeration. + * - Every buffer is retained across {@link build} calls (grow-only + * high-water marks), so steady-state rebuilds allocate nothing. + * + * Deterministic: bucket ids are assigned in first-seen order over the + * index-ordered point scan, members within a bucket ascend by point index, + * and lookups exact-match cell coordinates. Identical input yields identical + * iteration order, independent of hash-table layout. + */ + +/** + * One 2-D point set snapshot, hashed by cell. See the module doc for the + * design; see {@link build} for the lifecycle. + */ +export class UniformGrid { + #count = 0; + #cellSize = 1; + #bucketCount = 0; + + /** Per-point cell coordinates (valid for indices < {@link build}'s count). */ + #cellX = new Int32Array(0); + #cellY = new Int32Array(0); + #bucketOfPoint = new Int32Array(0); + + /** Open-addressed cell table: coordinates + bucket id, -1 = empty slot. */ + #tableMask = 0; + #tableCellX = new Int32Array(0); + #tableCellY = new Int32Array(0); + #tableBucket = new Int32Array(0); + + /** Counting-sort layout: bucket b owns order[starts[b] .. starts[b+1]). */ + #starts = new Int32Array(1); + #order = new Uint32Array(0); + /** Scratch cursor reused by the counting sort's scatter pass. */ + #cursor = new Int32Array(0); + + get cellSize(): number { + return this.#cellSize; + } + + get bucketCount(): number { + return this.#bucketCount; + } + + /** + * Bucket b's members are `order[starts[b] .. starts[b+1])`, ascending by + * point index. Valid until the next {@link build}. + */ + get starts(): Int32Array { + return this.#starts; + } + + get count(): number { + return this.#count; + } + + /** The counting-sorted member array (see {@link starts}). */ + get order(): Uint32Array { + return this.#order; + } + + cellXOf(point: number): number { + return this.#cellX[point]!; + } + + cellYOf(point: number): number { + return this.#cellY[point]!; + } + + /** The bucket id of the point's own cell. */ + bucketOfPoint(point: number): number { + return this.#bucketOfPoint[point]!; + } + + static #hash(cellX: number, cellY: number): number { + let hash = Math.imul(cellX, 0x9e3779b1) ^ Math.imul(cellY, 0x85ebca77); + hash ^= hash >>> 15; + return hash; + } + + /** + * The bucket id at integer cell (cellX, cellY), or -1 when no point + * occupies that cell. O(1) expected (load factor ≤ ½). + */ + bucketAt(cellX: number, cellY: number): number { + const mask = this.#tableMask; + let slot = UniformGrid.#hash(cellX, cellY) & mask; + + for (;;) { + const bucket = this.#tableBucket[slot]!; + if (bucket === -1) { + return -1; + } + + if ( + this.#tableCellX[slot]! === cellX && + this.#tableCellY[slot]! === cellY + ) { + return bucket; + } + + slot = (slot + 1) & mask; + } + } + + /** + * (Re)build the grid over `count` points at cell size `cellSize`. + * Reads x/y once; the snapshot stays valid while callers mutate positions + * afterwards. + */ + build( + x: ArrayLike, + y: ArrayLike, + count: number, + cellSize: number, + ): void { + this.#count = count; + this.#cellSize = cellSize; + + if (this.#cellX.length < count) { + this.#cellX = new Int32Array(count); + this.#cellY = new Int32Array(count); + this.#bucketOfPoint = new Int32Array(count); + this.#cursor = new Int32Array(count + 1); + } + + // Table capacity: next power of two ≥ 2·count keeps the load factor ≤ ½. + let capacity = 16; + while (capacity < count * 2) { + capacity *= 2; + } + + if (this.#tableBucket.length < capacity) { + this.#tableCellX = new Int32Array(capacity); + this.#tableCellY = new Int32Array(capacity); + this.#tableBucket = new Int32Array(capacity); + } + + const mask = capacity - 1; + this.#tableMask = mask; + this.#tableBucket.fill(-1, 0, capacity); + + // Pass 1: assign cells, intern each distinct cell into a bucket id + // (first-seen order over the index scan), count members per bucket. + const invCell = 1 / cellSize; + const counts = this.#cursor; + + let bucketCount = 0; + + for (let point = 0; point < count; point++) { + const cellX = Math.floor(x[point]! * invCell); + const cellY = Math.floor(y[point]! * invCell); + + this.#cellX[point] = cellX; + this.#cellY[point] = cellY; + + let slot = UniformGrid.#hash(cellX, cellY) & mask; + let bucket: number; + + for (;;) { + const existing = this.#tableBucket[slot]!; + + if (existing === -1) { + bucket = bucketCount; + bucketCount += 1; + this.#tableBucket[slot] = bucket; + this.#tableCellX[slot] = cellX; + this.#tableCellY[slot] = cellY; + counts[bucket] = 0; + + break; + } + + if ( + this.#tableCellX[slot]! === cellX && + this.#tableCellY[slot]! === cellY + ) { + bucket = existing; + + break; + } + + slot = (slot + 1) & mask; + } + + this.#bucketOfPoint[point] = bucket; + counts[bucket]! += 1; + } + + this.#bucketCount = bucketCount; + + // Pass 2: prefix-sum starts, then scatter points in index order so each + // bucket's members ascend by point index. + if (this.#starts.length < bucketCount + 1) { + this.#starts = new Int32Array(bucketCount + 1); + } + + if (this.#order.length < count) { + this.#order = new Uint32Array(count); + } + + const starts = this.#starts; + starts[0] = 0; + + for (let bucket = 0; bucket < bucketCount; bucket++) { + starts[bucket + 1] = starts[bucket]! + counts[bucket]!; + } + + for (let bucket = 0; bucket < bucketCount; bucket++) { + counts[bucket] = starts[bucket]!; + } + + for (let point = 0; point < count; point++) { + const bucket = this.#bucketOfPoint[point]!; + this.#order[counts[bucket]!] = point; + counts[bucket]! += 1; + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/commit-coalescer.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/commit-coalescer.test.ts new file mode 100644 index 00000000000..2c3729da06e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/commit-coalescer.test.ts @@ -0,0 +1,204 @@ +/** + * Policy guards for {@link CommitCoalescer}: first-enqueue flush (cold-load + * latency), drain-deferred coalescing, the batch-count / age backstops, and + * the cross-batch delta merge (sums per group, first `previousCount` / + * `isNewGroup` win, `rebuildTree` rides the merged commit exactly once). + */ +import { describe, expect, it } from "vitest"; + +import { TypeSetKey } from "../../ids"; +import { + type CoalescedCommit, + CommitCoalescer, + MAX_COALESCE_DELAY_MS, + MAX_COALESCED_BATCHES, +} from "./commit-coalescer"; + +import type { IngestDelta } from "../hierarchy/cluster-tree"; + +function delta( + groupKey: string, + growth: number, + opts?: { isNewGroup?: boolean; previousCount?: number }, +): IngestDelta { + return { + groupKey: TypeSetKey(groupKey), + delta: growth, + isNewGroup: opts?.isNewGroup ?? false, + previousCount: opts?.previousCount ?? 0, + }; +} + +interface Harness { + readonly coalescer: CommitCoalescer; + readonly commits: CoalescedCommit[]; + /** Deliver the pending drain message, as the event loop would after a burst. */ + fireDrain(): void; + advanceClock(ms: number): void; +} + +function newHarness(): Harness { + const commits: CoalescedCommit[] = []; + let clock = 0; + let pendingFire: (() => void) | undefined; + const coalescer = new CommitCoalescer({ + commit: (opts) => commits.push(opts), + now: () => clock, + scheduleDrain: (fire) => { + pendingFire = fire; + }, + }); + return { + coalescer, + commits, + fireDrain() { + const fire = pendingFire; + pendingFire = undefined; + fire?.(); + }, + advanceClock(ms) { + clock += ms; + }, + }; +} + +/** A harness whose first-flush latch is already spent (steady-state stream). */ +function primedHarness(): Harness { + const harness = newHarness(); + harness.coalescer.enqueueDeltas([delta("g0", 1)]); + harness.commits.length = 0; + return harness; +} + +describe("CommitCoalescer, latency policy", () => { + it("flushes the very first enqueue synchronously (cold-load first paint)", () => { + const harness = newHarness(); + harness.coalescer.enqueueDeltas([ + delta("people", 10, { isNewGroup: true }), + ]); + + expect(harness.commits).toHaveLength(1); + expect(harness.coalescer.hasPending).toBe(false); + }); + + it("defers subsequent batches to the drain, producing one merged commit", () => { + const harness = primedHarness(); + + harness.coalescer.enqueueDeltas([delta("people", 5)]); + harness.coalescer.enqueueDeltas([delta("people", 7)]); + harness.coalescer.enqueueDeltas([delta("orgs", 3, { isNewGroup: true })]); + expect(harness.commits).toHaveLength(0); + expect(harness.coalescer.hasPending).toBe(true); + + harness.fireDrain(); + + expect(harness.commits).toHaveLength(1); + expect(harness.coalescer.hasPending).toBe(false); + }); + + it("batches separated by a drain land in separate commits (progressive paint)", () => { + const harness = primedHarness(); + + harness.coalescer.enqueueDeltas([delta("people", 5)]); + harness.fireDrain(); + harness.coalescer.enqueueDeltas([delta("people", 7)]); + harness.fireDrain(); + + expect(harness.commits).toHaveLength(2); + }); + + it("caps a sustained burst at MAX_COALESCED_BATCHES even if the drain never fires", () => { + const harness = primedHarness(); + + for (let batch = 0; batch < MAX_COALESCED_BATCHES; batch++) { + harness.coalescer.enqueueDeltas([delta("people", 1)]); + } + + expect(harness.commits).toHaveLength(1); + expect(harness.coalescer.hasPending).toBe(false); + }); + + it("caps deferral age at MAX_COALESCE_DELAY_MS (checked at enqueue)", () => { + const harness = primedHarness(); + + harness.coalescer.enqueueDeltas([delta("people", 1)]); + harness.advanceClock(MAX_COALESCE_DELAY_MS); + harness.coalescer.enqueueDeltas([delta("people", 1)]); + + expect(harness.commits).toHaveLength(1); + }); + + it("a drain arriving after a cap flush is a no-op, not an empty commit", () => { + const harness = primedHarness(); + + for (let batch = 0; batch < MAX_COALESCED_BATCHES; batch++) { + harness.coalescer.enqueueDeltas([delta("people", 1)]); + } + expect(harness.commits).toHaveLength(1); + + harness.fireDrain(); + expect(harness.commits).toHaveLength(1); + }); + + it("explicit flush() commits pending state and reports whether it ran", () => { + const harness = primedHarness(); + + expect(harness.coalescer.flush()).toBe(false); + harness.coalescer.enqueueDeltas([delta("people", 5)]); + expect(harness.coalescer.flush()).toBe(true); + expect(harness.commits).toHaveLength(1); + expect(harness.coalescer.flush()).toBe(false); + }); +}); + +describe("CommitCoalescer, merged-delta semantics", () => { + it("sums per-group deltas; previousCount/isNewGroup keep the first batch's values", () => { + const harness = primedHarness(); + + harness.coalescer.enqueueDeltas([ + delta("people", 5, { isNewGroup: true, previousCount: 0 }), + ]); + harness.coalescer.enqueueDeltas([ + delta("people", 7, { isNewGroup: false, previousCount: 5 }), + delta("orgs", 2, { isNewGroup: false, previousCount: 40 }), + ]); + harness.fireDrain(); + + const [commit] = harness.commits; + expect(commit!.deltas).toEqual([ + { groupKey: "people", delta: 12, isNewGroup: true, previousCount: 0 }, + { groupKey: "orgs", delta: 2, isNewGroup: false, previousCount: 40 }, + ]); + }); + + it("merges a mid-burst rebuildTree into the burst's single commit, exactly once", () => { + const harness = primedHarness(); + + harness.coalescer.enqueueDeltas([delta("people", 5)]); + harness.coalescer.enqueueRebuildTree(); + harness.coalescer.enqueueDeltas([delta("people", 3)]); + harness.fireDrain(); + + expect(harness.commits).toHaveLength(1); + expect(harness.commits[0]!.rebuildTree).toBe(true); + expect(harness.commits[0]!.deltas).toEqual([ + { groupKey: "people", delta: 8, isNewGroup: false, previousCount: 0 }, + ]); + + // The flag must not leak into the next burst. + harness.coalescer.enqueueDeltas([delta("people", 1)]); + harness.fireDrain(); + expect(harness.commits[1]!.rebuildTree).toBe(false); + }); + + it("counts a link-only batch (no deltas) so the commit still lands", () => { + const harness = primedHarness(); + + harness.coalescer.enqueueDeltas([]); + expect(harness.coalescer.hasPending).toBe(true); + + harness.fireDrain(); + expect(harness.commits).toHaveLength(1); + expect(harness.commits[0]!.deltas).toEqual([]); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/commit-coalescer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/commit-coalescer.ts new file mode 100644 index 00000000000..ff17260d22d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/commit-coalescer.ts @@ -0,0 +1,213 @@ +/** + * Coalesces structure commits during streaming ingest. + * + * Ingest still lands per batch in the stores immediately (cheap, + * incremental), but the expensive commit routes through this class instead + * of running per batch. A burst of batches sitting in the worker's message + * queue then produces one commit: the first deferral posts a message to a + * MessageChannel port, which the event loop delivers only after the already + * queued worker messages drain (the same property {@link TickScheduler} + * exploits), so a saturated queue yields one merged commit per queue cycle. + * Batches separated by a real gap land in separate commits, so progressive + * rendering is preserved. + * + * Latency policy: + * - The first enqueue ever flushes synchronously, so a cold load's first + * visible structure is never held back by coalescing. + * - A sustained stream that keeps the queue non-empty cannot starve the + * commit: the drain message caps deferral at one queue cycle, and two + * backstops bound pathological backlogs (a batch-count cap + * ({@link MAX_COALESCED_BATCHES}) and an age cap + * ({@link MAX_COALESCE_DELAY_MS}), both checked at enqueue time. + * + * Delta merging: per-group `delta`s sum; `previousCount` / `isNewGroup` keep + * the values of the burst's first batch touching that group (stores are + * add-only, so `count(flush) - previousCount(first) = Σ deltas`). A + * `rebuildTree` request (new type registered mid-burst) becomes a flag on the + * merged commit, applied exactly once. It subsumes the deltas in the + * hierarchical tier (a tree rebuild reads the full stores). + */ +import type { TypeSetKey } from "../../ids"; +import type { IngestDelta } from "../hierarchy/cluster-tree"; + +/** Options forwarded to {@link EntityGraphWorker.commitStructure} on flush. */ +export interface CoalescedCommit { + readonly deltas: readonly IngestDelta[]; + readonly rebuildTree: boolean; +} + +/** + * Flush once this many batches are pending, even mid-burst. Bounds how much + * a sustained stream batches into one commit (progressive rendering) and is + * what paces commits in synchronous drivers (tests/benches) where the drain + * message never gets to fire. + */ +export const MAX_COALESCED_BATCHES = 8; + +/** + * Flush when the oldest pending batch is older than this (checked at enqueue + * time). A backstop for backlogs of few but slow-to-ingest batches, where the + * batch-count cap would not trip; aligned with the default + * `stability.flatLouvainLingerMs`. + */ +export const MAX_COALESCE_DELAY_MS = 100; + +interface MutableDelta { + readonly groupKey: TypeSetKey; + delta: number; + readonly isNewGroup: boolean; + readonly previousCount: number; +} + +export interface CommitCoalescerDependencies { + /** Runs the actual commit (and any post-commit work, e.g. root-flip restyle). */ + readonly commit: (opts: CoalescedCommit) => void; + /** Batch-count flush cap. Defaults to {@link MAX_COALESCED_BATCHES}. */ + readonly maxCoalescedBatches?: number; + /** Oldest-pending-batch age flush cap (ms). Defaults to {@link MAX_COALESCE_DELAY_MS}. */ + readonly maxCoalesceDelayMs?: number; + /** Injectable clock (tests). Defaults to `performance.now`. */ + readonly now?: () => number; + /** + * Injectable drain scheduling (tests). `schedule` is called at most once per + * pending cycle and must eventually invoke `fire` on a later event-loop + * turn. Defaults to a MessageChannel port message, which the event loop + * delivers after the currently queued worker messages. + */ + readonly scheduleDrain?: (fire: () => void) => void; +} + +export class CommitCoalescer { + readonly #commit: (opts: CoalescedCommit) => void; + readonly #maxCoalescedBatches: number; + readonly #maxCoalesceDelayMs: number; + readonly #now: () => number; + readonly #scheduleDrain: (fire: () => void) => void; + + /** Merged per-group deltas of the pending burst, in first-touched order. */ + readonly #pendingDeltas = new Map(); + #pendingRebuildTree = false; + /** Enqueues since the last flush (a link-only batch has no deltas but still counts). */ + #pendingCount = 0; + /** Clock reading of the first pending enqueue; undefined when nothing is pending. */ + #pendingSince: number | undefined; + /** Whether a drain message is in flight (posted but not yet fired). */ + #drainArmed = false; + /** First-ever enqueue flushes synchronously (bounded first-paint latency). */ + #everFlushed = false; + + constructor(dependencies: CommitCoalescerDependencies) { + this.#commit = dependencies.commit; + this.#maxCoalescedBatches = + dependencies.maxCoalescedBatches ?? MAX_COALESCED_BATCHES; + this.#maxCoalesceDelayMs = + dependencies.maxCoalesceDelayMs ?? MAX_COALESCE_DELAY_MS; + this.#now = dependencies.now ?? (() => performance.now()); + this.#scheduleDrain = + dependencies.scheduleDrain ?? this.#defaultDrainScheduler(); + } + + get hasPending(): boolean { + return this.#pendingCount > 0; + } + + /** + * Record one ingested batch's deltas (possibly empty; a link-only batch + * still changes topology via the link count) and flush or defer per policy. + */ + enqueueDeltas(deltas: readonly IngestDelta[]): void { + for (const delta of deltas) { + const merged = this.#pendingDeltas.get(delta.groupKey); + + if (merged) { + merged.delta += delta.delta; + } else { + this.#pendingDeltas.set(delta.groupKey, { ...delta }); + } + } + + this.#recordEnqueue(); + } + + /** Record a tree-rebuild request (new type registered) and flush or defer per policy. */ + enqueueRebuildTree(): void { + this.#pendingRebuildTree = true; + this.#recordEnqueue(); + } + + /** + * Commit everything pending now. Invoked by the drain scheduler, + * enqueue-time caps, and any protocol handler that must observe committed + * topology before answering (viewport, queries, pin/highlight). No-op when + * idle; returns whether a commit ran. + */ + flush(): boolean { + if (this.#pendingCount === 0) { + return false; + } + + const commit: CoalescedCommit = { + deltas: [...this.#pendingDeltas.values()], + rebuildTree: this.#pendingRebuildTree, + }; + + this.#pendingDeltas.clear(); + this.#pendingRebuildTree = false; + this.#pendingCount = 0; + this.#pendingSince = undefined; + this.#everFlushed = true; + this.#commit(commit); + return true; + } + + #recordEnqueue(): void { + this.#pendingCount += 1; + this.#pendingSince ??= this.#now(); + + if (!this.#everFlushed) { + // Cold load: the first structure must not wait behind the burst. + this.flush(); + return; + } + + if ( + this.#pendingCount >= this.#maxCoalescedBatches || + this.#now() - this.#pendingSince >= this.#maxCoalesceDelayMs + ) { + this.flush(); + return; + } + + if (!this.#drainArmed) { + this.#drainArmed = true; + this.#scheduleDrain(() => { + this.#drainArmed = false; + // A cap may have flushed already; flush() no-ops on an empty state. + this.flush(); + }); + } + } + + /** + * The production drain: one MessageChannel whose port message is delivered + * after the worker messages already queued at post time (i.e. after the + * rest of the current ingest burst has been ingested and merged. + */ + #defaultDrainScheduler(): (fire: () => void) => void { + let channel: MessageChannel | undefined; + let pendingFire: (() => void) | undefined; + + return (fire: () => void) => { + if (!channel) { + channel = new MessageChannel(); + channel.port1.onmessage = () => { + const run = pendingFire; + pendingFire = undefined; + run?.(); + }; + } + pendingFire = fire; + channel.port2.postMessage(undefined); + }; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/committed-view.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/committed-view.ts new file mode 100644 index 00000000000..a602c36cdd2 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/committed-view.ts @@ -0,0 +1,58 @@ +import type { ClusterId } from "../../ids"; +import type { CutIndex, EdgeFrame } from "../geometry/edge-aggregation"; +import type { ClusterNode } from "../hierarchy/cluster-tree"; + +/** A visible cluster plus its container nesting depth (0 = leaf/standalone). */ +export interface RenderedEntry { + readonly node: ClusterNode; + readonly depth: number; +} + +/** + * The committed hierarchical view: the visible cluster set and the topology + * derived from it at the last structure commit. One mutable instance is + * shared by the commit path (writer) and the frame emitters (readers), so a + * position tick always renders exactly the topology the last commit produced. + * + * `cutIndex`/`edgeFrame` are `undefined` outside the hierarchical regime and + * before the first viewport arrives. + */ +export class CommittedView { + /** Committed visible clusters, in a stable order; positions index-align. */ + rendered: RenderedEntry[] = []; + readonly renderedIndex = new Map(); + + /** Cached topology from the last structure commit; drives position-only ticks. */ + cutIndex: CutIndex | undefined; + edgeFrame: EdgeFrame | undefined; + + /** Bumped on every cluster-tree mutation; compared against committed values to detect no-ops. */ + clusterEpoch = 0; + /** Epoch and link count as of the last emitted hierarchical structure frame. */ + committedClusterEpoch = -1; + committedLinkCount = -1; + + /** + * Atomically swaps the committed visible-cluster list and rebuilds + * {@link renderedIndex} so cluster ids index-align with position buffers. + */ + replaceRendered(rendered: RenderedEntry[]): void { + this.rendered = rendered; + this.renderedIndex.clear(); + for (let idx = 0; idx < rendered.length; idx++) { + // rendered is built contiguously by callers; idx is always in range + // and entries are non-null. + this.renderedIndex.set(rendered[idx]!.node.id, idx); + } + } + + clearRendered(): void { + this.rendered = []; + this.renderedIndex.clear(); + } + + clearTopology(): void { + this.cutIndex = undefined; + this.edgeFrame = undefined; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/flat-edge-writer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/flat-edge-writer.ts new file mode 100644 index 00000000000..c18d395ea10 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/flat-edge-writer.ts @@ -0,0 +1,86 @@ +/** + * The shared straight-edge writer for flat pipelines: turns one edge between + * two placed disks into a clipped straight cubic (inset off both node rims) + * plus a packed endpoint arrow. Both flat lifecycles use it — the entity + * tier's per-link pipeline ({@link "../entity-graph/flat/edges"}) and the + * type graph's edge table — so the inset/arrow geometry cannot drift between + * them. Per-tick hot path: scalar sink writes only, no allocation. + */ +import type { Color } from "../../frames"; +import type { + BezierSegmentSink, + EndpointArrowSink, +} from "../geometry/edge-geometry"; + +/** + * Write one straight edge from disk A (`ax`, `ay`, radius `aRadius`) to disk + * B, clipped off both rims, as a degenerate cubic plus an endpoint arrow at + * the target. Skips degenerate edges (coincident nodes, or a visible chord + * shorter than the stroke width). `id` is the segment's pick identity + * (`beziers.ids[i]`): the caller decides what it indexes (the entity tier + * writes the link's EntityIdx, the type graph an edge-table index). + */ +export function writeStraightFlatEdge( + sink: BezierSegmentSink, + arrows: EndpointArrowSink, + ax: number, + ay: number, + aRadius: number, + bx: number, + by: number, + bRadius: number, + color: Color, + edgeWidth: number, + id: number, +): void { + const dx = bx - ax; + const dy = by - ay; + const chord = Math.hypot(dx, dy); + + if (chord <= 0.001) { + return; + } + + const startDistance = aRadius + edgeWidth; + const endDistance = bRadius + edgeWidth; + const visibleChord = chord - startDistance - endDistance; + if (visibleChord <= edgeWidth) { + return; + } + + const ux = dx / chord; + const uy = dy / chord; + const sx = ax + ux * startDistance; + const sy = ay + uy * startDistance; + const tx = bx - ux * endDistance; + const ty = by - uy * endDistance; + const edgeEndInset = Math.min(edgeWidth * 0.9, visibleChord * 0.35); + const edgeTx = tx - ux * edgeEndInset; + const edgeTy = ty - uy * edgeEndInset; + const visibleDx = edgeTx - sx; + const visibleDy = edgeTy - sy; + + sink.pushUnclipped( + sx, + sy, + sx + visibleDx / 3, + sy + visibleDy / 3, + sx + (2 * visibleDx) / 3, + sy + (2 * visibleDy) / 3, + edgeTx, + edgeTy, + color, + edgeWidth, + id, + ); + + const arrowInset = Math.min(edgeWidth * 0.45, visibleChord * 0.2); + arrows.push( + tx + ux * arrowInset, + ty + uy * arrowInset, + Math.atan2(dy, dx), + edgeWidth, + visibleChord, + color, + ); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/flat-seed.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/flat-seed.ts new file mode 100644 index 00000000000..6b8f282897e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/flat-seed.ts @@ -0,0 +1,76 @@ +/** + * Seed placement for whole-graph flat layout (re)builds, shared by the two + * flat lifecycles (the entity flat tier and the type graph). + * + * Already-placed nodes keep their prior position; new nodes land beside a + * placed neighbour (golden-angle spread so siblings fan out); remaining + * orphans fill a deterministic phyllotaxis disk. + */ +import type { PositionScratch } from "../collections/position-scratch"; + +/** Seed offset (world units) for a streamed node placed beside a placed neighbour. */ +export const FLAT_SEED_NEIGHBOUR_OFFSET = 24; + +/** Phyllotaxis disk scale (world units) for cold-start / orphan flat nodes. */ +export const FLAT_SEED_DISK_SCALE = 28; + +/** Optional overrides for the seeding geometry (defaults above). */ +export interface FlatSeedTuning { + readonly neighbourOffset?: number; + readonly diskScale?: number; +} + +/** + * Fill `placed` with a position for every id in `ids`. `placed` holds the + * prior positions on entry (see {@link PositionScratch}) and is extended in + * place with every new placement, so after the call it covers all of `ids`. + */ +export function placeFlatSeeds( + ids: readonly Index[], + placed: PositionScratch, + neighboursOf: (id: Index) => Iterable, + tuning?: FlatSeedTuning, +): void { + const neighbourOffset = tuning?.neighbourOffset ?? FLAT_SEED_NEIGHBOUR_OFFSET; + const diskScale = tuning?.diskScale ?? FLAT_SEED_DISK_SCALE; + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + + // Repeat until no progress: each unplaced node with a placed neighbor + // picks a golden-angle offset beside that neighbor (handles out-of-order + // input). + let changed = true; + while (changed) { + changed = false; + + for (const id of ids) { + if (placed.has(id)) { + continue; + } + + for (const neighbour of neighboursOf(id)) { + if (placed.has(neighbour)) { + const angle = id * goldenAngle; + + placed.set( + id, + placed.x(neighbour) + Math.cos(angle) * neighbourOffset, + placed.y(neighbour) + Math.sin(angle) * neighbourOffset, + ); + + changed = true; + break; + } + } + } + } + + // Remaining unplaced -> a phyllotaxis disk (even, deterministic fill). + const unplaced = ids.filter((id) => !placed.has(id)); + const fillRadius = diskScale * Math.sqrt(Math.max(1, unplaced.length)); + + for (let slot = 0; slot < unplaced.length; slot++) { + const dist = fillRadius * Math.sqrt((slot + 0.5) / unplaced.length); + const angle = slot * goldenAngle; + placed.set(unplaced[slot]!, Math.cos(angle) * dist, Math.sin(angle) * dist); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/frames/leaf-local-cache.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/frames/leaf-local-cache.test.ts new file mode 100644 index 00000000000..7358340ad3a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/frames/leaf-local-cache.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { EntityIndex, nodeIdForEntityIndex } from "../../../ids"; +import { LeafLocalCache } from "./leaf-local-cache"; + +describe("LeafLocalCache", () => { + it("maps entity indices to their local layout slots", () => { + const cache = new LeafLocalCache(); + const layout = { + nodeIds: [ + nodeIdForEntityIndex(EntityIndex(7)), + nodeIdForEntityIndex(EntityIndex(3)), + ], + }; + + const localOf = cache.of(layout); + + expect(localOf.get(EntityIndex(7))).toBe(0); + expect(localOf.get(EntityIndex(3))).toBe(1); + expect(localOf.size).toBe(2); + }); + + it("returns the cached map for the same layout object", () => { + const cache = new LeafLocalCache(); + const layout = { nodeIds: [nodeIdForEntityIndex(EntityIndex(1))] }; + + expect(cache.of(layout)).toBe(cache.of(layout)); + }); + + it("rebuilds for a new layout object even with identical node ids", () => { + const cache = new LeafLocalCache(); + const nodeIds = [nodeIdForEntityIndex(EntityIndex(1))]; + + const first = cache.of({ nodeIds }); + const second = cache.of({ nodeIds }); + + expect(second).not.toBe(first); + expect(second).toEqual(first); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/frames/leaf-local-cache.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/frames/leaf-local-cache.ts new file mode 100644 index 00000000000..cd1ccd0dec0 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/frames/leaf-local-cache.ts @@ -0,0 +1,36 @@ +import { entityIndexFromNodeId } from "../../../ids"; + +import type { EntityIndex } from "../../../ids"; + +/** The slice of a layout simulation the cache keys on and reads. */ +export interface LeafLayoutNodeIds { + readonly nodeIds: readonly string[]; +} + +/** + * Entity-index to local-slot maps for leaf layouts, keyed on the layout + * object so an entry invalidates automatically when the node set changes + * (a changed member set always produces a new layout). + */ +export class LeafLocalCache { + readonly #cache = new WeakMap< + LeafLayoutNodeIds, + ReadonlyMap + >(); + + of(layout: LeafLayoutNodeIds): ReadonlyMap { + const cached = this.#cache.get(layout); + if (cached) { + return cached; + } + + const localOf = new Map(); + // idx is bounded by layout.nodeIds.length in the loop. + for (let idx = 0; idx < layout.nodeIds.length; idx++) { + localOf.set(entityIndexFromNodeId(layout.nodeIds[idx]!), idx); + } + + this.#cache.set(layout, localOf); + return localOf; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/frames/positions-frame.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/frames/positions-frame.ts new file mode 100644 index 00000000000..aa65bc08618 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/frames/positions-frame.ts @@ -0,0 +1,360 @@ +/** + * PositionsFrame emission: cluster positions, highway/feeder Bezier + * geometry, edge labels/arrows, and the per-leaf entity fan-out, all + * computed from the current node positions against the committed topology. + * Runs every tick that moved something, plus once per structure commit. + */ +import { entityIndexFromNodeId } from "../../../ids"; +import { computeAllPorts } from "../../geometry/bubble-ports"; +import { makePairKey } from "../../geometry/edge-aggregation"; +import { + BezierSegmentSink, + buildBezierSegments, + containerBoundaryWaypoint, + EndpointArrowSink, + highwayEndpoints, + portsFor, +} from "../../geometry/edge-geometry"; + +import type { VizConfig } from "../../../config"; +import type { + PositionsFrame, + RenderEdgeArrow, + RenderEdgeLabel, + RenderEndpointArrowBuffers, + RenderEntityFanOut, +} from "../../../frames"; +import type { Position } from "../../../geometry"; +import type { ClusterId, TypeSetId } from "../../../ids"; +import type { PortConstraintController } from "../../entity-graph/hierarchical/port-constraints"; +import type { LinkStore } from "../../entity-graph/store/link"; +import type { Port, PortCache } from "../../geometry/bubble-ports"; +import type { CutIndex, EdgeAggregator } from "../../geometry/edge-aggregation"; +import type { ClusterTree } from "../../hierarchy/cluster-tree"; +import type { CommittedView } from "../committed-view"; +import type { LayoutRegistry } from "../layout-registry"; +import type { LeafLocalCache } from "./leaf-local-cache"; + +type PortPairs = ReadonlyMap< + string, + { readonly source: Port; readonly target: Port } +>; + +/** Supplies straight clipped edge segments when hierarchical Bezier routing is inactive. */ +export interface FlatEdgeSource { + readonly hasRenderEdges: boolean; + buildEdgeBeziers(sink: BezierSegmentSink, arrows: EndpointArrowSink): void; +} + +export interface PositionsFrameDependencies { + readonly config: VizConfig; + readonly view: CommittedView; + readonly layouts: LayoutRegistry; + readonly leafLocalCache: LeafLocalCache; + readonly clusterTree: ClusterTree; + readonly links: LinkStore; + readonly edgeAggregator: EdgeAggregator; + readonly portCache: PortCache; + readonly portConstraints: PortConstraintController; + readonly flatEdges: FlatEdgeSource; + readonly syncWorldPositions: () => void; + readonly onFrame: (frame: PositionsFrame) => void; +} + +export class PositionsFrameEmitter { + readonly #dependencies: PositionsFrameDependencies; + + #version = 0; + /** Reused flat-array scratch for Bezier segments; snapshot()ed per frame. */ + readonly #bezierSink = new BezierSegmentSink(); + /** Reused packed scratch for flat-tier endpoint arrows; snapshot()ed per frame. */ + readonly #arrowSink = new EndpointArrowSink(); + + constructor(dependencies: PositionsFrameDependencies) { + this.#dependencies = dependencies; + } + + /** + * Publishes one positions tick: syncs world circles, recomputes ports and + * edge geometry, and fans out per-leaf feeder endpoints. + */ + emit(): void { + const { view, layouts, clusterTree, config, flatEdges } = + this.#dependencies; + + // Authoritative: recompose the opened subtree's world circles before any + // positional read, so a moved ancestor reaches its whole subtree (incl. + // settled depth >= 2 layouts), on commit ticks too, not only while moving. + this.#dependencies.syncWorldPositions(); + const ports = this.#computePorts(); + + // Labels are emitted by the geometry builder at true curve midpoints, so + // they sit on the drawn highways (one per merged highway, not per base pair). + const edgeLabels: RenderEdgeLabel[] = []; + const edgeArrows: RenderEdgeArrow[] = []; + let flatArrows: RenderEndpointArrowBuffers | undefined; + + this.#bezierSink.reset(); + if (view.edgeFrame && view.cutIndex) { + // Every visible bubble (including opened containers) is a potential + // obstacle; routeAround exempts the ones that enclose an edge's endpoint. + const obstacles = view.rendered.map((entry) => ({ + id: entry.node.id, + circle: entry.node.circle, + })); + + buildBezierSegments( + view.edgeFrame, + ports, + { clusterTree, cutIndex: view.cutIndex, obstacles }, + config, + this.#bezierSink, + edgeLabels, + edgeArrows, + ); + } else if (flatEdges.hasRenderEdges) { + // Flat tier emits straight clipped segments (no cluster containers to + // route around) and packed per-edge arrows (object arrows at 22k+ edges + // made postMessage serialisation the dominant frame cost). + this.#arrowSink.reset(); + flatEdges.buildEdgeBeziers(this.#bezierSink, this.#arrowSink); + flatArrows = this.#arrowSink.snapshot(); + } + + const beziers = this.#bezierSink.snapshot(); + + const clusterPositions = new Float32Array(view.rendered.length * 2); + for (let idx = 0; idx < view.rendered.length; idx++) { + const circle = view.rendered[idx]!.node.circle; + clusterPositions[idx * 2] = circle.x; + clusterPositions[idx * 2 + 1] = circle.y; + } + + // Fan-out feeder endpoints + force targets for the current positions + // (positional: refreshed every tick, never via the structure frame). + const entityFanOut = + view.cutIndex && view.edgeFrame + ? this.#buildEntityFanOut(view.cutIndex, ports) + : []; + + this.#version++; + this.#dependencies.onFrame({ + version: this.#version, + // settled gates main-thread animation: false while any force sim is + // still running. + settled: !layouts.anyLayoutRunning(), + clusterPositions, + beziers, + edgeLabels, + edgeArrows, + flatArrows, + entityFanOut, + }); + } + + /** + * Recompute ports at the current positions. Ports live at the highway level: + * each base pair is collapsed to its outermost rendered containers, so a + * cluster's port toward a neighbor's subtree is stable whether that + * subtree's container is open or closed (opening an unrelated container + * does not reshuffle a cluster's ports). + */ + #computePorts(): PortPairs { + const { view, clusterTree, edgeAggregator, portCache, config } = + this.#dependencies; + if (!view.edgeFrame || !view.cutIndex) { + return new Map(); + } + const containerIds = view.cutIndex.containerIds; + const highwayPairs = new Map< + string, + { + readonly sourceId: ClusterId; + readonly targetId: ClusterId; + totalCount: number; + readonly byType: Set; + } + >(); + + for (const pair of edgeAggregator.pairs.values()) { + const { highwaySourceId: hwSourceId, highwayTargetId: hwTargetId } = + highwayEndpoints( + pair.sourceId, + pair.targetId, + clusterTree, + containerIds, + ); + if (hwSourceId === hwTargetId) { + continue; + } + const { key, sourceId, targetId } = makePairKey(hwSourceId, hwTargetId); + let highway = highwayPairs.get(key); + if (!highway) { + highway = { sourceId, targetId, totalCount: 0, byType: new Set() }; + highwayPairs.set(key, highway); + } + highway.totalCount += pair.totalCount; + for (const typeSetId of pair.byType.keys()) { + highway.byType.add(typeSetId); + } + } + + return computeAllPorts(highwayPairs, clusterTree, config, portCache); + } + + /** + * Fan-out feeder endpoints for the current positions (one entry per open + * leaf), plus a refill of each leaf's port-attraction targets. The exit + * per external owner is the leaf's boundary point toward the highway port, + * chaining into the feeder. + */ + #buildEntityFanOut( + cutIndex: CutIndex, + ports: PortPairs, + ): RenderEntityFanOut[] { + const { layouts, clusterTree, leafLocalCache, links, config } = + this.#dependencies; + const result: RenderEntityFanOut[] = []; + // While the macro is still moving, keep dot layouts warm so dots + // track the continuous port drift instead of lagging it. + const clustersRunning = layouts.anyClusterLayoutRunning(); + + for (const leafId of cutIndex.entityModeIds) { + const cluster = clusterTree.get(leafId); + const layout = layouts.get(leafId); + if (!cluster || !layout) { + continue; + } + + const localOf = leafLocalCache.of(layout); + + const exitForOwner = new Map(); + + const ownerExit = (otherOwner: ClusterId): Position | null => { + const cached = exitForOwner.get(otherOwner); + if (cached !== undefined) { + return cached; + } + + const { highwaySourceId, highwayTargetId } = highwayEndpoints( + leafId, + otherOwner, + clusterTree, + cutIndex.containerIds, + ); + + const highwayPorts = + highwaySourceId === highwayTargetId + ? undefined + : portsFor(ports, highwaySourceId, highwayTargetId); + + let exit: Position | null = null; + if (highwayPorts) { + // Aim at the feeder's first waypoint (the nearest enclosing + // container boundary toward the outermost port), not the port + // directly. At depth >= 2 these differ; sharing the waypoint + // function keeps fan-out and feeder aligned. + let target: Position = highwayPorts.a; + let ancestor = cluster.parent; + + while (ancestor) { + if (cutIndex.containerIds.has(ancestor.id)) { + if (ancestor.id !== highwaySourceId) { + target = containerBoundaryWaypoint( + ancestor.circle, + highwayPorts.a.x, + highwayPorts.a.y, + config.portPaddingWorld, + ); + } + + break; + } + + ancestor = ancestor.parent; + } + + const angle = Math.atan2( + target.y - cluster.circle.y, + target.x - cluster.circle.x, + ); + + exit = { + x: cluster.circle.radius * Math.cos(angle), + y: cluster.circle.radius * Math.sin(angle), + }; + } + + exitForOwner.set(otherOwner, exit); + return exit; + }; + + const portTargets = + this.#dependencies.portConstraints.portTargetsOf(leafId); + + // A dot gaining or losing an external connection (e.g. on reopen, or a + // highway re-routing) must re-energise the sim even when the macro is + // settled: a structural change, not continuous drift. + let connectivityChanged = false; + const fanOut: number[] = []; + + for (const node of layout.nodes) { + const entityIdx = entityIndexFromNodeId(node.id); + // layout.nodes and localOf are built from the same nodeIds list, so + // every layout node id maps to a local slot. + const localIdx = localOf.get(entityIdx)!; + const seenTargets = new Set(); + + let sumX = 0; + let sumY = 0; + let exitCount = 0; + + for (const link of links.linksFor(entityIdx)) { + const otherOwner = cutIndex.ownerOf(link.otherId); + if ( + !otherOwner || + otherOwner === leafId || + seenTargets.has(otherOwner) + ) { + continue; + } + + seenTargets.add(otherOwner); + + const exit = ownerExit(otherOwner); + if (exit) { + fanOut.push(localIdx, exit.x, exit.y); + sumX += exit.x; + sumY += exit.y; + exitCount += 1; + } + } + + // Port-attraction target: centroid of this entity's exits + // (NaN = no external connection). + if (portTargets) { + const hasTarget = exitCount > 0; + const nextX = hasTarget ? sumX / exitCount : Number.NaN; + const nextY = hasTarget ? sumY / exitCount : Number.NaN; + const hadTarget = !Number.isNaN(portTargets[localIdx * 2]!); + + if (hadTarget !== hasTarget) { + connectivityChanged = true; + } + + portTargets[localIdx * 2] = nextX; + portTargets[localIdx * 2 + 1] = nextY; + } + } + + // Re-energise the entity sim so dots reach their (possibly moved) ports. + if (clustersRunning || connectivityChanged) { + layout.resume(); + } + + result.push({ layoutId: leafId, fanOut: Float32Array.from(fanOut) }); + } + + return result; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/frames/structure-frame.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/frames/structure-frame.ts new file mode 100644 index 00000000000..044d22f7498 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/frames/structure-frame.ts @@ -0,0 +1,276 @@ +/** + * StructureFrame emission: the topology snapshot the main thread renders + * (visible clusters, per-leaf entity layers, highway-lane summaries). + * Emitted on structure commits only, never on a position tick. + */ +import { graphColors } from "../../../visual-style"; +import { + frontierCount, + frontierMembers, +} from "../../entity-graph/cluster-membership"; +import { analyzeHierarchy } from "../../geometry/edge-geometry"; +import { colorForCluster } from "../../hierarchy/cluster-tree"; + +import type { VizConfig } from "../../../config"; +import type { + Color, + HighwayLaneSummary, + RenderCluster, + RenderEntityLayer, + RenderFlatGraph, + StructureFrame, +} from "../../../frames"; +import type { ClusterId, EntityIndex, VizMode } from "../../../ids"; +import type { EntityStore } from "../../entity-graph/store/entity"; +import type { TypeSetStore } from "../../entity-graph/store/type-set"; +import type { CutIndex } from "../../geometry/edge-aggregation"; +import type { ClusterNode, ClusterTree } from "../../hierarchy/cluster-tree"; +import type { TypeRegistry } from "../../store/type-registry"; +import type { CommittedView } from "../committed-view"; +import type { LayoutRegistry } from "../layout-registry"; +import type { LeafLocalCache } from "./leaf-local-cache"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +const FAN_OUT_COLOR: Color = [...graphColors.fanOutEdge]; + +export interface StructureFrameDependencies { + readonly config: VizConfig; + readonly view: CommittedView; + readonly layouts: LayoutRegistry; + readonly leafLocalCache: LeafLocalCache; + readonly clusterTree: ClusterTree; + readonly entities: EntityStore; + readonly typeSets: TypeSetStore; + readonly types: TypeRegistry; + readonly mode: () => VizMode; + readonly onFrame: (frame: StructureFrame) => void; +} + +export class StructureFrameEmitter { + readonly #dependencies: StructureFrameDependencies; + + #version = 0; + /** Per-lane link-entity unions, indexed by laneId. A merged highway's + * lanes share one union (the whole ribbon's links). */ + #highwayLaneUnions: EntityIndex[][] = []; + + constructor(dependencies: StructureFrameDependencies) { + this.#dependencies = dependencies; + } + + emit( + entityLayers: readonly RenderEntityLayer[], + flatGraph?: RenderFlatGraph, + ): void { + const { view, layouts, config } = this.#dependencies; + this.#version++; + const clusters: RenderCluster[] = view.rendered.map((entry) => + this.#renderCluster(entry.node, entry.depth), + ); + if (config.debug) { + // eslint-disable-next-line no-console + console.debug( + `[graph-worker][structure v${this.#version}] mode=${this.#dependencies.mode()} ` + + `clusters=${clusters.length} entityLayers=${entityLayers.length} ` + + `flat=${flatGraph?.count ?? 0} ` + + `layouts=${layouts.size}`, + ); + } + this.#dependencies.onFrame({ + version: this.#version, + mode: this.#dependencies.mode(), + clusters, + entityLayers, + flatGraph, + highwayLanes: this.#buildHighwayLanes(), + }); + } + + /** + * The link entities a clicked highway represents: the union of every + * aggregate lane merged into the same ribbon as `laneId`. + */ + highwayLinks(laneId: number): EntityIndex[] { + return laneId >= 0 && laneId < this.#highwayLaneUnions.length + ? [...(this.#highwayLaneUnions[laneId] ?? [])] + : []; + } + + #renderCluster(cluster: ClusterNode, depth: number): RenderCluster { + const { entities, typeSets, types } = this.#dependencies; + const clusterFrontierCount = frontierCount(cluster, typeSets, entities); + const allFrontier = + cluster.count > 0 && clusterFrontierCount === cluster.count; + // Multi-line property labels append the count on a new line; single-line + // labels keep the count inline. + const text = cluster.label.text; + const label = + text.length === 0 + ? `(${cluster.count})` + : text.includes("\n") + ? `${text}\n(${cluster.count})` + : `${text} (${cluster.count})`; + + return { + id: cluster.id, + color: colorForCluster(cluster, types), + label, + count: cluster.count, + radius: cluster.circle.radius, + depth, + frontierCount: clusterFrontierCount, + // Only the (rare) wholly-frontier cluster materialises its member ids; + // every other cluster gets away with the count alone. + ...(allFrontier + ? { + // idx comes from frontierMembers for this cluster, so it is + // always interned. + frontierEntityIds: Array.from( + frontierMembers(cluster, typeSets, entities), + (idx) => entities.get(idx)!, + ), + } + : {}), + }; + } + + /** Collects per-open-leaf internal edge slot pairs and metadata for the structure frame. */ + buildEntityLayers(cutIndex: CutIndex): RenderEntityLayer[] { + const { view, layouts, leafLocalCache, clusterTree, types } = + this.#dependencies; + const layers: RenderEntityLayer[] = []; + + for (const leafId of cutIndex.entityModeIds) { + const leafIndex = view.renderedIndex.get(leafId); + const cluster = clusterTree.get(leafId); + const layout = layouts.get(leafId); + if (leafIndex === undefined || !cluster || !layout) { + continue; + } + + const localOf = leafLocalCache.of(layout); + + const internal: number[] = []; + if (view.edgeFrame) { + for (const edge of view.edgeFrame.visualEdges) { + // Skip cross-leaf visual edges: fan-out handles those positionally. + if ( + edge.kind !== "individual" || + edge.source.ownerClusterId !== leafId + ) { + continue; + } + const sourceSlot = localOf.get(edge.source.entityIdx); + const targetSlot = localOf.get(edge.target.entityIdx); + if (sourceSlot !== undefined && targetSlot !== undefined) { + internal.push(sourceSlot, targetSlot); + } + } + } + + layers.push({ + layoutId: leafId, + leafClusterIndex: leafIndex, + count: layout.nodeIds.length, + radius: + cluster.circle.radius * + this.#dependencies.config.clusterSizing.entityRadiusFraction, + color: colorForCluster(cluster, types), + internalEdges: Uint32Array.from(internal), + fanOutColor: FAN_OUT_COLOR, + }); + } + + return layers; + } + + /** Per-lane summaries for the rendered highways, indexed by `laneId`. */ + #buildHighwayLanes(): HighwayLaneSummary[] { + const { view, clusterTree } = this.#dependencies; + const placeholder: HighwayLaneSummary = { + typeId: null, + typeLabel: "", + count: 0, + direction: "both", + }; + const edges = view.edgeFrame?.visualEdges; + if (!edges) { + this.#highwayLaneUnions = []; + return []; + } + // Group lanes by highway-level endpoints so a merged highway's segments + // all resolve to the whole ribbon's links and a combined summary. + const containerIds = view.cutIndex?.containerIds ?? new Set(); + const groups = new Map(); + for (let idx = 0; idx < edges.length; idx++) { + const edge = edges[idx]!; + if (edge.kind !== "aggregate") { + continue; + } + const { sourceContainers, targetContainers } = analyzeHierarchy( + edge.source.id, + edge.target.id, + clusterTree, + containerIds, + ); + // Group key includes type+direction so distinct single-type lanes + // aren't folded together. Unmerged lanes key on their own visualKey. + const outerSource = + sourceContainers[sourceContainers.length - 1]?.containerId ?? + edge.source.id; + const outerTarget = + targetContainers[targetContainers.length - 1]?.containerId ?? + edge.target.id; + // Unmerged aggregate lanes use visualKey as the grouping key; it is + // always a string at this branch. + const key = + sourceContainers.length === 0 && targetContainers.length === 0 + ? (edge.visualKey as string) + : `hw:${outerSource}:${outerTarget}:${edge.typeSetId ?? "roll"}:${edge.direction}`; + const list = groups.get(key); + if (list) { + list.push(idx); + } else { + groups.set(key, [idx]); + } + } + + const summaries: HighwayLaneSummary[] = edges.map(() => placeholder); + const unions: EntityIndex[][] = edges.map(() => []); + for (const laneIdxs of groups.values()) { + const union = new Set(); + let typeId: VersionedUrl | null = null; + let typeLabel = ""; + let direction: HighwayLaneSummary["direction"] = "both"; + let count = 0; + for (const idx of laneIdxs) { + const edge = edges[idx]; + if (!edge || edge.kind !== "aggregate") { + continue; + } + for (const entityIdx of edge.entities) { + union.add(entityIdx); + } + count += edge.count; + // Every lane in a group shares one type + direction (the group key), so any member's + // identity describes the whole group. + typeId = edge.typeId; + typeLabel = edge.typeLabel; + direction = edge.direction; + } + const summary: HighwayLaneSummary = { + typeId, + typeLabel, + count, + direction, + }; + const unionArr = [...union]; + for (const idx of laneIdxs) { + summaries[idx] = summary; + unions[idx] = unionArr; + } + } + this.#highwayLaneUnions = unions; + return summaries; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/layout-registry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/layout-registry.ts new file mode 100644 index 00000000000..53dc92ca7fc --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/layout-registry.ts @@ -0,0 +1,94 @@ +import { ClusterId } from "../../ids"; + +import type { LayoutSimulation } from "../layout/force-simulation"; + +/** + * The single registry id for the whole-graph flat layout (the entity flat + * tier and the type graph both run exactly one, keyed here so the shared + * tick loop recognises it). + */ +export const FLAT_LAYOUT_ID = ClusterId("flat:all"); + +/** What a force layout's nodes represent: child cluster bubbles or entities. */ +export type LayoutKind = "clusters" | "entities"; + +/** + * The live force layouts, keyed by the cluster (or flat-tier) id they animate, + * each tagged with what its nodes represent ({@link LayoutKind}). + * + * Invariant: a layout and its kind are registered and removed together; + * `kindOf` is defined exactly for the ids `get` resolves. + */ +export class LayoutRegistry { + readonly #layouts = new Map(); + readonly #kinds = new Map(); + + get size(): number { + return this.#layouts.size; + } + + get(id: ClusterId): LayoutSimulation | undefined { + return this.#layouts.get(id); + } + + kindOf(id: ClusterId): LayoutKind | undefined { + return this.#kinds.get(id); + } + + has(id: ClusterId): boolean { + return this.#layouts.has(id); + } + + set(id: ClusterId, kind: LayoutKind, layout: LayoutSimulation): void { + this.#layouts.set(id, layout); + this.#kinds.set(id, kind); + } + + /** Remove a layout and its kind. Returns whether the id was registered. */ + delete(id: ClusterId): boolean { + this.#kinds.delete(id); + return this.#layouts.delete(id); + } + + clear(): void { + this.#layouts.clear(); + this.#kinds.clear(); + } + + keys(): IterableIterator { + return this.#layouts.keys(); + } + + values(): IterableIterator { + return this.#layouts.values(); + } + + entries(): IterableIterator<[ClusterId, LayoutSimulation]> { + return this.#layouts.entries(); + } + + /** True while any cluster-level (macro/container) layout is still moving. */ + anyClusterLayoutRunning(): boolean { + for (const [clusterId, layout] of this.#layouts) { + if ( + this.#kinds.get(clusterId) === "clusters" && + layout.status === "running" + ) { + return true; + } + } + + return false; + } + + /** Any layout (cluster or entity) still running; drives scheduler shutdown. */ + anyLayoutRunning(): boolean { + for (const layout of this.#layouts.values()) { + if (layout.status === "running") { + return true; + } + } + + return false; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/schedulers.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/schedulers.test.ts new file mode 100644 index 00000000000..84a82c57959 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/schedulers.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import { JobScheduler, TickScheduler } from "./schedulers"; + +/** Let already-queued MessageChannel macro tasks drain. */ +const settle = () => + new Promise((resolve) => { + setTimeout(resolve, 20); + }); + +describe("TickScheduler", () => { + it("ticks repeatedly until stopped from inside the tick, then stays stopped", async () => { + let ticks = 0; + let resolveDone!: () => void; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + + const scheduler: TickScheduler = new TickScheduler(() => { + ticks += 1; + if (ticks === 3) { + scheduler.stop(); + resolveDone(); + } + }); + + expect(scheduler.running).toBe(false); + scheduler.ensureRunning(); + expect(scheduler.running).toBe(true); + + await done; + await settle(); + + expect(ticks).toBe(3); + expect(scheduler.running).toBe(false); + }); + + it("does not double-schedule when ensureRunning is called while running", async () => { + let ticks = 0; + let resolveDone!: () => void; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + + const scheduler: TickScheduler = new TickScheduler(() => { + ticks += 1; + // Re-entrant ensureRunning while running must be a no-op. + scheduler.ensureRunning(); + if (ticks === 2) { + scheduler.stop(); + resolveDone(); + } + }); + + scheduler.ensureRunning(); + scheduler.ensureRunning(); + + await done; + await settle(); + + // A duplicated schedule would deliver extra queued ticks after stop. + expect(ticks).toBe(2); + }); + + it("can be restarted after stopping", async () => { + let ticks = 0; + let resolveRun!: () => void; + + const scheduler: TickScheduler = new TickScheduler(() => { + ticks += 1; + scheduler.stop(); + resolveRun(); + }); + + const firstRun = new Promise((resolve) => { + resolveRun = resolve; + }); + scheduler.ensureRunning(); + await firstRun; + + const secondRun = new Promise((resolve) => { + resolveRun = resolve; + }); + scheduler.ensureRunning(); + await secondRun; + + expect(ticks).toBe(2); + }); +}); + +describe("JobScheduler", () => { + it("runs jobs asynchronously in FIFO order", async () => { + const scheduler = new JobScheduler(); + const order: number[] = []; + let resolveDone!: () => void; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + + scheduler.schedule(() => order.push(1)); + scheduler.schedule(() => order.push(2)); + scheduler.schedule(() => { + order.push(3); + resolveDone(); + }); + + // Nothing runs synchronously on schedule(). + expect(order).toEqual([]); + + await done; + expect(order).toEqual([1, 2, 3]); + }); + + it("runs jobs scheduled from inside a job after the current queue", async () => { + const scheduler = new JobScheduler(); + const order: string[] = []; + let resolveDone!: () => void; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + + scheduler.schedule(() => { + order.push("first"); + scheduler.schedule(() => { + order.push("nested"); + resolveDone(); + }); + }); + scheduler.schedule(() => order.push("second")); + + await done; + expect(order).toEqual(["first", "second", "nested"]); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/schedulers.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/schedulers.ts new file mode 100644 index 00000000000..cc14998da2c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/schedulers.ts @@ -0,0 +1,125 @@ +/** + * MessageChannel-based deferral primitives for the worker's event loop. + * + * Both classes exploit the same property: posting to a MessageChannel port + * schedules a macro task that yields to the event loop (so incoming worker + * messages get processed between runs) without the ~4ms setTimeout floor. + */ + +/** + * Drives a repeated tick callback, one macro task per tick, until the host + * reports there is nothing left to animate. + * + * The host calls {@link ensureRunning} whenever it (re)starts a layout and + * {@link stop} from inside the tick when everything has settled; a stopped + * scheduler simply does not re-post, so `ensureRunning` is always safe to + * call again. + */ +export class TickScheduler { + readonly #channel = new MessageChannel(); + #running = false; + #paused = false; + + constructor(tick: () => void) { + this.#channel.port1.onmessage = () => { + if (this.#paused) { + return; + } + + tick(); + + if (this.#running) { + this.#scheduleNextTick(); + } + }; + } + + get running(): boolean { + return this.#running; + } + + get paused(): boolean { + return this.#paused; + } + + ensureRunning(): void { + if (this.#paused) { + return; + } + + if (!this.#running) { + this.#running = true; + this.#scheduleNextTick(); + } + } + + /** Idempotent: stops scheduling further ticks once every layout has settled. */ + stop(): void { + this.#running = false; + } + + pause(): void { + this.#paused = true; + } + + resume(): void { + this.#paused = false; + + if (this.#running) { + this.#scheduleNextTick(); + } + } + + #scheduleNextTick(): void { + if (this.#paused) { + return; + } + + this.#channel.port2.postMessage(undefined); + } +} + +/** + * One-shot background jobs (e.g. cluster naming), one job per macro task, so + * a job that scans every member's properties never blocks the commit that + * just rendered the clusters, and each job yields to the event loop + * (incoming messages, the prior commit's paint) before the next runs. + */ +export class JobScheduler { + readonly #channel = new MessageChannel(); + readonly #jobs: Array<() => void> = []; + #paused = false; + + constructor() { + this.#channel.port1.onmessage = () => { + if (this.#paused) { + return; + } + + this.#jobs.shift()?.(); + + if (this.#jobs.length > 0) { + this.#channel.port2.postMessage(undefined); + } + }; + } + + pause(): void { + this.#paused = true; + } + + resume(): void { + this.#paused = false; + + if (this.#jobs.length > 0) { + this.#channel.port2.postMessage(undefined); + } + } + + schedule(job: () => void): void { + this.#jobs.push(job); + if (this.#jobs.length === 1) { + this.#channel.port2.postMessage(undefined); + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/tick-loop.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/tick-loop.ts new file mode 100644 index 00000000000..91536629b69 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/core/tick-loop.ts @@ -0,0 +1,220 @@ +/** + * The per-frame simulation step driven by {@link TickScheduler}: advance every + * running layout once, propagate cluster movement into world positions and + * port anchors, and emit PositionsFrames. Purely positional: the topology + * pipeline (cut, CutIndex, aggregation) never runs here. + */ +import { sharedBufferAvailable } from "../layout/force-simulation"; +import { FLAT_LAYOUT_ID } from "./layout-registry"; + +import type { VizConfig } from "../../config"; +import type { PortConstraintController } from "../entity-graph/hierarchical/port-constraints"; +import type { SettlePolisher } from "../entity-graph/hierarchical/settle-polish"; +import type { ClusterTree } from "../hierarchy/cluster-tree"; +import type { LayoutSimulation } from "../layout/force-simulation"; +import type { LayoutSideChannelMessage } from "../protocol"; +import type { PositionsFrameEmitter } from "./frames/positions-frame"; +import type { LayoutRegistry } from "./layout-registry"; + +export interface TickLoopDependencies { + /** + * The worker's live config: `debug` and `ingest.slowTickWarningMs` are read + * per tick (not copied) so a live config update applies immediately. + */ + readonly config: VizConfig; + readonly layouts: LayoutRegistry; + readonly clusterTree: ClusterTree; + readonly polisher: SettlePolisher; + readonly portConstraints: PortConstraintController; + readonly positionsEmitter: PositionsFrameEmitter; + /** Recompose world positions over the opened subtree after clusters moved. */ + readonly syncWorldPositions: () => void; + readonly postLayoutMessage: (msg: LayoutSideChannelMessage) => void; + /** Stop the scheduler once every layout has settled. */ + readonly stopScheduler: () => void; +} + +export class TickLoop { + readonly #dependencies: TickLoopDependencies; + + constructor(dependencies: TickLoopDependencies) { + this.#dependencies = dependencies; + } + + /** + * One simulation step across all active layouts. + * + * Entity layouts stream positions via SharedArrayBuffer. Cluster layouts + * write back to child circles; when any cluster moved, a PositionsFrame is + * emitted. + */ + tick(): void { + const { layouts, clusterTree, polisher } = this.#dependencies; + const debug = this.#dependencies.config.debug; + + const tickStart = performance.now(); + const clustersRunningBefore = layouts.anyClusterLayoutRunning(); + const layoutsRunningBefore = layouts.anyLayoutRunning(); + + let clusterMoved = false; + let flatMoved = false; + + for (const [clusterId, layout] of layouts.entries()) { + if (layout.status === "settled" || layout.status === "paused") { + continue; + } + + const kind = layouts.kindOf(clusterId); + + const layoutTickStart = performance.now(); + const changed = layout.tick(1); + const layoutTickMs = performance.now() - layoutTickStart; + + if (debug && kind === "entities") { + this.#logOverlapDiagnostics(clusterId, layout, changed, layoutTickMs); + } + + if (kind === "entities") { + if (changed && clusterId === FLAT_LAYOUT_ID) { + // Flat edges are worker-built beziers; emit a frame so they + // track the moved dots. + flatMoved = true; + } else if (changed && !sharedBufferAvailable) { + // Non-shared-buffer fallback: post position snapshots. + const positions = new Float32Array(layout.nodes.length * 2); + for (let idx = 0; idx < layout.nodes.length; idx++) { + const node = layout.nodes[idx]!; + positions[idx * 2] = node.x ?? 0; + positions[idx * 2 + 1] = node.y ?? 0; + } + this.#dependencies.postLayoutMessage({ + type: "LAYOUT_POSITIONS", + clusterId, + positions, + }); + } + continue; + } + + // Cluster layout moved, so the top-down propagation pass below writes the + // world circles (it must run for settled intermediates too, so it can't + // live here in the per-ticked-layout loop). + const cluster = clusterTree.get(clusterId); + if ( + changed && + cluster && + cluster.children.length === layout.nodes.length + ) { + clusterMoved = true; + } + + // On the tick when a layout settles, polish positions once (root -> + // optimiser, sub-cluster -> untangle). Also runs from + // ensureChildrenLayout for layouts that settle during their warm-up + // (which this loop would skip). + if (cluster && layout.isSettled && !polisher.isPolished(clusterId)) { + polisher.polishSettledLayout(cluster, layout); + clusterMoved = true; + } + } + + // Emit when clusters moved, when the last cluster layout settles (final + // settled flag), or when the flat graph moved / any layout just settled. + const clustersJustSettled = + clustersRunningBefore && !layouts.anyClusterLayoutRunning(); + + const layoutsJustSettled = + layoutsRunningBefore && !layouts.anyLayoutRunning(); + + if (clusterMoved || clustersJustSettled) { + // Recompose world positions top-down so anchor re-aiming reads correct, + // fully-propagated circles through settled nested layouts. + this.#dependencies.syncWorldPositions(); + + if (clusterMoved) { + // Re-aim opened sub-clusters' port anchors at their moved neighbours. + this.#dependencies.portConstraints.updateAnchorTracking(); + } + + this.#dependencies.positionsEmitter.emit(); + } else if (flatMoved || layoutsJustSettled) { + this.#dependencies.positionsEmitter.emit(); + } + + if (!layouts.anyLayoutRunning()) { + this.#dependencies.stopScheduler(); + } + + const elapsed = performance.now() - tickStart; + if (debug && elapsed > this.#dependencies.config.ingest.slowTickWarningMs) { + // eslint-disable-next-line no-console + console.warn( + `[graph-worker][slow tick] ${elapsed.toFixed(1)}ms (${layouts.size} layouts)`, + ); + } + } + + /** + * Per-tick instrumentation for the majorization engine's overlap projection: + * confirms on the user's actual graph that no single tick freezes and that + * the overlap count marches to zero. Debug-gated; the fields are duck-typed + * (read structurally off the layout) so the tick loop needs no engine import. + */ + #logOverlapDiagnostics( + clusterId: string, + layout: LayoutSimulation, + changed: boolean, + layoutTickMs: number, + ): void { + // Debug-only duck typing: majorization layouts expose these fields; + // other engines omit them and skip logging. + const diag = layout as Partial<{ + /** Strict disk overlaps at the last iterate / settle verification. */ + overlapsRemaining: number; + /** Majorization iterations completed. */ + overlapProjectionCalls: number; + /** Worst single tick (ms), the per-tick budget guard. */ + maxTickMs: number; + /** Laplacian (re)builds (cold build + every warm absorb/relayout). */ + laplacianRebuilds: number; + edgeCount: number; + /** Iteration cap hit (solve stopped before convergence). */ + capped: boolean; + /** Settle pass cap hit (violations may remain). */ + settleCapped: boolean; + /** Community-region floor violations at last measurement. */ + regionViolations: number; + }>; + + if ( + !changed || + typeof diag.overlapProjectionCalls !== "number" || + diag.overlapProjectionCalls <= 0 + ) { + return; + } + + const cappedFlags = [ + ...(diag.capped ? ["iterations"] : []), + ...(diag.settleCapped ? ["settle"] : []), + ]; + + const parts = [ + `[graph-worker][majorization] cluster=${clusterId}`, + `n=${layout.nodes.length}`, + `edges=${diag.edgeCount ?? "?"}`, + `tickMs=${layoutTickMs.toFixed(2)}`, + `iterations=${diag.overlapProjectionCalls}`, + `overlaps=${diag.overlapsRemaining ?? "?"}`, + ...(diag.regionViolations !== undefined + ? [`regionViolations=${diag.regionViolations}`] + : []), + `rebuilds=${diag.laplacianRebuilds ?? 0}`, + `maxTickMs=${(diag.maxTickMs ?? 0).toFixed(2)}`, + ...(cappedFlags.length > 0 ? [`capped=${cappedFlags.join("+")}`] : []), + ]; + + // eslint-disable-next-line no-console + console.debug(parts.join(" ")); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/csr-graph.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/csr-graph.test.ts new file mode 100644 index 00000000000..744d3dbb429 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/csr-graph.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { Column } from "./collections/column"; +import { connectedComponents } from "./csr-graph"; + +import type { EntityIndex } from "../ids"; +import type { CsrGraph } from "./csr-graph"; + +/** Build a CsrGraph from an undirected adjacency list keyed by local index. */ +const csrFrom = (adjacency: number[][]): CsrGraph => { + const nodeIds = new Column( + Int32Array, + Math.max(1, adjacency.length), + ); + for (let index = 0; index < adjacency.length; index++) { + nodeIds.push(index as EntityIndex); + } + const offsets = new Int32Array(adjacency.length + 1); + const flat: number[] = []; + for (let index = 0; index < adjacency.length; index++) { + offsets[index] = flat.length; + flat.push(...adjacency[index]!); + } + offsets[adjacency.length] = flat.length; + return { + nodeIds, + offsets, + neighbors: Int32Array.from(flat), + weights: Float32Array.from(flat, () => 1), + }; +}; + +/** Components in a canonical order so the assertion is independent of traversal order. */ +const normalize = (components: number[][]) => + components + .map((component) => [...component].sort((lhs, rhs) => lhs - rhs)) + .sort((lhs, rhs) => lhs[0]! - rhs[0]!); + +describe("connectedComponents", () => { + it("separates disconnected components and isolated nodes", () => { + const graph = csrFrom([[1], [0], [3], [2], []]); + expect(normalize(connectedComponents(graph))).toEqual([ + [0, 1], + [2, 3], + [4], + ]); + }); + + it("returns a single component when fully connected", () => { + const graph = csrFrom([[1], [0, 2], [1, 3], [2]]); + expect(normalize(connectedComponents(graph))).toEqual([[0, 1, 2, 3]]); + }); + + it("returns one component per node when there are no edges", () => { + const graph = csrFrom([[], [], []]); + expect(normalize(connectedComponents(graph))).toEqual([[0], [1], [2]]); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/csr-graph.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/csr-graph.ts new file mode 100644 index 00000000000..0f81fee65e2 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/csr-graph.ts @@ -0,0 +1,123 @@ +/** + * Compressed sparse row (CSR) graph over a set of entities. + * + * Undirected edges are stored as two directed entries, one per endpoint, so + * an edge with both endpoints in the set contributes exactly two rows. + * `offsets.length` is always `nodeIds.length + 1`, and `neighbors` and + * `weights` are parallel arrays indexed by the same edge slot. + */ + +import { BitSet } from "./collections/bitset"; + +import type { EntityIndex } from "../ids"; +import type { Column } from "./collections/column"; +import type { LinkStore } from "./entity-graph/store/link"; + +export interface CsrGraph { + readonly nodeIds: Column; + /** Maps local index -> offset into neighbors/weights. Length = nodeIds.length + 1. */ + readonly offsets: Int32Array; + readonly neighbors: Int32Array; + readonly weights: Float32Array; +} + +/** + * Build a CSR graph restricted to the given entity set. + * + * Links with an endpoint outside the set, or self-loops, are dropped. + * + * Runs in O(sum of member degrees): links are discovered through each + * member's adjacency, not by scanning the whole store, so repeated + * subdivisions of small clusters stay cheap on large graphs. A link with + * both endpoints in the set surfaces once per endpoint, which yields + * exactly the two directed CSR entries it needs. + */ +export function buildInducedCsr( + entityIdxs: Column, + links: LinkStore, +): CsrGraph { + const localIndex = new Map(); + for (let idx = 0; idx < entityIdxs.length; idx++) { + localIndex.set(entityIdxs.get(idx), idx); + } + + const adjacency: number[][] = Array.from( + { length: entityIdxs.length }, + () => [], + ); + + for (let idx = 0; idx < entityIdxs.length; idx++) { + const member = entityIdxs.get(idx); + for (const { otherId } of links.linksFor(member)) { + const otherLocal = localIndex.get(otherId); + if (otherLocal === undefined || otherLocal === idx) { + continue; + } + adjacency[idx]!.push(otherLocal); + } + } + + const totalEdges = adjacency.reduce((sum, adj) => sum + adj.length, 0); + const offsets = new Int32Array(entityIdxs.length + 1); + const neighbors = new Int32Array(totalEdges); + const weights = new Float32Array(totalEdges); + weights.fill(1); + + let offset = 0; + for (let idx = 0; idx < adjacency.length; idx++) { + offsets[idx] = offset; + for (const neighbor of adjacency[idx]!) { + neighbors[offset] = neighbor; + offset += 1; + } + } + offsets[entityIdxs.length] = offset; + + return { nodeIds: entityIdxs, offsets, neighbors, weights }; +} + +/** + * Returns each connected component as local node indices; isolated nodes + * are singleton components. Order follows ascending start-node discovery, + * not component size. + */ +export function connectedComponents(graph: CsrGraph): number[][] { + const nodeCount = graph.nodeIds.length; + const visited = BitSet.empty(nodeCount); + const components: number[][] = []; + const stack: number[] = []; + + for (let start = 0; start < nodeCount; start++) { + if (visited.has(start)) { + continue; + } + + const component: number[] = []; + stack.push(start); + visited.add(start); + + while (stack.length > 0) { + const node = stack.pop()!; + component.push(node); + + // CSR offsets are well-formed (built by buildInducedCsr or test + // csrFrom), so every popped node has a valid [offsets[i], offsets[i+1)) + // slice and every edge index resolves to a neighbor. + for ( + let edge = graph.offsets[node]!; + edge < graph.offsets[node + 1]!; + edge++ + ) { + const neighbor = graph.neighbors[edge]!; + if (!visited.has(neighbor)) { + visited.add(neighbor); + stack.push(neighbor); + } + } + } + + components.push(component); + } + + return components; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/cluster-membership.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/cluster-membership.test.ts new file mode 100644 index 00000000000..ab259d8b04e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/cluster-membership.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; + +import { entityIdFromComponents } from "@blockprotocol/type-system"; + +import { ClusterId, EntityIndex, TypeId } from "../../ids"; +import { Column } from "../collections/column"; +import { ReadonlySortedSet } from "../collections/readonly-sorted-set"; +import { ClusterNode } from "../hierarchy/cluster-tree"; +import { + entityIdsForCluster, + entityIndicesForCluster, + frontierCount, + frontierMembers, +} from "./cluster-membership"; +import { EntityStore } from "./store/entity"; +import { TypeSetStore } from "./store/type-set"; + +import type { EntityUuid, WebId } from "@blockprotocol/type-system"; + +const webId = "11111111-1111-4111-8111-111111111111" as WebId; + +const entityIdFor = (index: number) => + entityIdFromComponents( + webId, + `22222222-2222-4222-8222-${index.toString(16).padStart(12, "0")}` as EntityUuid, + ); + +/** An entity store holding `count` entities (EntityIndex 0..count-1). */ +function entityStoreOf(count: number): EntityStore { + const entities = new EntityStore(); + for (let index = 0; index < count; index++) { + entities.insert(entityIdFor(index)); + } + return entities; +} + +function directNode(id: string, memberIdxs: readonly number[]): ClusterNode { + const members = new Column( + Int32Array, + Math.max(memberIdxs.length, 1), + ); + for (const memberIdx of memberIdxs) { + members.push(EntityIndex(memberIdx)); + } + const node = new ClusterNode(ClusterId(id), "entity-bucket", { + source: "direct", + members, + }); + node.count = memberIdxs.length; + return node; +} + +/** A type-set store with one group containing the given entities. */ +function groupsFixture(memberIdxs: readonly number[]): { + typeSets: TypeSetStore; + node: ClusterNode; +} { + const typeSets = new TypeSetStore(); + const group = typeSets.getOrCreate( + new ReadonlySortedSet([TypeId(0)], (lhs, rhs) => lhs - rhs), + 4, + ); + for (const memberIdx of memberIdxs) { + group.addEntity(EntityIndex(memberIdx)); + } + const node = new ClusterNode(ClusterId("cluster:groups"), "type-set", { + source: "groups", + keys: [group.key], + }); + node.count = memberIdxs.length; + return { typeSets, node }; +} + +describe("entityIndicesForCluster", () => { + it("yields direct members in packed order", () => { + const typeSets = new TypeSetStore(); + const node = directNode("cluster:direct", [3, 1, 2]); + + expect([...entityIndicesForCluster(node, typeSets)]).toEqual([3, 1, 2]); + }); + + it("yields group members for group-sourced nodes", () => { + const { typeSets, node } = groupsFixture([0, 2]); + + expect([...entityIndicesForCluster(node, typeSets)]).toEqual([0, 2]); + }); + + it("recurses into children only when the node has no own entities", () => { + const typeSets = new TypeSetStore(); + const rollup = new ClusterNode(ClusterId("cluster:rollup"), "type-set", { + source: "groups", + keys: [], + }); + rollup.addChild(directNode("cluster:child-a", [1])); + rollup.addChild(directNode("cluster:child-b", [4, 5])); + + expect([...entityIndicesForCluster(rollup, typeSets)]).toEqual([1, 4, 5]); + }); +}); + +describe("frontierMembers / frontierCount", () => { + it("yields only non-root members of a direct node", () => { + const entities = entityStoreOf(4); + entities.insertRoot(EntityIndex(0)); + entities.insertRoot(EntityIndex(2)); + const typeSets = new TypeSetStore(); + const node = directNode("cluster:direct", [0, 1, 2, 3]); + + expect([...frontierMembers(node, typeSets, entities)]).toEqual([1, 3]); + expect(frontierCount(node, typeSets, entities)).toBe(2); + }); + + it("yields only non-root members of a groups node", () => { + const entities = entityStoreOf(3); + entities.insertRoot(EntityIndex(1)); + const { typeSets, node } = groupsFixture([0, 1, 2]); + + expect([...frontierMembers(node, typeSets, entities)]).toEqual([0, 2]); + expect(frontierCount(node, typeSets, entities)).toBe(2); + }); + + it("does not recurse into children (unlike entityIndicesForCluster)", () => { + const entities = entityStoreOf(2); + const typeSets = new TypeSetStore(); + const rollup = new ClusterNode(ClusterId("cluster:rollup"), "type-set", { + source: "groups", + keys: [], + }); + rollup.addChild(directNode("cluster:child", [0, 1])); + + expect([...frontierMembers(rollup, typeSets, entities)]).toEqual([]); + expect(frontierCount(rollup, typeSets, entities)).toBe(0); + }); +}); + +describe("entityIdsForCluster", () => { + it("maps direct members to their EntityIds", () => { + const entities = entityStoreOf(3); + const typeSets = new TypeSetStore(); + const node = directNode("cluster:direct", [2, 0]); + + expect([...entityIdsForCluster(node, typeSets, entities)]).toEqual([ + entityIdFor(2), + entityIdFor(0), + ]); + }); + + it("maps group members to their EntityIds", () => { + const entities = entityStoreOf(3); + const { typeSets, node } = groupsFixture([1, 2]); + + expect([...entityIdsForCluster(node, typeSets, entities)]).toEqual([ + entityIdFor(1), + entityIdFor(2), + ]); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/cluster-membership.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/cluster-membership.ts new file mode 100644 index 00000000000..ae0e0adccbb --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/cluster-membership.ts @@ -0,0 +1,129 @@ +/** + * Membership queries over cluster-tree nodes: which entities a cluster + * contains, and which of them are frontier (non-root) nodes. + * + * Group-sourced membership resolves through the type-set store; direct + * membership reads the packed member column. Callers pass the stores + * explicitly so these stay pure over their inputs. + */ +import type { EntityIndex } from "../../ids"; +import type { ClusterNode } from "../hierarchy/cluster-tree"; +import type { EntityStore } from "./store/entity"; +import type { TypeSetStore } from "./store/type-set"; + +export function* entityIndicesForCluster( + cluster: ClusterNode, + typeSets: TypeSetStore, +): Generator { + if (cluster.membership.source === "direct") { + const view = cluster.membership.members.subarray(); + yield* view; + + return; + } + + let hasEntities = false; + for (const key of cluster.membership.keys) { + const group = typeSets.get(key); + if (group) { + yield* group.entities; + hasEntities ||= group.entities.length > 0; + } + } + + // A family/rollup carries no keys of its own; recurse into children so + // those entities are attributed to it (otherwise the optimiser misses + // edges through the family). Only when the node has no own entities: + // a subdivided type-set already covers its entities via its keys. + if (!hasEntities) { + for (const child of cluster.children) { + yield* entityIndicesForCluster(child, typeSets); + } + } +} + +/** + * The frontier (non-root) members of a cluster. Non-recursive: reads only the + * cluster's own membership (unlike {@link entityIndicesForCluster}, which + * descends into children for key-less rollups). + */ +export function* frontierMembers( + cluster: ClusterNode, + typeSets: TypeSetStore, + entities: EntityStore, +): Generator { + const { membership } = cluster; + + if (membership.source === "groups") { + for (const key of membership.keys) { + const group = typeSets.get(key); + if (!group) { + continue; + } + for (const entityIdx of group.entities) { + if (!entities.isRoot(entityIdx)) { + yield entityIdx; + } + } + } + + return; + } + + const members = membership.members.subarray(); + for (let idx = 0; idx < members.length; idx++) { + const entityIdx = members.get(idx); + if (!entities.isRoot(entityIdx)) { + yield entityIdx; + } + } +} + +/** How many of a cluster's members are frontier, without materialising them. */ +export function frontierCount( + cluster: ClusterNode, + typeSets: TypeSetStore, + entities: EntityStore, +): number { + let count = 0; + + for (const _memberIdx of frontierMembers(cluster, typeSets, entities)) { + count += 1; + } + + return count; +} + +/** + * Yields the EntityIds of the cluster's own members (non-recursive, same + * membership surface as {@link frontierMembers}). Unknown group keys are + * skipped. + */ +export function* entityIdsForCluster( + node: ClusterNode, + typeSets: TypeSetStore, + entities: EntityStore, +): Generator { + if (node.membership.source === "groups") { + for (const key of node.membership.keys) { + const group = typeSets.get(key); + if (group) { + for (const idx of group.entities) { + // group.entities only lists indices present in the EntityStore; + // ingest is add-only so interned members always resolve. + yield entities.get(idx)!; + } + } + } + + return; + } + + const members = node.membership.members.subarray(); + + for (let idx = 0; idx < members.length; idx++) { + // direct membership column is written at ingest time; every packed + // index is a live EntityStore row. + yield entities.get(members.get(idx))!; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/commit-rebuild.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/commit-rebuild.bench.ts new file mode 100644 index 00000000000..525fa0056db --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/commit-rebuild.bench.ts @@ -0,0 +1,193 @@ +/** + * End-to-end rebuild/redraw path: what `commitStructure` costs, driven through + * the real {@link EntityGraphWorker} exactly as `entry.ts` does (ingest -> commit). + * + * Two questions: + * - `no-op re-commit`: how much work does a commit do when nothing changed? + * (`recomputeMode` + `#allNodeEntityIdxs` sort + topology rescan + `#writeFlatStyle` + * + `#buildFlatRenderEdges`, or the hierarchical cut recompute + frame rebuild.) + * A no-op should be near-free; anything else is per-commit waste on every batch. + * - `bulk vs streaming`: the same final graph delivered as one batch+commit vs. + * many batch+commits. The gap is the per-commit O(N) work (re)done per batch -- + * the streaming-ingest tax. + * + * The EntityGraphWorker runs its layout scheduler on MessageChannels; a synchronous + * bench body never lets those macrotasks fire mid-measurement, and vitest tears + * the process down after the run, so no scheduler cleanup is needed here. + * + * Run: `cd apps/hash-frontend && ../../node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer/worker/entity-graph/commit-rebuild.bench.ts` + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { defaultVizConfig } from "../../config"; +import { benchTypeSchemas, buildIngestEntities } from "../bench-fixtures"; +import { CommitCoalescer } from "../core/commit-coalescer"; +import { EntityGraphWorker } from "./worker"; + +import type { GraphShape } from "../bench-fixtures"; +import type { IngestEntity } from "../protocol"; + +/** Fresh workers are built per iteration below, and each is relatively + * expensive, so bound the fresh-worker benches to a fixed iteration count. */ +const FRESH_WORKER_OPTS = { + time: 0, + iterations: 6, + warmupTime: 0, + warmupIterations: 1, +} as const; + +const IGNORE = (): void => { + // Suppress frame/layout callbacks so vitest measures commit cost only. +}; + +function newWorker(typeCount: number): EntityGraphWorker { + const worker = new EntityGraphWorker(defaultVizConfig); + worker.onLayoutMessage = IGNORE; + worker.onStructureFrame = IGNORE; + worker.onPositionsFrame = IGNORE; + worker.registerTypes(benchTypeSchemas(typeCount), []); + return worker; +} + +/** Ingest the whole graph, commit twice (stabilise mode + tree), record a + * viewport (so the hierarchical tier has a cut to recompute), and return the + * worker sitting in a committed steady state. */ +function primedWorker(shape: GraphShape): EntityGraphWorker { + const worker = newWorker(shape.typeCount); + const deltas = worker.ingestBatch(buildIngestEntities(shape)); + worker.commitStructure({ deltas }); + worker.commitStructure(); + worker.handleViewport({ + zoom: 1, + centerX: 0, + centerY: 0, + width: 1600, + height: 900, + }); + return worker; +} + +interface Case { + readonly label: string; + readonly shape: GraphShape; +} + +const NOOP_CASES: readonly Case[] = [ + { + label: "flat-force (150 nodes)", + shape: { + nodeCount: 150, + linkCount: 300, + typeCount: 8, + hubCount: 8, + rootFraction: 1, + seed: 41, + }, + }, + { + label: "community-force (1500 nodes)", + shape: { + nodeCount: 1_500, + linkCount: 4_000, + typeCount: 16, + hubCount: 40, + rootFraction: 1, + seed: 42, + }, + }, + { + label: "hierarchical-lod (8000 nodes)", + shape: { + nodeCount: 8_000, + linkCount: 20_000, + typeCount: 24, + hubCount: 80, + rootFraction: 1, + seed: 43, + }, + }, +]; + +for (const { label, shape } of NOOP_CASES) { + const worker = primedWorker(shape); + describe(`no-op re-commit: ${label}`, () => { + bench("commitStructure() with no changes", () => { + worker.commitStructure(); + }); + }); +} + +// Same final graph, delivered bulk vs streamed. Lands in community-force. +const STREAM_SHAPE: GraphShape = { + nodeCount: 2_000, + linkCount: 5_000, + typeCount: 16, + hubCount: 40, + rootFraction: 1, + seed: 51, +}; +const STREAM_ENTITIES: readonly IngestEntity[] = + buildIngestEntities(STREAM_SHAPE); +const STREAM_BATCH = 100; + +describe(`ingest ${STREAM_SHAPE.nodeCount} nodes / ${STREAM_SHAPE.linkCount} links`, () => { + bench( + "bulk: 1 batch + 1 commit", + () => { + const worker = newWorker(STREAM_SHAPE.typeCount); + const deltas = worker.ingestBatch(STREAM_ENTITIES); + worker.commitStructure({ deltas }); + }, + FRESH_WORKER_OPTS, + ); + + // Uncoalesced streaming baseline: one commit per batch, representing the + // per-commit O(N) tax coalescing is meant to reduce. + bench( + `streaming, uncoalesced: ${STREAM_BATCH}-entity batches + commit each`, + () => { + const worker = newWorker(STREAM_SHAPE.typeCount); + for ( + let start = 0; + start < STREAM_ENTITIES.length; + start += STREAM_BATCH + ) { + const chunk = STREAM_ENTITIES.slice(start, start + STREAM_BATCH); + const deltas = worker.ingestBatch(chunk); + worker.commitStructure({ deltas }); + worker.restyleIfRootsFlipped(); + } + }, + FRESH_WORKER_OPTS, + ); + + // Coalesced streaming path: per-batch ingest with commits routed through + // CommitCoalescer. This synchronous bench never delivers the MessageChannel + // drain mid-run, so the batch-count cap paces commits (worst-case saturated + // queue). Real inter-batch gaps commit less often. The trailing flush() + // stands in for the drain hook. + bench( + `streaming, coalesced: ${STREAM_BATCH}-entity batches through CommitCoalescer`, + () => { + const worker = newWorker(STREAM_SHAPE.typeCount); + const coalescer = new CommitCoalescer({ + commit: ({ deltas, rebuildTree }) => { + worker.commitStructure({ deltas, rebuildTree }); + worker.restyleIfRootsFlipped(); + }, + }); + for ( + let start = 0; + start < STREAM_ENTITIES.length; + start += STREAM_BATCH + ) { + const chunk = STREAM_ENTITIES.slice(start, start + STREAM_BATCH); + coalescer.enqueueDeltas(worker.ingestBatch(chunk)); + } + coalescer.flush(); + }, + FRESH_WORKER_OPTS, + ); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/ego.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/ego.ts new file mode 100644 index 00000000000..9d71c0a70a8 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/ego.ts @@ -0,0 +1,38 @@ +import type { EntityIndex } from "../../ids"; +import type { CutIndex } from "../geometry/edge-aggregation"; +import type { EgoTarget } from "../protocol"; +import type { LinkStore } from "./store/link"; + +/** + * The ego of a selected node: for each neighbor, the representative currently + * on screen (the entity itself when individually rendered, or the visible + * cluster it collapses into). Neighbors not in view are omitted. Without a + * cut (flat tier), every entity is an individually-rendered dot. + */ +export function egoTargets( + entityIdx: EntityIndex, + links: LinkStore, + cutIndex: CutIndex | undefined, +): EgoTarget[] { + const targets = new Map(); + for (const link of links.linksFor(entityIdx)) { + const neighbor = link.otherId; + + if (!cutIndex) { + targets.set(`e${neighbor}`, { kind: "entity", entityIdx: neighbor }); + continue; + } + + const owner = cutIndex.ownerOf(neighbor); + if (owner === undefined) { + continue; // not in the current view + } + + if (cutIndex.isEntityMode(owner)) { + targets.set(`e${neighbor}`, { kind: "entity", entityIdx: neighbor }); + } else { + targets.set(`c${owner}`, { kind: "cluster", clusterId: owner }); + } + } + return [...targets.values()]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/entity-edges.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/entity-edges.test.ts new file mode 100644 index 00000000000..2d8af575e80 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/entity-edges.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { + ClusterId, + EntityIndex, + nodeIdForEntityIndex, + TypeSetId, +} from "../../ids"; +import { Column } from "../collections/column"; +import { ClusterNode } from "../hierarchy/cluster-tree"; +import { buildClusterEdges, buildEntityEdges } from "./entity-edges"; +import { LinkStore } from "./store/link"; +import { TypeSetStore } from "./store/type-set"; + +import type { ForceNode } from "../layout/force-simulation"; + +const TYPE = TypeSetId(0); + +/** The link entity itself also gets an index; keep those clear of the node range. */ +let nextLinkEntity = 1000; +function link(links: LinkStore, left: number, right: number): void { + links.insert( + EntityIndex(left), + EntityIndex(right), + TYPE, + EntityIndex(nextLinkEntity++), + ); +} + +function nodesFor(entityIdxs: readonly number[]): ForceNode[] { + return entityIdxs.map((idx) => ({ + id: nodeIdForEntityIndex(EntityIndex(idx)), + x: 0, + y: 0, + radius: 1, + })); +} + +function directNode(id: string, memberIdxs: readonly number[]): ClusterNode { + const members = new Column( + Int32Array, + Math.max(memberIdxs.length, 1), + ); + for (const memberIdx of memberIdxs) { + members.push(EntityIndex(memberIdx)); + } + const node = new ClusterNode(ClusterId(id), "entity-bucket", { + source: "direct", + members, + }); + node.count = memberIdxs.length; + return node; +} + +describe("buildEntityEdges", () => { + it("emits one edge per linked member pair, referencing node ids", () => { + const links = new LinkStore(); + link(links, 0, 1); + link(links, 1, 2); + + const memberIdxs = [0, 1, 2].map(EntityIndex); + const edges = buildEntityEdges(memberIdxs, nodesFor([0, 1, 2]), links); + + expect(edges).toEqual([ + { + source: nodeIdForEntityIndex(EntityIndex(0)), + target: nodeIdForEntityIndex(EntityIndex(1)), + weight: 1, + }, + { + source: nodeIdForEntityIndex(EntityIndex(1)), + target: nodeIdForEntityIndex(EntityIndex(2)), + weight: 1, + }, + ]); + }); + + it("deduplicates parallel links into a single edge", () => { + const links = new LinkStore(); + link(links, 0, 1); + link(links, 1, 0); + link(links, 0, 1); + + const memberIdxs = [0, 1].map(EntityIndex); + const edges = buildEntityEdges(memberIdxs, nodesFor([0, 1]), links); + + expect(edges).toHaveLength(1); + }); + + it("ignores links whose other endpoint is outside the member set", () => { + const links = new LinkStore(); + link(links, 0, 7); + + const memberIdxs = [0, 1].map(EntityIndex); + const edges = buildEntityEdges(memberIdxs, nodesFor([0, 1]), links); + + expect(edges).toEqual([]); + }); +}); + +describe("buildClusterEdges", () => { + it("weights a sibling pair by its inter-cluster link count, counted once", () => { + const links = new LinkStore(); + link(links, 0, 2); + link(links, 1, 3); + link(links, 1, 2); + // Intra-cluster link contributes nothing. + link(links, 0, 1); + + const typeSets = new TypeSetStore(); + const children = [ + directNode("cluster:a", [0, 1]), + directNode("cluster:b", [2, 3]), + ]; + + const edges = buildClusterEdges(children, links, typeSets); + + expect(edges).toEqual([ + { source: "cluster:a", target: "cluster:b", weight: 3 }, + ]); + }); + + it("ignores links to entities owned by no sibling", () => { + const links = new LinkStore(); + link(links, 0, 99); + + const typeSets = new TypeSetStore(); + const children = [ + directNode("cluster:a", [0]), + directNode("cluster:b", [1]), + ]; + + expect(buildClusterEdges(children, links, typeSets)).toEqual([]); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/entity-edges.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/entity-edges.ts new file mode 100644 index 00000000000..96297b78dfc --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/entity-edges.ts @@ -0,0 +1,124 @@ +/** + * Force-edge list builders for the layout engines: entity-to-entity edges + * within one member set, and weighted sibling-cluster edges for a container + * layout. Both deduplicate so each link (or cluster pair) contributes one + * edge, matching what the aggregator draws. + */ +import { entityIndicesForCluster } from "./cluster-membership"; + +import type { ClusterId, EntityIndex } from "../../ids"; +import type { ClusterNode } from "../hierarchy/cluster-tree"; +import type { ForceEdge, ForceNode } from "../layout/force-simulation"; +import type { LinkStore } from "./store/link"; +import type { TypeSetStore } from "./store/type-set"; + +/** + * Numeric unordered-pair key for two entity indices, `(first << 26) | second` + * in spirit. Written as multiplication because JS bitwise operators truncate + * to signed 32 bits, which would corrupt this 52-bit key for any index >= 64; + * float64 integer arithmetic is exact up to 2^53, and 26 bits per side is the + * widest even split that fits. Supports indices up to 2^26 (~67M); larger + * index spaces need a different dedupe structure, not a wider base. + */ +const PAIR_KEY_BASE = 2 ** 26; + +function entityPairKey(first: EntityIndex, second: EntityIndex): number { + return first < second + ? first * PAIR_KEY_BASE + second + : second * PAIR_KEY_BASE + first; +} + +/** + * Edges between the given entities (both endpoints in `entityIdxs`), + * deduplicated per unordered pair. `nodes[i]` must be the layout node for + * `entityIdxs[i]`; edges reference the node ids. + */ +export function buildEntityEdges( + entityIdxs: readonly EntityIndex[], + nodes: readonly ForceNode[], + links: LinkStore, +): ForceEdge[] { + const idxToNodeId = new Map(); + for (let idx = 0; idx < entityIdxs.length; idx++) { + // entityIdxs and nodes are parallel inputs from the same caller; equal + // length is a precondition of buildEntityEdges. + idxToNodeId.set(entityIdxs[idx]!, nodes[idx]!.id); + } + + const edges: ForceEdge[] = []; + const seen = new Set(); + + for (const entityIdx of entityIdxs) { + for (const link of links.linksFor(entityIdx)) { + // Skip links whose other endpoint is outside entityIdxs before + // allocating an edge record. + const targetId = idxToNodeId.get(link.otherId); + if (targetId === undefined) { + continue; + } + const pairKey = entityPairKey(entityIdx, link.otherId); + if (seen.has(pairKey)) { + continue; + } + seen.add(pairKey); + edges.push({ + // entityIdx is always a member of entityIdxs (the outer loop + // variable), so the map lookup cannot fail here. + source: idxToNodeId.get(entityIdx)!, + target: targetId, + weight: 1, + }); + } + } + + return edges; +} + +/** + * Weighted edges between sibling clusters: one edge per connected pair, + * weight = the number of links between their member sets. This same count is + * also what the aggregator draws as the highway between the pair, so layout + * attraction and highway width derive from one quantity. + */ +export function buildClusterEdges( + children: readonly ClusterNode[], + links: LinkStore, + typeSets: TypeSetStore, +): ForceEdge[] { + // Build the entityIdx -> childId lookup once; its entries then drive the + // link walk directly, so no per-child member array is ever materialised. + const entityToChild = new Map(); + for (const child of children) { + for (const entityIdx of entityIndicesForCluster(child, typeSets)) { + entityToChild.set(entityIdx, child.id); + } + } + + const edgeCounts = new Map(); + for (const [entityIdx, childId] of entityToChild) { + for (const link of links.linksFor(entityIdx)) { + const otherChildId = entityToChild.get(link.otherId); + if (otherChildId === undefined || otherChildId === childId) { + continue; + } + // Every inter-sibling link surfaces from both endpoints' children; + // count it only from the lower-sorted side so the weight equals the + // link count (matching the aggregator's highway count). + if (childId > otherChildId) { + continue; + } + const pairKey = `${childId}|${otherChildId}`; + edgeCounts.set(pairKey, (edgeCounts.get(pairKey) ?? 0) + 1); + } + } + + const edges: ForceEdge[] = []; + for (const [pairKey, weight] of edgeCounts) { + // pairKey is always "${childId}|${otherChildId}" with ClusterId strings + // that contain no '|'. + const [sourceId, targetId] = pairKey.split("|") as [string, string]; + edges.push({ source: sourceId, target: targetId, weight }); + } + + return edges; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/colors.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/colors.ts new file mode 100644 index 00000000000..4f546ab5b25 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/colors.ts @@ -0,0 +1,81 @@ +/** + * Cached colour resolution for entities and their type-set groups. + * + * Colours are pure functions of a type-set group, but resolving one walks the + * group's type ids; callers on hot paths (style passes over every node) pass + * a per-pass cache so each group is resolved once, not once per node. + */ +import { + colorForType, + edgeColorForType, + FRONTIER_COLOR, + primaryTypeOfSet, +} from "../../entity-style"; + +import type { Color } from "../../../frames"; +import type { EntityIndex, TypeSetId } from "../../../ids"; +import type { TypeRegistry } from "../../store/type-registry"; +import type { EntityStore } from "../store/entity"; +import type { TypeSetStore } from "../store/type-set"; + +export type ColorCache = Map; + +/** Node colour for a type-set group, keyed off the type's root. */ +export function colorForTypeGroup( + groupIdx: TypeSetId, + cache: ColorCache, + typeSets: TypeSetStore, + types: TypeRegistry, +): Color { + const cached = cache.get(groupIdx); + if (cached) { + return cached; + } + const group = typeSets.getById(groupIdx); + const primary = group + ? primaryTypeOfSet(group.directTypeIds, types) + : undefined; + const color = colorForType(primary, types); + cache.set(groupIdx, color); + return color; +} + +/** Hierarchy-aware colour for an entity, cached per type-set group. */ +export function colorForEntity( + entityIdx: EntityIndex, + cache: ColorCache, + entities: EntityStore, + typeSets: TypeSetStore, + types: TypeRegistry, +): Color { + // A frontier node (fetched, not yet expanded) reads greyed-out, whatever its type. + if (!entities.isRoot(entityIdx)) { + return FRONTIER_COLOR; + } + const groupIdx = entities.getTypeSet(entityIdx); + if (groupIdx === -1) { + return colorForType(undefined, types); + } + return colorForTypeGroup(groupIdx, cache, typeSets, types); +} + +/** Edge colour for a link's type-set group, keyed off the link's own type + * slot (not its root, since all link types share the `Link` root). */ +export function edgeColorForTypeGroup( + groupIdx: TypeSetId, + cache: ColorCache, + typeSets: TypeSetStore, + types: TypeRegistry, +): Color { + const cached = cache.get(groupIdx); + if (cached) { + return cached; + } + const group = typeSets.getById(groupIdx); + const primary = group + ? primaryTypeOfSet(group.directTypeIds, types) + : undefined; + const color = edgeColorForType(primary, types); + cache.set(groupIdx, color); + return color; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/edges.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/edges.ts new file mode 100644 index 00000000000..4a3cf053e48 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/edges.ts @@ -0,0 +1,203 @@ +/** + * The flat tier's edge pipeline: per-link render edges (local node indices + + * link-type colour, rebuilt on commit) and the per-tick pass that turns them + * into straight clipped beziers + arrowheads from current node positions. + * + * Implements the `FlatEdgeSource` contract the positions emitter reads. + */ +import { dimColor } from "../../../dim-color"; +import { entityIndexFromNodeId } from "../../../ids"; +import { writeStraightFlatEdge } from "../../core/flat-edge-writer"; +import { entityStyle } from "../../entity-style"; +import { edgeColorForTypeGroup } from "./colors"; + +import type { Color } from "../../../frames"; +import type { EntityIndex, LinkId } from "../../../ids"; +import type { + BezierSegmentSink, + EndpointArrowSink, +} from "../../geometry/edge-geometry"; +import type { LayoutSimulation } from "../../layout/force-simulation"; +import type { TypeRegistry } from "../../store/type-registry"; +import type { LinkStore } from "../store/link"; +import type { TypeSetStore } from "../store/type-set"; +import type { ColorCache } from "./colors"; + +/** + * Degree-scaled link fading: a link incident to a high-degree hub draws fainter, so a + * 150-leaf starburst reads as a soft halo instead of an opaque disk of strokes (every + * spoke crowds the same few pixels around the hub. Full-alpha spokes sum to a blob + * that hides both the hub and any through-traffic). The scale ramps down with the + * LOG of the larger endpoint degree: links into ordinary nodes (degree below the + * start) keep full alpha, and the ramp saturates at a floor so hub links stay + * visible, just de-emphasised. Thresholds come from the live + * {@link entityStyle} (`hubLinkFade*`). + */ +function hubLinkAlphaScale(degree: number): number { + const { hubLinkFadeStartDegree, hubLinkFadeEndDegree, hubLinkFadeMinScale } = + entityStyle(); + + if (degree <= hubLinkFadeStartDegree) { + return 1; + } + + const ramp = + Math.log(degree / hubLinkFadeStartDegree) / + Math.log(hubLinkFadeEndDegree / hubLinkFadeStartDegree); + + return 1 - Math.min(1, ramp) * (1 - hubLinkFadeMinScale); +} + +/** + * A flat-tier render edge: local node indices into the flat layout plus the + * link's own type colour. Rebuilt each commit (topology + colour); the per-tick + * geometry just reads the two nodes' current positions for these. + */ +interface FlatRenderEdge { + readonly sourceIdx: number; + readonly targetIdx: number; + readonly color: Color; + /** The link's own EntityIdx, so a picked edge resolves to its link entity. */ + readonly linkEntityIdx: EntityIndex; +} + +export interface FlatEdgePipelineDependencies { + readonly links: LinkStore; + readonly typeSets: TypeSetStore; + readonly types: TypeRegistry; + /** The live flat layout, or undefined outside the flat regime. */ + readonly layout: () => LayoutSimulation | undefined; + readonly highlightedEntities: () => ReadonlySet; +} + +export class FlatEdgePipeline { + readonly #dependencies: FlatEdgePipelineDependencies; + + /** One entry per link between placed nodes; rebuilt each commit. */ + #renderEdges: FlatRenderEdge[] = []; + + constructor(dependencies: FlatEdgePipelineDependencies) { + this.#dependencies = dependencies; + } + + get hasRenderEdges(): boolean { + return this.#renderEdges.length > 0; + } + + clear(): void { + this.#renderEdges = []; + } + + /** + * Rebuild the render edges: local node indices + link-type colour for each + * link whose endpoints are both in the layout's node set. + */ + rebuild(layout: LayoutSimulation): void { + const { links, typeSets, types } = this.#dependencies; + const localOf = new Map(); + + for (let idx = 0; idx < layout.nodeIds.length; idx++) { + // nodeIds is dense 0..length-1 for flat layouts built in the same + // commit pass. + localOf.set(entityIndexFromNodeId(layout.nodeIds[idx]!), idx); + } + + const colorCache: ColorCache = new Map(); + const seenLinks = new Set(); + const edges: FlatRenderEdge[] = []; + + for (const [entityIdx, sourceIdx] of localOf) { + for (const link of links.linksFor(entityIdx)) { + if (seenLinks.has(link.linkId)) { + continue; + } + + const targetIdx = localOf.get(link.otherId); + if (targetIdx === undefined) { + continue; + } + + seenLinks.add(link.linkId); + + // Hub-incident links draw fainter (degree-scaled alpha) so a hub's spoke + // fan reads as a halo rather than an opaque starburst. Applied here (once + // per commit) rather than per tick: degree only changes with topology. + const typeColor = edgeColorForTypeGroup( + link.typeSetId, + colorCache, + typeSets, + types, + ); + + const fade = hubLinkAlphaScale( + Math.max(links.degreeOf(entityIdx), links.degreeOf(link.otherId)), + ); + + const color: Color = + fade < 1 + ? [ + typeColor[0], + typeColor[1], + typeColor[2], + Math.round(typeColor[3] * fade), + ] + : typeColor; + + edges.push({ + sourceIdx: link.direction === "out" ? sourceIdx : targetIdx, + targetIdx: link.direction === "out" ? targetIdx : sourceIdx, + color, + linkEntityIdx: links.getEntityIndex(link.linkId), + }); + } + } + + this.#renderEdges = edges; + } + + /** + * Emit one straight cubic per flat render edge from current node positions, + * plus one packed endpoint arrow per edge. Per-tick hot path at 22k+ edges: + * scalar sink writes only, no per-edge allocation. + */ + buildEdgeBeziers(sink: BezierSegmentSink, arrows: EndpointArrowSink): void { + const layout = this.#dependencies.layout(); + if (!layout) { + return; + } + + const { nodes } = layout; + const highlighted = this.#dependencies.highlightedEntities(); + const edgeWidth = entityStyle().flatEdgeWidth; + + for (const edge of this.#renderEdges) { + const source = nodes[edge.sourceIdx]; + const target = nodes[edge.targetIdx]; + + if (!source || !target) { + continue; + } + + // An edge stays full only when both endpoints are highlighted. + const full = + highlighted.size === 0 || + (highlighted.has(entityIndexFromNodeId(source.id)) && + highlighted.has(entityIndexFromNodeId(target.id))); + const color = full ? edge.color : dimColor(edge.color); + + writeStraightFlatEdge( + sink, + arrows, + source.x ?? 0, + source.y ?? 0, + source.radius, + target.x ?? 0, + target.y ?? 0, + target.radius, + color, + edgeWidth, + edge.linkEntityIdx, + ); + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/seed.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/seed.test.ts new file mode 100644 index 00000000000..861262a0af6 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/seed.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; + +import { EntityIndex, nodeIdForEntityIndex, TypeSetId } from "../../../ids"; +import { PositionScratch } from "../../collections/position-scratch"; +import { radiusForDegree } from "../../entity-style"; +import { LinkStore } from "../store/link"; +import { seedFlatNodes } from "./seed"; + +const TYPE = TypeSetId(0); + +/** World-unit offset a streamed node is seeded at, from flat-seed.ts. */ +const NEIGHBOUR_OFFSET = 24; + +let nextLinkEntity = 1000; +function link(links: LinkStore, left: number, right: number): void { + links.insert( + EntityIndex(left), + EntityIndex(right), + TYPE, + EntityIndex(nextLinkEntity++), + ); +} + +function scratchWith( + entries: readonly (readonly [number, number, number])[], +): PositionScratch { + const scratch = new PositionScratch(); + scratch.reset(64); + for (const [idx, x, y] of entries) { + scratch.set(EntityIndex(idx), x, y); + } + return scratch; +} + +// ForceNode declares x/y optional (unseeded nodes); seeded output always has them. +const distance = ( + a: { x?: number; y?: number }, + b: { x?: number; y?: number }, +) => + Math.hypot( + (a.x ?? Number.NaN) - (b.x ?? Number.NaN), + (a.y ?? Number.NaN) - (b.y ?? Number.NaN), + ); + +describe("seedFlatNodes", () => { + it("keeps prior positions exactly and records radii from link degree", () => { + const links = new LinkStore(); + link(links, 0, 1); + + const nodes = seedFlatNodes( + [EntityIndex(0), EntityIndex(1)], + scratchWith([ + [0, 100, -50], + [1, 130, -50], + ]), + links, + ); + + expect(nodes[0]).toEqual({ + id: nodeIdForEntityIndex(EntityIndex(0)), + x: 100, + y: -50, + radius: radiusForDegree(1), + }); + expect(nodes[1]!.x).toBe(130); + expect(nodes[1]!.y).toBe(-50); + }); + + it("seeds an unplaced node one neighbour-offset away from a placed link neighbour", () => { + const links = new LinkStore(); + link(links, 0, 1); + + const nodes = seedFlatNodes( + [EntityIndex(0), EntityIndex(1)], + scratchWith([[0, 100, 50]]), + links, + ); + + expect(distance(nodes[1]!, nodes[0]!)).toBeCloseTo(NEIGHBOUR_OFFSET, 6); + }); + + it("cascades placement along link chains within one call", () => { + const links = new LinkStore(); + link(links, 0, 1); + link(links, 1, 2); + + // Node 2 precedes its (initially unplaced) neighbour 1 in input order, so + // placement needs the outward-growth sweep to repeat. + const nodes = seedFlatNodes( + [EntityIndex(2), EntityIndex(1), EntityIndex(0)], + scratchWith([[0, 0, 0]]), + links, + ); + + const byId = new Map(nodes.map((node) => [node.id, node])); + const node0 = byId.get(nodeIdForEntityIndex(EntityIndex(0)))!; + const node1 = byId.get(nodeIdForEntityIndex(EntityIndex(1)))!; + const node2 = byId.get(nodeIdForEntityIndex(EntityIndex(2)))!; + + expect(distance(node1, node0)).toBeCloseTo(NEIGHBOUR_OFFSET, 6); + expect(distance(node2, node1)).toBeCloseTo(NEIGHBOUR_OFFSET, 6); + }); + + it("places orphans deterministically on distinct positions", () => { + const links = new LinkStore(); + const orphanIdxs = [EntityIndex(0), EntityIndex(1), EntityIndex(2)]; + + const first = seedFlatNodes(orphanIdxs, scratchWith([]), links); + const second = seedFlatNodes(orphanIdxs, scratchWith([]), links); + + expect(second).toEqual(first); + const positions = new Set(first.map((node) => `${node.x},${node.y}`)); + expect(positions.size).toBe(first.length); + }); + + it("extends the scratch in place so every input index ends up placed", () => { + const links = new LinkStore(); + link(links, 0, 1); + const scratch = scratchWith([[0, 10, 10]]); + + seedFlatNodes( + [EntityIndex(0), EntityIndex(1), EntityIndex(2)], + scratch, + links, + ); + + expect(scratch.has(EntityIndex(1))).toBe(true); + expect(scratch.has(EntityIndex(2))).toBe(true); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/seed.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/seed.ts new file mode 100644 index 00000000000..928590247ad --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/seed.ts @@ -0,0 +1,44 @@ +/** + * Seed positions for entity flat-tier layout (re)builds: the shared flat + * placement pass ({@link "../../core/flat-seed"}) walked over the link + * store's adjacency, then materialised as {@link ForceNode}s sized by degree. + */ +import { nodeIdForEntityIndex } from "../../../ids"; +import { placeFlatSeeds } from "../../core/flat-seed"; +import { radiusForDegree } from "../../entity-style"; + +import type { EntityIndex } from "../../../ids"; +import type { PositionScratch } from "../../collections/position-scratch"; +import type { FlatSeedTuning } from "../../core/flat-seed"; +import type { ForceNode } from "../../layout/force-simulation"; +import type { LinkStore } from "../store/link"; + +/** + * `placed` holds the prior positions on entry (see + * {@link PositionScratch}) and is extended in place with every new + * placement, so after the call it covers all of `entityIdxs`. + */ +export function seedFlatNodes( + entityIdxs: readonly EntityIndex[], + placed: PositionScratch, + links: LinkStore, + tuning?: FlatSeedTuning, +): ForceNode[] { + placeFlatSeeds( + entityIdxs, + placed, + function* neighbours(idx) { + for (const link of links.linksFor(idx)) { + yield link.otherId; + } + }, + tuning, + ); + + return entityIdxs.map((idx) => ({ + id: nodeIdForEntityIndex(idx), + x: placed.x(idx), + y: placed.y(idx), + radius: radiusForDegree(links.degreeOf(idx)), + })); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/tier.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/tier.ts new file mode 100644 index 00000000000..b8d92b5eb02 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/flat/tier.ts @@ -0,0 +1,481 @@ +/** + * The flat tier: the whole entity set rendered as one individual-entity + * graph (both the small-N cola tier and the medium-N community/stress tier). + * + * Owns the interleaved SharedArrayBuffer the flat layout streams into and the + * commit path that decides between reuse, warm absorption, and a full + * rebuild. Render edges + bezier emission live in {@link FlatEdgePipeline}; + * the layout itself lives in the shared {@link LayoutRegistry} under + * {@link FLAT_LAYOUT_ID} so the scheduler ticks it like any other. + */ +import { dimColor } from "../../../dim-color"; +import { entityIndexFromNodeId } from "../../../ids"; +import { FlatGraphBuffer } from "../../buffers/position-buffer"; +import { PositionScratch } from "../../collections/position-scratch"; +import { FLAT_LAYOUT_ID } from "../../core/layout-registry"; +import { createFlatLayout } from "../../layout/flat-layout"; +import { createMajorizationLayout } from "../../layout/majorization-layout"; +import { buildEntityEdges } from "../entity-edges"; +import { colorForEntity } from "./colors"; +import { FlatEdgePipeline } from "./edges"; +import { seedFlatNodes } from "./seed"; + +import type { VizConfig } from "../../../config"; +import type { RenderFlatGraph } from "../../../frames"; +import type { EntityIndex, VizMode } from "../../../ids"; +import type { RepublishHandler } from "../../buffers/growable-buffer"; +import type { CommittedView } from "../../core/committed-view"; +import type { LayoutRegistry } from "../../core/layout-registry"; +import type { + BezierSegmentSink, + EndpointArrowSink, +} from "../../geometry/edge-geometry"; +import type { LayoutSimulation } from "../../layout/force-simulation"; +import type { + CapturedLayoutFixture, + LayoutSideChannelMessage, +} from "../../protocol"; +import type { TypeRegistry } from "../../store/type-registry"; +import type { EntityStore } from "../store/entity"; +import type { LinkStore } from "../store/link"; +import type { TypeSetStore } from "../store/type-set"; +import type { ColorCache } from "./colors"; + +/** Over-allocate capacity so streamed nodes can append without reallocation. */ +function flatCapacityFor(count: number): number { + return Math.max(count + 64, Math.ceil(count * 1.5)); +} + +export interface FlatTierDependencies { + readonly config: VizConfig; + readonly layouts: LayoutRegistry; + readonly view: CommittedView; + readonly links: LinkStore; + readonly entities: EntityStore; + readonly typeSets: TypeSetStore; + readonly types: TypeRegistry; + readonly mode: () => VizMode; + readonly nodeCount: () => number; + readonly snapshotNodeEntityIdxs: () => EntityIndex[]; + readonly rootFlipPending: () => boolean; + readonly highlightedEntities: () => ReadonlySet; + readonly ensureSchedulerRunning: () => void; + readonly postLayoutMessage: (msg: LayoutSideChannelMessage) => void; + readonly emitStructure: (flatGraph?: RenderFlatGraph) => void; + readonly emitPositions: () => void; +} + +export class FlatTierController { + readonly #dependencies: FlatTierDependencies; + + /** Interleaved SharedArrayBuffer backing the flat layout (positions + radii + colours). */ + #buffer: FlatGraphBuffer | undefined; + + /** Re-publishes the flat layout buffer on reallocation. */ + readonly #republishBuffer: RepublishHandler = (raw, capacity) => { + this.#dependencies.postLayoutMessage({ + type: "BUFFER_REPUBLISHED", + target: { kind: "layout", clusterId: FLAT_LAYOUT_ID }, + buffer: raw, + capacity, + }); + }; + + /** Render edges + per-tick bezier emission (the positions emitter reads it). */ + readonly #edges: FlatEdgePipeline; + /** Link count at the last flat-layout (re)build; a change forces a rebuild. */ + #builtLinkCount = -1; + /** Which engine the current flat layout was built for ("flat-force" -> cola, + * "community-force" -> stress). Crossing that boundary forces a rebuild. */ + #builtLayoutMode: VizMode | undefined; + /** Trailing-debounce timer: one final Louvain once community-force ingests quiet. */ + #lingerTimer: ReturnType | undefined; + + /** Reusable prior-position buffer for seeding passes (see {@link seedFlatNodes}). */ + readonly #seedScratch = new PositionScratch(); + + constructor(dependencies: FlatTierDependencies) { + this.#dependencies = dependencies; + this.#edges = new FlatEdgePipeline({ + links: dependencies.links, + typeSets: dependencies.typeSets, + types: dependencies.types, + layout: () => dependencies.layouts.get(FLAT_LAYOUT_ID), + highlightedEntities: dependencies.highlightedEntities, + }); + } + + get hasRenderEdges(): boolean { + return this.#edges.hasRenderEdges; + } + + /** + * Commit the flat tier: the whole entity set as one individual-entity graph. + * + * Detects topology changes via O(1) node/link count comparison (stores are + * add-only). Returns immediately when nothing changed. A colour-only change + * (type registration or root flip) restyles without rebuilding the layout. + */ + commit(opts?: { readonly rebuildTree?: boolean }): void { + const { layouts, links, view } = this.#dependencies; + const nodeCount = this.#dependencies.nodeCount(); + + if (nodeCount === 0) { + this.tearDown(); + view.clearRendered(); + this.#dependencies.emitStructure(); + this.#dependencies.emitPositions(); + return; + } + + const existing = layouts.get(FLAT_LAYOUT_ID); + const modeChanged = this.#builtLayoutMode !== this.#dependencies.mode(); + // Add-only stores: a count delta versus the live layout's built count (and + // versus the link count at that build) captures every topology change. + const builtNodeCount = existing?.nodes.length ?? -1; + const nodesChanged = nodeCount !== builtNodeCount; + const linksChanged = links.count !== this.#builtLinkCount; + const structureChanged = + !existing || modeChanged || nodesChanged || linksChanged; + + // A type registration (rebuildTree) or a frontier->root flip recolours + // nodes without changing topology; restyle in place, no layout rebuild. + const styleDirty = + opts?.rebuildTree === true || this.#dependencies.rootFlipPending(); + + if (!structureChanged && !styleDirty) { + // Nothing changed: the layout keeps streaming positions via the + // scheduler, no frame or buffer write needed. + return; + } + + let layoutRebuilt = false; + if (structureChanged) { + // Materialise the packed column into a plain array for the layout builders + // (they map/filter/spread it). Only reached on a real structural change -- + // the no-op path above never allocates. + const entityIdxs = this.#dependencies.snapshotNodeEntityIdxs(); + + // community-force layouts can warm-absorb additions in place; everything + // else (first build, mode switch, or a shrink -- impossible with add-only + // stores, handled defensively) rebuilds, warm-seeded from current spots. + const canAbsorb = + !!existing && + !modeChanged && + nodeCount >= builtNodeCount && + this.#dependencies.mode() === "community-force" && + typeof existing.absorb === "function"; + + if (canAbsorb) { + this.#absorbNodes(existing, entityIdxs); + } else { + this.#rebuildLayout(entityIdxs); + layoutRebuilt = true; + } + } + + const layout = layouts.get(FLAT_LAYOUT_ID); + const buffer = this.#buffer; + if (!layout || !buffer) { + this.#dependencies.emitStructure(); + this.#dependencies.emitPositions(); + return; + } + + // Per-node radius + colour into the shared buffer, plus the per-link render + // edges for the bezier emission. Both run on any structural OR colour change + // (so colours track a type change even when the layout was reused), and are + // skipped on the no-op path above. + this.writeStyle(layout, buffer); + this.#edges.rebuild(layout); + + // Announce a rebuilt layout only after the style pass above has filled the + // buffer: the main thread starts reading the SAB on LAYOUT_CREATED, and an + // earlier post would let it render colourless zero-radius records. + if (layoutRebuilt) { + this.#dependencies.postLayoutMessage({ + type: "LAYOUT_CREATED", + clusterId: FLAT_LAYOUT_ID, + buffer: buffer.raw, + nodeIds: layout.nodeIds, + flatCapacity: buffer.capacity, + }); + } + + this.#emitFrame(layout); + if (structureChanged) { + this.#scheduleLouvainLinger(); + } + } + + /** Re-write node colours honouring the current highlight (no rebuild). */ + restyle(): void { + const layout = this.#dependencies.layouts.get(FLAT_LAYOUT_ID); + if (layout && this.#buffer) { + this.writeStyle(layout, this.#buffer); + } + } + + /** + * Mark the built layout stale so the next {@link commit} rebuilds it + * (warm-seeded from current positions) instead of reusing it. Used by + * live config updates: the layout engines copy their tuning at + * construction, so new tuning needs a rebuild to take effect. + */ + invalidateLayout(): void { + this.#builtLayoutMode = undefined; + } + + /** + * capture-live-fixture debug hook: serialize the live flat-tier layout (node + * positions/radii, deduped edges rebuilt from the link store exactly as the + * layout received them, Louvain communities) for replay via + * `forceGraphFromCapturedFixture` in bench-fixtures.ts. Null when no flat + * layout is live. + */ + captureFixture(): CapturedLayoutFixture | null { + const layout = this.#dependencies.layouts.get(FLAT_LAYOUT_ID); + if (!layout || layout.nodes.length === 0) { + return null; + } + const entityIdxs = layout.nodes.map((node) => + entityIndexFromNodeId(node.id), + ); + const edges = buildEntityEdges( + entityIdxs, + layout.nodes, + this.#dependencies.links, + ); + return { + capturedAt: new Date().toISOString(), + nodes: layout.nodes.map((node) => ({ + id: node.id, + x: node.x ?? 0, + y: node.y ?? 0, + radius: node.radius, + })), + edges: edges.map((edge) => ({ + source: typeof edge.source === "string" ? edge.source : edge.source.id, + target: typeof edge.target === "string" ? edge.target : edge.target.id, + weight: edge.weight, + })), + communities: layout.communities + ? [...layout.communities] + : layout.nodes.map(() => -1), + }; + } + + /** Destroy the flat layout + its shared buffer (entering the hierarchical regime). */ + tearDown(): void { + const { layouts } = this.#dependencies; + if (layouts.has(FLAT_LAYOUT_ID)) { + layouts.delete(FLAT_LAYOUT_ID); + this.#dependencies.postLayoutMessage({ + type: "LAYOUT_DESTROYED", + clusterId: FLAT_LAYOUT_ID, + }); + } + + this.#buffer = undefined; + this.#edges.clear(); + this.#builtLinkCount = -1; + this.#builtLayoutMode = undefined; + if (this.#lingerTimer !== undefined) { + clearTimeout(this.#lingerTimer); + this.#lingerTimer = undefined; + } + } + + /** Emit the flat structure frame (count + Louvain membership) and positions. */ + #emitFrame(layout: LayoutSimulation): void { + this.#dependencies.emitStructure({ + layoutId: FLAT_LAYOUT_ID, + count: layout.nodes.length, + communities: layout.communities + ? Int32Array.from(layout.communities) + : undefined, + }); + this.#dependencies.emitPositions(); + } + + /** + * After ingests go quiet for `stability.flatLouvainLingerMs`, run one + * trailing Louvain so BubbleSets reflect the settled graph; the last batch + * may not have crossed the growth-fraction refresh threshold. Relabel-only + * by contract ({@link LayoutSimulation.refreshCommunities} is + * position-neutral): the re-emitted frame regroups the hulls, while a solve + * still in flight keeps its build-time community target shaping until the + * next absorb rebuilds the solver, which is also why this path needs no + * scheduler re-kick. + */ + #scheduleLouvainLinger(): void { + if (this.#dependencies.mode() !== "community-force") { + return; + } + if (this.#lingerTimer !== undefined) { + clearTimeout(this.#lingerTimer); + } + this.#lingerTimer = setTimeout(() => { + this.#lingerTimer = undefined; + const layout = this.#dependencies.layouts.get(FLAT_LAYOUT_ID); + if (layout?.refreshCommunities?.()) { + this.#emitFrame(layout); + } + }, this.#dependencies.config.stability.flatLouvainLingerMs); + } + + /** + * (Re)build the flat layout over the given node set. Warm-seeded from the + * current layout's positions so existing nodes stay in place. + */ + #rebuildLayout(entityIdxs: readonly EntityIndex[]): void { + const { layouts, links, config } = this.#dependencies; + const previous = layouts.get(FLAT_LAYOUT_ID); + const priorPositions = this.#capturePriorPositions(previous); + + const nodes = seedFlatNodes( + entityIdxs, + priorPositions, + links, + this.#seedTuning(), + ); + const edges = buildEntityEdges(entityIdxs, nodes, links); + + // One interleaved shared buffer for all per-node data. Over-allocated + // so community-force can append streamed nodes without reallocation. + const buffer = new FlatGraphBuffer( + flatCapacityFor(nodes.length), + this.#republishBuffer, + ); + buffer.setCount(nodes.length); + + // flat-force uses cola; community-force uses the constrained + // stress-majorization engine (the only community-tier engine). Both fill the + // same shared buffer; downstream style/edges/render are identical. + const layout = + this.#dependencies.mode() === "community-force" + ? createMajorizationLayout(nodes, edges, buffer, config.majorization) + : createFlatLayout(nodes, edges, buffer, config.flatForce); + + if (previous) { + this.#dependencies.postLayoutMessage({ + type: "LAYOUT_DESTROYED", + clusterId: FLAT_LAYOUT_ID, + }); + } + + this.#buffer = buffer; + this.#builtLinkCount = links.count; + this.#builtLayoutMode = this.#dependencies.mode(); + layouts.set(FLAT_LAYOUT_ID, "entities", layout); + this.#dependencies.ensureSchedulerRunning(); + + // The LAYOUT_CREATED message is delayed and only sent after the style pass inside of commit(). + } + + /** + * Absorb newly-arrived nodes into the live community-force layout without a + * full rebuild. New nodes are seeded beside placed neighbours; appended + * records land in the buffer's spare capacity. + */ + #absorbNodes( + layout: LayoutSimulation, + entityIdxs: readonly EntityIndex[], + ): void { + const priorPositions = this.#capturePriorPositions(layout); + + // Which incoming indices are new, recorded before seeding places them. + const isNew = entityIdxs.map((idx) => !priorPositions.has(idx)); + const seeded = seedFlatNodes( + entityIdxs, + priorPositions, + this.#dependencies.links, + this.#seedTuning(), + ); + + const newNodes = seeded.filter((_, position) => isNew[position]!); + const edges = buildEntityEdges( + [...entityIdxs], + seeded, + this.#dependencies.links, + ); + + // If the new count exceeds capacity, re-allocate (the flat shared buffer + // is non-resizable for GPU upload). The layout's warm state is preserved. + if (this.#buffer && entityIdxs.length > this.#buffer.capacity) { + this.#buffer.ensureCapacity(flatCapacityFor(entityIdxs.length)); + } + + layout.absorb?.(newNodes, edges); + this.#builtLinkCount = this.#dependencies.links.count; + // absorb() re-energises the layout, but the scheduler may have stopped + // when it last settled, so re-kick it. + this.#dependencies.ensureSchedulerRunning(); + } + + /** Seeding geometry from the live config (see {@link seedFlatNodes}). */ + #seedTuning(): { neighbourOffset: number; diskScale: number } { + const { stability } = this.#dependencies.config; + + return { + neighbourOffset: stability.flatSeedNeighbourOffset, + diskScale: stability.flatSeedDiskScale, + }; + } + + /** + * Snapshot a layout's node positions into the reusable seed scratch, + * indexed by entity index. Sized to the whole entity store so any incoming + * index can be probed. + */ + #capturePriorPositions( + layout: LayoutSimulation | undefined, + ): PositionScratch { + const scratch = this.#seedScratch; + scratch.reset(this.#dependencies.entities.size); + + if (layout) { + for (const node of layout.nodes) { + scratch.set(entityIndexFromNodeId(node.id), node.x ?? 0, node.y ?? 0); + } + } + + return scratch; + } + + /** Write per-node radius + colour into the interleaved shared buffer. */ + writeStyle(layout: LayoutSimulation, buffer: FlatGraphBuffer): void { + const { entities, typeSets, types } = this.#dependencies; + const highlighted = this.#dependencies.highlightedEntities(); + const colorByGroup: ColorCache = new Map(); + + for (let idx = 0; idx < layout.nodes.length; idx++) { + const node = layout.nodes[idx]!; + const entityIdx = entityIndexFromNodeId(node.id); + + buffer.setRadius(idx, node.radius); + + const base = colorForEntity( + entityIdx, + colorByGroup, + entities, + typeSets, + types, + ); + const dimmed = highlighted.size > 0 && !highlighted.has(entityIdx); + + buffer.setColor(idx, dimmed ? dimColor(base) : base); + // The join key: which entity this record is (the main thread pairs it with + // the EntityId map shared buffer to resolve labels / icons / tooltips / picking). + buffer.setEntityIdx(idx, entityIdx); + } + + buffer.setCount(layout.nodes.length); + buffer.commit(); + } + + /** Emit one straight cubic per flat render edge. See {@link FlatEdgePipeline.buildEdgeBeziers}. */ + buildEdgeBeziers(sink: BezierSegmentSink, arrows: EndpointArrowSink): void { + this.#edges.buildEdgeBeziers(sink, arrows); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/embedding-coordinator.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/embedding-coordinator.ts new file mode 100644 index 00000000000..e0e86c47d6e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/embedding-coordinator.ts @@ -0,0 +1,202 @@ +/** + * Embedding-driven subdivision and cluster naming. + * + * When a subdivision produces deterministic entity buckets, this requests a + * server-side embedding clustering (requests are drained after each + * structure frame dispatch) and, when the result lands, replaces the + * buckets with the embedding groups. Both bucket and embedding children get + * distinctive-feature names computed off the job scheduler, so the + * placeholder commit paints first and the relabel lands as a label-only + * re-emit. + */ +import { ClusterId } from "../../../ids"; +import { createClusterFeatureSource } from "../../hierarchy/cluster-feature-source"; +import { nameClustersByDistinctiveFeatures } from "../../hierarchy/distinctive-cluster-label"; +import { entityIdsForCluster } from "../cluster-membership"; + +import type { VizConfig } from "../../../config"; +import type { JobScheduler } from "../../core/schedulers"; +import type { ClusterNode, ClusterTree } from "../../hierarchy/cluster-tree"; +import type { ClusterMembers } from "../../hierarchy/distinctive-cluster-label"; +import type { EmbeddingClusteringNeededMessage } from "../../protocol"; +import type { TypeRegistry } from "../../store/type-registry"; +import type { EntityStore } from "../store/entity"; +import type { LinkStore } from "../store/link"; +import type { PropertyStore } from "../store/property"; +import type { TypeSetStore } from "../store/type-set"; +import type { EntityId } from "@blockprotocol/type-system"; + +export interface EmbeddingCoordinatorDependencies { + readonly config: VizConfig; + readonly clusterTree: ClusterTree; + readonly entities: EntityStore; + readonly links: LinkStore; + readonly properties: PropertyStore; + readonly typeSets: TypeSetStore; + readonly types: TypeRegistry; + readonly jobs: JobScheduler; + /** The cluster tree changed shape; the next commit must not no-op. */ + readonly bumpClusterEpoch: () => void; + /** Re-emit the current topology with fresh labels (no cut recompute). */ + readonly recommitLabelsOnly: () => void; +} + +export class EmbeddingCoordinator { + readonly #dependencies: EmbeddingCoordinatorDependencies; + readonly #pendingRequests: EmbeddingClusteringNeededMessage[] = []; + + constructor(dependencies: EmbeddingCoordinatorDependencies) { + this.#dependencies = dependencies; + } + + /** Returns and clears embedding-clustering requests queued during subdivision. */ + drainRequests(): EmbeddingClusteringNeededMessage[] { + if (this.#pendingRequests.length === 0) { + return []; + } + + const requests = [...this.#pendingRequests]; + this.#pendingRequests.length = 0; + + return requests; + } + + /** + * After a subdivision produced children for `node`: request an embedding + * upgrade when the children are deterministic entity buckets, and give + * fallback groups (community + entity-bucket) distinctive-feature names so + * they carry a meaningful name before (or without) embeddings. Type-set + * children are excluded (the type labeler names those). If embeddings + * arrive later they replace these children and re-name via + * {@link applyResult}. + */ + afterSubdivide(node: ClusterNode): void { + const { config, clusterTree, entities, typeSets } = this.#dependencies; + const hasEntityBuckets = node.children.some( + (child) => child.kind === "entity-bucket", + ); + + if ( + hasEntityBuckets && + clusterTree.needsEmbeddingSubdivision(node, config) + ) { + const entityIds = [...entityIdsForCluster(node, typeSets, entities)]; + const targetSize = Math.floor( + config.entityRevealMax * config.embeddingTargetLeafFillRatio, + ); + const clusterCount = Math.max( + 2, + Math.min( + config.embeddingMaxK, + Math.ceil(entityIds.length / targetSize), + ), + ); + + clusterTree.markSubdivisionRequested(node.id); + this.#pendingRequests.push({ + type: "EMBEDDING_CLUSTERING_NEEDED", + clusterId: node.id, + entityIds, + clusterCount, + }); + } + + const fallbackGroups: ClusterMembers[] = []; + for (const child of node.children) { + if ( + (child.kind === "community" || child.kind === "entity-bucket") && + child.membership.source === "direct" + ) { + fallbackGroups.push({ + childId: child.id, + memberIdxs: child.membership.members.subarray().view, + }); + } + } + + this.scheduleDistinctiveFeatureNaming(fallbackGroups); + } + + /** Apply server-side embedding clustering results. */ + applyResult( + clusterId: ClusterId, + clusters: readonly { + readonly clusterId: number; + readonly entityIds: readonly string[]; + }[], + ): void { + const { clusterTree, entities } = this.#dependencies; + const assignments = clusters.map((embeddingCluster) => { + // Compact unknown ids away. Leaving a slot at its Int32Array default of + // 0 would silently claim entity index 0 as a member of every cluster + // containing an id the store has not interned. + const scratch = new Int32Array(embeddingCluster.entityIds.length); + let matched = 0; + + for (const rawId of embeddingCluster.entityIds) { + // Embedding payloads carry string EntityIds; cast matches the store + // lookup key type. Unknown ids are dropped below. + const entityIdx = entities.lookup(rawId as EntityId); + if (entityIdx !== undefined) { + scratch[matched] = entityIdx; + matched += 1; + } + } + + return { + childId: ClusterId( + `${clusterId}:embedding:${embeddingCluster.clusterId}`, + ), + count: matched, + memberIdxs: scratch.subarray(0, matched), + }; + }); + + clusterTree.applyEmbeddingResult(clusterId, assignments); + this.#dependencies.bumpClusterEpoch(); + + // The children render immediately with their "Similar group n" placeholder (set in + // ClusterTree.applyEmbeddingResult); the relabel lands later off the job scheduler. + this.scheduleDistinctiveFeatureNaming(assignments); + } + + /** + * Schedule distinctive-feature naming for sibling child clusters (embedding + * groups or fallback buckets). Names from a unified feature space: exact + * property values, numeric/date ranges, and link/target types. Deferred onto + * the job scheduler (O(members x features)); the placeholder commit paints + * first, then the relabel lands once the scan completes. + */ + scheduleDistinctiveFeatureNaming(groups: readonly ClusterMembers[]): void { + if (groups.length === 0) { + return; + } + + const { properties, links, entities, typeSets, types, clusterTree } = + this.#dependencies; + this.#dependencies.jobs.schedule(() => { + const labels = nameClustersByDistinctiveFeatures( + groups, + createClusterFeatureSource({ + properties, + links, + entities, + typeSets, + types, + }), + ); + + if (labels.size === 0) { + return; + } + + for (const [childId, label] of labels) { + clusterTree.setLabelText(childId, label); + } + + // Labels don't affect the cut, so re-emit the current topology with the + // fresh labels rather than paying a full cut + aggregation rebuild. + this.#dependencies.recommitLabelsOnly(); + }); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/layout-reuse.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/layout-reuse.test.ts new file mode 100644 index 00000000000..1911e8ebf68 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/layout-reuse.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; + +import { + GROWTH_RELAYOUT_TOLERANCE_FRAC, + layoutNeedsRebuild, + layoutOutgrown, + OVERLAP_REBUILD_TOLERANCE_FRAC, +} from "./layout-reuse"; + +/** Two bubbles 100 apart on the x-axis; radii supplied per case. */ +const pairAt = (radiusA: number, radiusB: number) => + ({ + previous: [ + { id: "a", x: 0, y: 0 }, + { id: "b", x: 100, y: 0 }, + ], + current: [ + { id: "a", radius: radiusA }, + { id: "b", radius: radiusB }, + ], + }) as const; + +describe("layoutNeedsRebuild", () => { + it("reuses a layout whose children still fit", () => { + const { previous, current } = pairAt(40, 40); // gap of 20 + expect(layoutNeedsRebuild(previous, current)).toBe(false); + }); + + it("rebuilds when the child count changes", () => { + expect( + layoutNeedsRebuild( + [{ id: "a", x: 0, y: 0 }], + [ + { id: "a", radius: 10 }, + { id: "b", radius: 10 }, + ], + ), + ).toBe(true); + }); + + it("rebuilds when a child id is swapped (same count)", () => { + expect( + layoutNeedsRebuild([{ id: "a", x: 0, y: 0 }], [{ id: "b", radius: 10 }]), + ).toBe(true); + }); + + it("reuses a big growth that still has slack (no churn)", () => { + // a: 40 -> 55 (nearly +40%). Edge-to-edge gap shrinks 20 -> 5 but there + // is no overlap despite growth, so the layout must be kept (anti-churn case). + const { previous, current } = pairAt(55, 40); + expect(layoutNeedsRebuild(previous, current)).toBe(false); + }); + + it("rebuilds on the reported bug: growth that overlaps a neighbour", () => { + // a balloons to 224 (the 70 -> 2000 case); it now buries b at distance 100. + const { previous, current } = pairAt(224, 12); + expect(layoutNeedsRebuild(previous, current)).toBe(true); + }); + + it("does NOT rebuild on a shrink (it only frees space)", () => { + const { previous, current } = pairAt(10, 10); // far smaller than the gap + expect(layoutNeedsRebuild(previous, current)).toBe(false); + }); + + it("tolerates a penetration within the dead-band, rebuilds just past it", () => { + // minDist = rA + rB; penetration = minDist - 100. Tolerance is a fraction of + // the smaller radius. With rB = 50, tolerance = 0.05 * 50 = 2.5. + const tol = OVERLAP_REBUILD_TOLERANCE_FRAC * 50; + expect(tol).toBeCloseTo(2.5); + // penetration = (52 + 50) - 100 = 2 (< 2.5) -> reuse. + const within = pairAt(52, 50); + expect(layoutNeedsRebuild(within.previous, within.current)).toBe(false); + // penetration = (53 + 50) - 100 = 3 (> 2.5) -> rebuild. + const past = pairAt(53, 50); + expect(layoutNeedsRebuild(past.previous, past.current)).toBe(true); + }); + + it("rebuilds when only ONE of many children grows into a neighbour", () => { + expect( + layoutNeedsRebuild( + [ + { id: "a", x: 0, y: 0 }, + { id: "b", x: 100, y: 0 }, + { id: "c", x: 0, y: 100 }, + ], + [ + { id: "a", radius: 20 }, + { id: "b", radius: 90 }, // reaches back across the 100 gap into a + { id: "c", radius: 20 }, + ], + ), + ).toBe(true); + }); +}); + +describe("layoutOutgrown", () => { + const buildTime = [ + { id: "a", radius: 40 }, + { id: "b", radius: 40 }, + ] as const; + + it("re-warms once a child grows past the threshold", () => { + const past = 40 * (1 + GROWTH_RELAYOUT_TOLERANCE_FRAC) + 1; + expect( + layoutOutgrown(buildTime, [ + { id: "a", radius: past }, + { id: "b", radius: 40 }, + ]), + ).toBe(true); + }); + + it("keeps the layout while growth stays within the threshold", () => { + const within = 40 * (1 + GROWTH_RELAYOUT_TOLERANCE_FRAC) - 1; + expect( + layoutOutgrown(buildTime, [ + { id: "a", radius: within }, + { id: "b", radius: within }, + ]), + ).toBe(false); + }); + + it("never re-warms on a shrink", () => { + expect( + layoutOutgrown(buildTime, [ + { id: "a", radius: 10 }, + { id: "b", radius: 5 }, + ]), + ).toBe(false); + }); + + it("ignores ids not present when the layout was built", () => { + // A genuinely new cluster is layoutNeedsRebuild's job (count change), not + // this growth check, so an unknown id alone must not trigger a re-warm. + expect(layoutOutgrown(buildTime, [{ id: "z", radius: 9_999 }])).toBe(false); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/layout-reuse.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/layout-reuse.ts new file mode 100644 index 00000000000..88dbde892c4 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/layout-reuse.ts @@ -0,0 +1,116 @@ +/** + * Decides when a settled cluster layout must be rebuilt. + * + * Layouts are reused across commits for stability, but a reused layout keeps + * frozen positions while child radii grow with entity count. A child that + * grows can overlap a neighbour at its frozen position, and a count-only + * guard never notices. + * + * The trigger is the overlap itself: growth with slack around it keeps the + * layout, growth into a neighbour rebuilds it, and a shrink never rebuilds + * (it only frees space). A radius-percentage threshold would rebuild + * harmless growth (churn) yet miss overlap in a tightly-packed spot. + */ + +import type { Circle, Position } from "../../../geometry"; + +/** + * How far one bubble may penetrate another (as a fraction of the smaller + * radius) before a rebuild is forced. A small dead-band so freshly-solved + * padding doesn't trigger an immediate rebuild. + */ +export const OVERLAP_REBUILD_TOLERANCE_FRAC = 0.05; + +interface PlacedNode extends Position { + readonly id: string; +} + +interface SizedNode { + readonly id: string; + readonly radius: number; +} + +/** + * Whether a reused layout must be rebuilt: the child set changed, or a child + * now overlaps a neighbour by more than `toleranceFrac` of the smaller radius. + */ +export function layoutNeedsRebuild( + previous: readonly PlacedNode[], + current: readonly SizedNode[], + toleranceFrac: number = OVERLAP_REBUILD_TOLERANCE_FRAC, +): boolean { + // Membership count changed: positions from the previous set are invalid. + if (previous.length !== current.length) { + return true; + } + + const positionById = new Map(); + for (const node of previous) { + positionById.set(node.id, node); + } + + // Join the new radii onto the current positions; a missing id means the set + // changed even though the count matched. + const placed: Circle[] = []; + for (const child of current) { + const position = positionById.get(child.id); + if (position === undefined) { + return true; + } + placed.push({ x: position.x, y: position.y, radius: child.radius }); + } + + // Overlap beyond the dead-band at frozen positions with updated radii means + // the reused layout is infeasible. + for (let idxA = 0; idxA < placed.length; idxA++) { + const nodeA = placed[idxA]!; + for (let idxB = idxA + 1; idxB < placed.length; idxB++) { + const nodeB = placed[idxB]!; + const distance = Math.hypot(nodeB.x - nodeA.x, nodeB.y - nodeA.y); + const penetration = nodeA.radius + nodeB.radius - distance; + const tolerance = toleranceFrac * Math.min(nodeA.radius, nodeB.radius); + if (penetration > tolerance) { + return true; + } + } + } + + return false; +} + +/** + * How much a child may grow (as a fraction of its build-time radius) before + * the layout is re-warmed. Separate from {@link layoutNeedsRebuild} (which + * fires on infeasible overlaps): this is a voluntary re-pack so growing + * clusters visibly re-arrange before any overlap occurs. The threshold + * prevents re-warming on every streaming batch. + * + * Radius grows as `sqrt(count)`, so 0.15 is about +32% members. + */ +export const GROWTH_RELAYOUT_TOLERANCE_FRAC = 0.15; + +/** + * Whether any child's radius has grown past `toleranceFrac` of the radius it had + * when the layout was built (`buildTime`). Ids only present on one side are + * ignored; {@link layoutNeedsRebuild} owns set-membership changes. + */ +export function layoutOutgrown( + buildTime: readonly SizedNode[], + current: readonly SizedNode[], + toleranceFrac: number = GROWTH_RELAYOUT_TOLERANCE_FRAC, +): boolean { + const builtRadiusById = new Map(); + + for (const node of buildTime) { + builtRadiusById.set(node.id, node.radius); + } + + for (const child of current) { + const built = builtRadiusById.get(child.id); + if (built !== undefined && child.radius > built * (1 + toleranceFrac)) { + return true; + } + } + + return false; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/layouts.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/layouts.ts new file mode 100644 index 00000000000..200b747db42 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/layouts.ts @@ -0,0 +1,350 @@ +/** + * Lifecycle of the hierarchical tier's force layouts: children (bubble) + * layouts for opened containers and entity (dot) layouts for open leaves. + * + * Creation is idempotent per committed cut: `ensure*` reuses a live layout + * unless it is stale (member set changed, sized child overlaps, or growth + * warrants a re-pack). {@link commitRendered} destroys layouts that left + * the cut and keeps every per-layout side table in sync. + */ +import { nodeIdForEntityIndex } from "../../../ids"; +import { createClusterLayout } from "../../layout/cluster-layout"; +import { createEntityLayout } from "../../layout/entity-layout"; +import { entityIndicesForCluster } from "../cluster-membership"; +import { buildClusterEdges, buildEntityEdges } from "../entity-edges"; +import { layoutNeedsRebuild, layoutOutgrown } from "./layout-reuse"; +import { writeLeafColors } from "./leaf-colors"; +import { membershipFingerprint } from "./membership-fingerprint"; + +import type { VizConfig } from "../../../config"; +import type { ClusterId, EntityIndex } from "../../../ids"; +import type { CommittedView, RenderedEntry } from "../../core/committed-view"; +import type { LayoutRegistry } from "../../core/layout-registry"; +import type { ClusterNode } from "../../hierarchy/cluster-tree"; +import type { + ForceNode, + LayoutSimulation, +} from "../../layout/force-simulation"; +import type { LayoutSideChannelMessage } from "../../protocol"; +import type { TypeRegistry } from "../../store/type-registry"; +import type { EntityStore } from "../store/entity"; +import type { LinkStore } from "../store/link"; +import type { TypeSetStore } from "../store/type-set"; +import type { PortConstraintController } from "./port-constraints"; +import type { SettlePolisher } from "./settle-polish"; + +export interface HierarchicalLayoutDependencies { + readonly config: VizConfig; + readonly layouts: LayoutRegistry; + readonly view: CommittedView; + readonly polisher: SettlePolisher; + readonly portConstraints: PortConstraintController; + readonly links: LinkStore; + readonly entities: EntityStore; + readonly typeSets: TypeSetStore; + readonly types: TypeRegistry; + readonly highlightedEntities: () => ReadonlySet; + readonly ensureSchedulerRunning: () => void; + readonly postLayoutMessage: (msg: LayoutSideChannelMessage) => void; +} + +/** + * True when frozen child positions overlap at updated radii or membership + * changed (delegates to {@link layoutNeedsRebuild}). + */ +const clusterLayoutStale = ( + layout: LayoutSimulation, + parent: ClusterNode, + toleranceFrac: number, +): boolean => + layoutNeedsRebuild( + layout.nodes.map((node) => ({ + id: node.id, + x: node.x ?? 0, + y: node.y ?? 0, + })), + parent.children.map((child) => ({ + id: child.id, + radius: child.circle.radius, + })), + toleranceFrac, + ); + +/** + * Whether a top-level cluster has grown enough since the macro layout was built + * to warrant re-warming it, so a growing hierarchy re-arranges even without an + * overlap. See {@link layoutOutgrown}. `layout.nodes[i].radius` is the radius the + * layout was built with; `child.circle.radius` is the current (grown) one. + */ +const clusterLayoutOutgrown = ( + layout: LayoutSimulation, + parent: ClusterNode, + toleranceFrac: number, +): boolean => + layoutOutgrown( + layout.nodes.map((node) => ({ id: node.id, radius: node.radius })), + parent.children.map((child) => ({ + id: child.id, + radius: child.circle.radius, + })), + toleranceFrac, + ); + +export class HierarchicalLayoutManager { + readonly #dependencies: HierarchicalLayoutDependencies; + + /** Per entity-layout, the member-set fingerprint it was built over (see {@link ensureEntityLayout}). */ + readonly #entityLayoutFingerprints = new Map(); + + constructor(dependencies: HierarchicalLayoutDependencies) { + this.#dependencies = dependencies; + } + + /** Forget all per-layout bookkeeping (full invalidation paths). */ + clearFingerprints(): void { + this.#entityLayoutFingerprints.clear(); + } + + ensureChildrenLayout(parent: ClusterNode): void { + const { layouts, polisher, portConstraints, links, typeSets } = + this.#dependencies; + const key = parent.id; + let layout = layouts.get(key); + + // Keep the persisted top-level positions current from the live layout, so a + // recreation/rebuild below re-seeds each existing cluster where it is now + // (and anchors the optimiser to it). Root only: it's the hierarchy overview + // whose stability the user notices. + if (parent.kind === "root" && layout) { + polisher.snapshotTopLevelPositions(layout); + } + + // Invalidate when a freshly-sized child overlaps a neighbour at its frozen + // position (harmless growth with slack around it is kept), or at top level + // only, when a cluster has grown enough since this layout was built to + // warrant a re-pack, so the hierarchy overview visibly re-arranges as it + // grows rather than only when growth finally forces an overlap. + const { stability } = this.#dependencies.config; + + if ( + layout && + (clusterLayoutStale(layout, parent, stability.overlapRebuildTolerance) || + (parent.kind === "root" && + clusterLayoutOutgrown( + layout, + parent, + stability.growthRelayoutTolerance, + ))) + ) { + layouts.delete(key); + portConstraints.deleteAnchors(key); + layout = undefined; + } + + if (!layout) { + // Top-level children re-seed from their persisted position when + // available; genuinely new clusters fall back to the cluster-tree seed. + const nodes: ForceNode[] = parent.children.map((child) => { + const persisted = + parent.kind === "root" + ? polisher.topLevelPositionOf(child.id) + : undefined; + return { + id: child.id, + x: persisted ? persisted.x : child.circle.x - parent.circle.x, + y: persisted ? persisted.y : child.circle.y - parent.circle.y, + radius: child.circle.radius, + }; + }); + + const edges = buildClusterEdges(parent.children, links, typeSets); + + // Capture inter-sibling edges as node-index pairs for the D1 untangle, + // before createClusterLayout, since d3 forceLink mutates edge.source/ + // target from ids into node objects in place. + const indexOf = new Map(); + for (let idx = 0; idx < parent.children.length; idx++) { + indexOf.set(parent.children[idx]!.id, idx); + } + const edgeIndices: [number, number][] = []; + for (const edge of edges) { + // buildClusterEdges emits string cluster ids matching + // parent.children[].id before forceLink mutates endpoints to node + // objects. + const sourceIdx = indexOf.get(edge.source as string); + const targetIdx = indexOf.get(edge.target as string); + if (sourceIdx !== undefined && targetIdx !== undefined) { + edgeIndices.push([sourceIdx, targetIdx]); + } + } + + // Root has no confinement; top-level clusters are free-floating. + const confinement = + parent.kind === "root" ? undefined : parent.circle.radius; + layout = createClusterLayout( + nodes, + edges, + confinement, + this.#dependencies.config.clusterForce, + ); + // Warm up so the first frame isn't the raw ring seed. + layout.tick(20); + const childById = new Map( + parent.children.map((child) => [child.id, child]), + ); + for (const node of layout.nodes) { + // Cluster layouts use ClusterId strings as force node ids; every + // node.id was just seeded from parent.children. + const child = childById.get(node.id as ClusterId); + if (child) { + child.circle.x = parent.circle.x + (node.x ?? 0); + child.circle.y = parent.circle.y + (node.y ?? 0); + } + } + layouts.set(key, "clusters", layout); + polisher.registerLayout(key, edgeIndices); + this.#dependencies.ensureSchedulerRunning(); + + // A small layout (e.g. the root's handful of top-level clusters) can fully + // settle inside the 20ms warm-up above. The scheduler loop then skips it + // (status === settled) and never fires the settle-polish, so run it now. + if (layout.isSettled) { + polisher.polishSettledLayout(parent, layout); + } + } + } + + ensureEntityLayout(cluster: ClusterNode): void { + const { layouts, portConstraints, links, entities, typeSets, types } = + this.#dependencies; + const key = cluster.id; + const existing = layouts.get(key); + const entityIdxs = [...entityIndicesForCluster(cluster, typeSets)]; + + // Order-independent membership fingerprint. A bare count check misses + // same-size swaps (an entity leaving the "other" bucket as another + // arrives), which would keep rendering the stale member set. + const fingerprint = membershipFingerprint(entityIdxs); + if (existing) { + if (this.#entityLayoutFingerprints.get(key) === fingerprint) { + return; + } + layouts.delete(key); + portConstraints.deletePortTargets(key); + this.#dependencies.postLayoutMessage({ + type: "LAYOUT_DESTROYED", + clusterId: key, + }); + } + + this.#entityLayoutFingerprints.set(key, fingerprint); + const parentRadius = cluster.circle.radius; + const entityRadius = + parentRadius * + this.#dependencies.config.clusterSizing.entityRadiusFraction; + + // Deterministic phyllotaxis (sunflower) seeding: even, stable disk fill so + // re-opening a cluster lands entities in the same place each time. + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + const fillRadius = parentRadius * 0.85; + const nodes: ForceNode[] = []; + for (let idx = 0; idx < entityIdxs.length; idx++) { + const dist = fillRadius * Math.sqrt((idx + 0.5) / entityIdxs.length); + const angle = idx * goldenAngle; + // entityIdxs is a fresh array built in the same function; idx is + // always in range. + nodes.push({ + id: nodeIdForEntityIndex(entityIdxs[idx]!), + x: Math.cos(angle) * dist, + y: Math.sin(angle) * dist, + radius: entityRadius, + }); + } + + const edges = buildEntityEdges(entityIdxs, nodes, links); + // Live port-attraction targets (one (x,y) per entity, NaN = no external + // connection). The layout's force reads these each tick so dots track + // their ports. + const portTargets = new Float32Array(entityIdxs.length * 2).fill( + Number.NaN, + ); + + portConstraints.setPortTargets(key, portTargets); + const layout = createEntityLayout( + nodes, + edges, + cluster.circle.radius, + portTargets, + this.#dependencies.config.entityForce, + ); + layouts.set(key, "entities", layout); + + // Per-node colours are written once here and again only on highlight + // changes, not per commit (avoiding re-upload stutter while zooming). + writeLeafColors(cluster, layout, { + types, + isRoot: (entityIdx) => entities.isRoot(entityIdx), + highlightedEntities: this.#dependencies.highlightedEntities, + }); + this.#dependencies.ensureSchedulerRunning(); + + // Shared-buffer reference so the main thread reads entity positions directly. + // Radius, color, and the leaf's world origin travel in the StructureFrame. + this.#dependencies.postLayoutMessage({ + type: "LAYOUT_CREATED", + clusterId: cluster.id, + buffer: layout.buffer, + nodeIds: layout.nodeIds, + }); + } + + /** Replace the committed visible set and destroy layouts that left the cut. */ + commitRendered( + rendered: RenderedEntry[], + activeLayouts: ReadonlySet, + ): void { + const { layouts, view, polisher, portConstraints } = this.#dependencies; + view.replaceRendered(rendered); + + for (const key of layouts.keys()) { + if (activeLayouts.has(key)) { + continue; + } + + const wasEntity = layouts.kindOf(key) === "entities"; + layouts.delete(key); + portConstraints.deleteFor(key); + this.#entityLayoutFingerprints.delete(key); + polisher.deleteFor(key); + + if (wasEntity) { + this.#dependencies.postLayoutMessage({ + type: "LAYOUT_DESTROYED", + clusterId: key, + }); + } + } + } + + /** + * Destroy every layout (posting LAYOUT_DESTROYED for entity ones, whose + * shared buffers the main thread holds) and clear all per-layout state. + */ + destroyAllLayouts(): void { + const { layouts, polisher, portConstraints } = this.#dependencies; + + for (const clusterId of layouts.keys()) { + if (layouts.kindOf(clusterId) === "entities") { + this.#dependencies.postLayoutMessage({ + type: "LAYOUT_DESTROYED", + clusterId, + }); + } + } + + layouts.clear(); + portConstraints.clear(); + this.#entityLayoutFingerprints.clear(); + polisher.resetLayouts(); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/leaf-colors.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/leaf-colors.ts new file mode 100644 index 00000000000..5b9e905b94c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/leaf-colors.ts @@ -0,0 +1,53 @@ +import { dimColor } from "../../../dim-color"; +import { entityIndexFromNodeId } from "../../../ids"; +import { FRONTIER_COLOR } from "../../entity-style"; +import { colorForCluster } from "../../hierarchy/cluster-tree"; + +import type { EntityIndex } from "../../../ids"; +import type { ClusterNode } from "../../hierarchy/cluster-tree"; +import type { LayoutSimulation } from "../../layout/force-simulation"; +import type { TypeRegistry } from "../../store/type-registry"; + +export interface LeafColorDependencies { + readonly types: TypeRegistry; + readonly isRoot: (entityIdx: EntityIndex) => boolean; + readonly highlightedEntities: () => ReadonlySet; +} + +/** + * Write per-node colour into a leaf's entity buffer. Runs on leaf creation + * and highlight changes, not per commit (avoiding pan/zoom stutter). + * + * A frontier node reads greyed-out, overriding the cluster colour and the + * focus dim; with a highlight active, non-highlighted roots dim. + */ +export function writeLeafColors( + cluster: ClusterNode, + layout: LayoutSimulation, + dependencies: LeafColorDependencies, +): void { + if (!layout.setNodeColor) { + return; + } + + const base = colorForCluster(cluster, dependencies.types); + const dim = dimColor(base); + const highlighted = dependencies.highlightedEntities(); + const active = highlighted.size > 0; + + for (let idx = 0; idx < layout.nodeIds.length; idx++) { + // nodeIds is parallel to layout.nodes for entity layouts created in + // ensureEntityLayout. + const entityIdx = entityIndexFromNodeId(layout.nodeIds[idx]!); + + if (!dependencies.isRoot(entityIdx)) { + layout.setNodeColor(idx, FRONTIER_COLOR); + continue; + } + + const dimmed = active && !highlighted.has(entityIdx); + layout.setNodeColor(idx, dimmed ? dim : base); + } + + layout.commitColors?.(); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/membership-fingerprint.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/membership-fingerprint.test.ts new file mode 100644 index 00000000000..4dd6928b4b8 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/membership-fingerprint.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { EntityIndex } from "../../../ids"; +import { membershipFingerprint } from "./membership-fingerprint"; + +const indices = (...values: number[]): EntityIndex[] => + values.map((value) => EntityIndex(value)); + +describe("membershipFingerprint", () => { + it("is order-independent", () => { + expect(membershipFingerprint(indices(1, 2, 3))).toBe( + membershipFingerprint(indices(3, 1, 2)), + ); + }); + + it("distinguishes equal-count sets differing in one member", () => { + expect(membershipFingerprint(indices(0, 1, 2))).not.toBe( + membershipFingerprint(indices(0, 1, 3)), + ); + }); + + it("distinguishes consecutive ranges shifted by one", () => { + // The raw indices are highly structured; the avalanche step is what + // keeps their sum/xor aggregates from colliding across shifts. + const rangeA = indices(...Array.from({ length: 100 }, (_, idx) => idx)); + const rangeB = indices(...Array.from({ length: 100 }, (_, idx) => idx + 1)); + expect(membershipFingerprint(rangeA)).not.toBe( + membershipFingerprint(rangeB), + ); + }); + + it("distinguishes sets of different sizes sharing a prefix", () => { + expect(membershipFingerprint(indices(1, 2))).not.toBe( + membershipFingerprint(indices(1, 2, 3)), + ); + }); + + it("fingerprints the empty set stably", () => { + expect(membershipFingerprint([])).toBe(membershipFingerprint([])); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/membership-fingerprint.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/membership-fingerprint.ts new file mode 100644 index 00000000000..be63a90632e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/membership-fingerprint.ts @@ -0,0 +1,28 @@ +import { fmix32 } from "../../../math/hash"; + +import type { EntityIndex } from "../../../ids"; + +/** + * Order-independent fingerprint of an entity-index set: count plus the sum + * and xor of each element's avalanche hash ({@link fmix32}, so structured + * sets like `{0..n}` do not cancel). Two member sets of the same size + * differing in even one entity produce different fingerprints, up to hash + * collision odds of ~2^-64 given both aggregates must match. + * + * Used to detect equal-count membership changes that a plain length check + * would miss (e.g. a cluster re-subdivision that swaps members). + */ +export function membershipFingerprint( + entityIdxs: readonly EntityIndex[], +): string { + /* eslint-disable no-bitwise -- integer hash aggregation */ + let sum = 0; + let xor = 0; + for (const entityIdx of entityIdxs) { + const word = fmix32(entityIdx | 0); + sum = (sum + word) | 0; + xor ^= word; + } + /* eslint-enable no-bitwise */ + return `${entityIdxs.length}:${sum}:${xor}`; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/port-constraints.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/port-constraints.ts new file mode 100644 index 00000000000..d6f64451a39 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/port-constraints.ts @@ -0,0 +1,210 @@ +/** + * Ports as layout constraints for opened containers. + * + * For each opened container, a fixed anchor sits on its rim toward each + * external neighbour and pulls the children connected through that port. + * As the macro layout moves the neighbours, the anchors re-aim. Entity + * (dot) layouts get per-entity port-attraction targets instead: a live + * Float32Array their force reads every tick. + */ +import { highwayEndpoints } from "../../geometry/edge-geometry"; + +import type { Position } from "../../../geometry"; +import type { ClusterId } from "../../../ids"; +import type { LayoutRegistry } from "../../core/layout-registry"; +import type { CutIndex, EdgeFrame } from "../../geometry/edge-aggregation"; +import type { ClusterTree } from "../../hierarchy/cluster-tree"; +import type { PortAnchor } from "../../layout/force-simulation"; + +export interface PortConstraintDependencies { + readonly layouts: LayoutRegistry; + readonly clusterTree: ClusterTree; +} + +export class PortConstraintController { + readonly #dependencies: PortConstraintDependencies; + + /** Per entity-layout, the live port-attraction targets (shared with its force). */ + readonly #entityPortTargets = new Map(); + /** Per opened container, the external endpoint ids its port anchors track. */ + readonly #anchorEndpoints = new Map(); + + constructor(dependencies: PortConstraintDependencies) { + this.#dependencies = dependencies; + } + + setPortTargets(id: ClusterId, targets: Float32Array): void { + this.#entityPortTargets.set(id, targets); + } + + portTargetsOf(id: ClusterId): Float32Array | undefined { + return this.#entityPortTargets.get(id); + } + + deletePortTargets(id: ClusterId): void { + this.#entityPortTargets.delete(id); + } + + deleteAnchors(id: ClusterId): void { + this.#anchorEndpoints.delete(id); + } + + deleteFor(id: ClusterId): void { + this.#entityPortTargets.delete(id); + this.#anchorEndpoints.delete(id); + } + + clear(): void { + this.#entityPortTargets.clear(); + this.#anchorEndpoints.clear(); + } + + /** + * Ports as WebCola constraints. For each opened container, add a fixed + * anchor on its rim toward each external neighbour and link the connected + * children. Only applied to still-running layouts. + */ + applyPortConstraints( + edgeFrame: EdgeFrame | undefined, + cutIndex: CutIndex | undefined, + ): void { + if (!edgeFrame || !cutIndex) { + return; + } + const { layouts, clusterTree } = this.#dependencies; + + for (const [containerId, layout] of layouts.entries()) { + if ( + layouts.kindOf(containerId) !== "clusters" || + layout.status !== "running" || + !layout.setPortAnchors + ) { + continue; + } + const container = clusterTree.get(containerId); + if (!container || container.kind === "root") { + continue; + } + + const childIndex = new Map(); + for (const [idx, child] of container.children.entries()) { + childIndex.set(child.id, idx); + } + + // Group the children by the external endpoint they connect to; the anchor + // sits on the rim in that endpoint's direction. + const byEndpoint = new Map< + ClusterId, + Position & { readonly counts: Map } + >(); + + for (const edge of edgeFrame.visualEdges) { + if (edge.kind !== "aggregate") { + continue; + } + + const sourceInside = childIndex.has(edge.source.id); + const targetInside = childIndex.has(edge.target.id); + if (sourceInside === targetInside) { + continue; // both internal, or both external, not a boundary edge + } + + const childId = sourceInside ? edge.source.id : edge.target.id; + const externalId = sourceInside ? edge.target.id : edge.source.id; + const { highwayTargetId } = highwayEndpoints( + childId, + externalId, + clusterTree, + cutIndex.containerIds, + ); + + const endpoint = clusterTree.get(highwayTargetId); + if (!endpoint) { + continue; + } + + const dx = endpoint.circle.x - container.circle.x; + const dy = endpoint.circle.y - container.circle.y; + const dist = Math.hypot(dx, dy); + if (dist < 1e-6) { + continue; + } + + let anchor = byEndpoint.get(highwayTargetId); + if (!anchor) { + anchor = { + x: (dx / dist) * container.circle.radius, + y: (dy / dist) * container.circle.radius, + counts: new Map(), + }; + byEndpoint.set(highwayTargetId, anchor); + } + + // childId is the in-container endpoint of a boundary aggregate + // edge, so it must be one of container.children. + const childPos = childIndex.get(childId)!; + anchor.counts.set( + childPos, + (anchor.counts.get(childPos) ?? 0) + edge.count, + ); + } + + if (byEndpoint.size > 0) { + const anchorList: PortAnchor[] = []; + for (const anchor of byEndpoint.values()) { + anchorList.push({ + x: anchor.x, + y: anchor.y, + // Pull weight grows (log) with the edge count through this port, so + // a child's strongest connection wins its placement. + children: [...anchor.counts].map(([index, count]) => ({ + index, + weight: 1 + Math.log2(1 + count), + })), + }); + } + layout.setPortAnchors(anchorList); + // Remember the endpoints (in anchor order) so updateAnchorTracking can + // re-aim the anchors as the macro moves. + this.#anchorEndpoints.set(containerId, [...byEndpoint.keys()]); + } else { + this.#anchorEndpoints.delete(containerId); + } + } + } + + /** + * Re-aim opened sub-clusters' port anchors at their moved external + * neighbours. Runs over every tracked container whenever any cluster-level + * layout moves: still-running layouts re-sort their children toward the new + * directions, settled layouts ignore the writes (an accepted staleness, + * documented on `updateAnchorPositions` in + * {@link "../../layout/cluster-layout"}). + */ + updateAnchorTracking(): void { + const { layouts, clusterTree } = this.#dependencies; + for (const [containerId, endpointIds] of this.#anchorEndpoints) { + const layout = layouts.get(containerId); + const container = clusterTree.get(containerId); + if (!layout?.updateAnchorPositions || !container) { + continue; + } + + const positions = endpointIds.map((endpointId) => { + const endpoint = clusterTree.get(endpointId); + if (!endpoint) { + return { x: 0, y: 0 }; + } + const dx = endpoint.circle.x - container.circle.x; + const dy = endpoint.circle.y - container.circle.y; + const dist = Math.hypot(dx, dy) || 1; + return { + x: (dx / dist) * container.circle.radius, + y: (dy / dist) * container.circle.radius, + }; + }); + + layout.updateAnchorPositions(positions); + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/settle-polish.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/settle-polish.ts new file mode 100644 index 00000000000..0535915c25e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/settle-polish.ts @@ -0,0 +1,251 @@ +/** + * Once-per-layout settle polish for cluster (bubble) layouts, plus the + * persisted top-level positions that keep the hierarchy overview stable + * across layout recreation. + * + * The polish runs when a layout settles: the root gets the crossing/detour + * optimiser, sub-clusters get the simulated-annealing untangle. Both replace + * node positions in place and write them through to the child world circles. + */ +import { murmur3String } from "../../../math/hash"; +import { optimizeTopLevel } from "../../layout/top-level-layout"; +import { untangleLayout } from "../../layout/untangle"; +import { viewportAnchorWeight } from "./viewport-anchor"; + +import type { ClusterId } from "../../../ids"; +import type { ClusterNode } from "../../hierarchy/cluster-tree"; +import type { ViewportState } from "../../hierarchy/lod"; +import type { LayoutSimulation } from "../../layout/force-simulation"; +import type { + Anchor, + LayoutNode, + TopLevelPolishConfig, +} from "../../layout/top-level-layout"; +import type { UntangleConfig, UntangleNode } from "../../layout/untangle"; + +export interface SettlePolisherDependencies { + readonly viewport: () => ViewportState | undefined; + /** Top-level optimiser tuning, including its `maxNodes` skip gate. */ + readonly topLevelPolish: TopLevelPolishConfig; + /** Sub-cluster untangle tuning, including its `maxNodes` skip gate. */ + readonly untangle: UntangleConfig; +} + +/** Write a children layout's local node positions back to child world circles. */ +export function writeChildCircles( + cluster: ClusterNode, + layout: LayoutSimulation, +): void { + const childById = new Map(cluster.children.map((child) => [child.id, child])); + for (const node of layout.nodes) { + // Cluster-layout node ids are ClusterIds drawn from cluster.children + // when the layout was created. + const child = childById.get(node.id as ClusterId); + + if (child) { + child.circle.x = cluster.circle.x + (node.x ?? 0); + child.circle.y = cluster.circle.y + (node.y ?? 0); + } + } +} + +export class SettlePolisher { + readonly #dependencies: SettlePolisherDependencies; + + /** + * Inter-sibling edges as node-index pairs, captured at layout creation + * (d3 forceLink mutates edge endpoints in place, so they cannot be + * recovered later), and the set of cluster layouts already polished (so + * the post-settle polish runs once per layout, not every tick). + */ + readonly #clusterEdges = new Map(); + readonly #polished = new Set(); + + /** + * Last committed local positions of the root's top-level children. + * Persisted across layout recreation and hierarchy rebuilds so the top + * level keeps its arrangement when a cluster is added or removed. + */ + readonly #topLevelPositions = new Map(); + + constructor(dependencies: SettlePolisherDependencies) { + this.#dependencies = dependencies; + } + + /** Record a layout's inter-sibling edges and reset its polished flag. */ + registerLayout(id: ClusterId, edgeIndices: [number, number][]): void { + this.#clusterEdges.set(id, edgeIndices); + this.#polished.delete(id); + } + + isPolished(id: ClusterId): boolean { + return this.#polished.has(id); + } + + /** Drop per-layout state when a layout leaves the committed cut. */ + deleteFor(id: ClusterId): void { + this.#clusterEdges.delete(id); + this.#polished.delete(id); + } + + clusterEdgesOf(id: ClusterId): [number, number][] | undefined { + return this.#clusterEdges.get(id); + } + + /** Reset all per-layout polish state (full layout invalidation). */ + resetLayouts(): void { + this.#clusterEdges.clear(); + this.#polished.clear(); + } + + /** Also forget the persisted top-level positions (regime teardown). */ + resetAll(): void { + this.resetLayouts(); + this.#topLevelPositions.clear(); + } + + topLevelPositionOf(id: ClusterId): { x: number; y: number } | undefined { + return this.#topLevelPositions.get(id); + } + + /** Drop persisted positions for cluster ids absent from the rebuilt tree. */ + pruneTopLevelPositions(exists: (id: ClusterId) => boolean): void { + for (const clusterId of this.#topLevelPositions.keys()) { + if (!exists(clusterId)) { + this.#topLevelPositions.delete(clusterId); + } + } + } + + /** Snapshot the root layout's current local node positions for warm-seeding. */ + snapshotTopLevelPositions(layout: LayoutSimulation): void { + for (const node of layout.nodes) { + this.#topLevelPositions.set(node.id as ClusterId, { + x: node.x ?? 0, + y: node.y ?? 0, + }); + } + } + + /** + * Once-per-layout settle polish: the optimiser for the root, the untangle + * for sub-clusters. Idempotent via {@link #polished}. + */ + polishSettledLayout(cluster: ClusterNode, layout: LayoutSimulation): void { + if (this.#polished.has(cluster.id)) { + return; + } + if (cluster.kind === "root") { + this.#optimizeTopLevelLayout(cluster, layout); + } else { + this.#untangleClusterLayout(cluster, layout); + } + this.#polished.add(cluster.id); + } + + /** + * Top-level pass (root only): replace force-settled positions with the + * layout minimising crossings, detours, edge length, non-overlap, and + * neighbour spread on rim-to-rim segments. See {@link optimizeTopLevel}. + */ + #optimizeTopLevelLayout( + cluster: ClusterNode, + layout: LayoutSimulation, + ): void { + const tuning = this.#dependencies.topLevelPolish; + const edges = this.#clusterEdges.get(cluster.id); + const nodeList = layout.nodes; + if ( + !edges || + edges.length === 0 || + nodeList.length < 3 || + nodeList.length > tuning.maxNodes + ) { + // Too small/large to optimise, but still record positions so a later + // recreation re-seeds from the current layout, not a stale snapshot. + this.snapshotTopLevelPositions(layout); + return; + } + + const nodes: LayoutNode[] = nodeList.map((node) => ({ + x: node.x ?? 0, + y: node.y ?? 0, + radius: node.radius, + })); + + // Anchor each cluster that existed in the previous layout to its persisted + // position (a local refine that keeps the mental map); leave genuinely-new + // clusters unanchored so they're placed freely. The anchor strength falls + // off with distance from the viewport centre (scaled by zoom), so what the + // user is looking at stays put while off-screen bubbles can reflow. See + // {@link optimizeTopLevel} and {@link viewportAnchorWeight}. + const viewport = this.#dependencies.viewport(); + const anchors: (Anchor | null)[] = nodeList.map((node) => { + const previous = this.#topLevelPositions.get(node.id as ClusterId); + if (!previous) { + return null; + } + + const weight = viewport + ? viewportAnchorWeight( + cluster.circle.x + previous.x, + cluster.circle.y + previous.y, + viewport, + tuning.viewportAnchorFloor, + ) + : 1; + + return { x: previous.x, y: previous.y, weight }; + }); + + optimizeTopLevel(nodes, edges, murmur3String(cluster.id), { + anchors, + tuning, + }); + + for (let idx = 0; idx < nodeList.length; idx++) { + nodeList[idx]!.x = nodes[idx]!.x; + nodeList[idx]!.y = nodes[idx]!.y; + } + writeChildCircles(cluster, layout); + + // The optimised positions are the layout the user sees; anchor the next + // incremental refine to them. + this.snapshotTopLevelPositions(layout); + } + + /** + * Polish a settled cluster layout once, minimising edge crossings and + * edges-through-bubbles. Only for small layouts (<= `untangle.maxNodes`). + */ + #untangleClusterLayout(cluster: ClusterNode, layout: LayoutSimulation): void { + const tuning = this.#dependencies.untangle; + const edges = this.#clusterEdges.get(cluster.id); + const nodeList = layout.nodes; + if (!edges || nodeList.length < 3 || nodeList.length > tuning.maxNodes) { + return; + } + + const nodes: UntangleNode[] = nodeList.map((node) => ({ + x: node.x ?? 0, + y: node.y ?? 0, + radius: node.radius, + })); + + untangleLayout(nodes, { + edges, + confinementRadius: + cluster.kind === "root" + ? Number.POSITIVE_INFINITY + : cluster.circle.radius, + seed: murmur3String(cluster.id), + tuning, + }); + + for (let idx = 0; idx < nodeList.length; idx++) { + nodeList[idx]!.x = nodes[idx]!.x; + nodeList[idx]!.y = nodes[idx]!.y; + } + writeChildCircles(cluster, layout); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/tier.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/tier.ts new file mode 100644 index 00000000000..e75b95ce991 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/tier.ts @@ -0,0 +1,381 @@ +/** + * The hierarchical (cluster-tree) tier: LOD-cut computation and the + * structure-commit pipeline that turns a cut into layouts, aggregated + * edges, and emitted frames. + * + * Owns the LOD state and the pinned leaf (the inputs that shape the cut). + * The regime decision itself (flat vs hierarchical) stays with EntityGraphWorker; + * this tier is only entered when the mode is `hierarchical-lod`. + */ +import { ClusterId } from "../../../ids"; +import { CutIndex } from "../../geometry/edge-aggregation"; +import { LodState, computeVisibleCut } from "../../hierarchy/lod"; + +import type { VizConfig } from "../../../config"; +import type { CommittedView, RenderedEntry } from "../../core/committed-view"; +import type { PositionsFrameEmitter } from "../../core/frames/positions-frame"; +import type { StructureFrameEmitter } from "../../core/frames/structure-frame"; +import type { PortCache } from "../../geometry/bubble-ports"; +import type { EdgeAggregator } from "../../geometry/edge-aggregation"; +import type { + ClusterNode, + ClusterTree, + IngestDelta, +} from "../../hierarchy/cluster-tree"; +import type { LodItem, ViewportState } from "../../hierarchy/lod"; +import type { TypeRegistry } from "../../store/type-registry"; +import type { LinkStore } from "../store/link"; +import type { TypeSetStore } from "../store/type-set"; +import type { EmbeddingCoordinator } from "./embedding-coordinator"; +import type { HierarchicalLayoutManager } from "./layouts"; +import type { PortConstraintController } from "./port-constraints"; +import type { SettlePolisher } from "./settle-polish"; + +const ROOT_ID = ClusterId("cluster:root"); + +export interface HierarchicalTierDependencies { + readonly config: VizConfig; + readonly clusterTree: ClusterTree; + readonly view: CommittedView; + readonly links: LinkStore; + readonly typeSets: TypeSetStore; + readonly types: TypeRegistry; + readonly edgeAggregator: EdgeAggregator; + readonly portCache: PortCache; + readonly portConstraints: PortConstraintController; + readonly layoutManager: HierarchicalLayoutManager; + readonly polisher: SettlePolisher; + readonly embedding: EmbeddingCoordinator; + readonly structureEmitter: StructureFrameEmitter; + readonly positionsEmitter: PositionsFrameEmitter; + readonly hierarchicalModeActive: () => boolean; + readonly viewport: () => ViewportState | undefined; + readonly rootFlipPending: () => boolean; + /** Route a cut-triggered commit through EntityGraphWorker.commitStructure (regime dispatch). */ + readonly requestCommit: (opts: { readonly cut: readonly LodItem[] }) => void; +} + +export class HierarchicalTier { + readonly #dependencies: HierarchicalTierDependencies; + + #lodState: LodState = new LodState(); + /** A pinned leaf cluster: kept open (with its ancestors) regardless of zoom, until the + * selection that set it is cleared. Drives {@link #pinnedOpenSet}. */ + #pinnedLeaf: ClusterId | undefined; + + constructor(dependencies: HierarchicalTierDependencies) { + this.#dependencies = dependencies; + } + + get hasClusters(): boolean { + return !this.#dependencies.clusterTree.isEmpty; + } + + /** Recompute the cut for a new viewport and commit if it changed. */ + handleViewport(viewport: ViewportState): void { + if (!this.#dependencies.hierarchicalModeActive() || !this.hasClusters) { + return; + } + + const cut = this.#computeCut(viewport); + if (this.#lodState.wouldChange(cut)) { + // handleViewport already computed cut; pass it through to avoid + // duplicate LOD work. + this.#dependencies.requestCommit({ cut }); + } + } + + /** Pin a leaf cluster open (with its ancestors) regardless of zoom. */ + pin(leafId: ClusterId | undefined): void { + if (this.#pinnedLeaf === leafId) { + return; + } + this.#pinnedLeaf = leafId; + if (!this.#dependencies.hierarchicalModeActive() || !this.hasClusters) { + return; + } + + // No viewport yet; the next commit will honour the pin. + const viewport = this.#dependencies.viewport(); + if (!viewport) { + return; + } + + // pin() mirrors handleViewport's wouldChange guard so identical cuts do + // not re-commit. + const cut = this.#computeCut(viewport); + if (this.#lodState.wouldChange(cut)) { + this.#dependencies.requestCommit({ cut }); + } + } + + /** + * Full rebuild. Used on first ingest or when the incremental + * path can't handle a structural change. + */ + rebuildClusters(): void { + const { view, clusterTree, polisher } = this.#dependencies; + // Full rebuild replaces the entire tree: every layout, the port cache, + // and the aggregation state are built against it and must all reset. + this.#dependencies.layoutManager.destroyAllLayouts(); + this.#dependencies.portCache.clear(); + this.#dependencies.edgeAggregator.reset(); + view.clearTopology(); + + clusterTree.rebuild( + this.#dependencies.typeSets, + this.#dependencies.types, + this.#dependencies.config, + ); + view.clusterEpoch += 1; + + // Drop warm-seed entries for cluster ids absent from the rebuilt tree so + // the map does not grow monotonically across source evolutions. + polisher.pruneTopLevelPositions( + (clusterId) => clusterTree.get(clusterId) !== undefined, + ); + } + + /** + * Incremental update. Applies deltas from an ingest batch + * to the existing cluster tree. + */ + updateClusters(deltas: readonly IngestDelta[]): void { + this.#dependencies.clusterTree.updateIncrementally( + deltas, + this.#dependencies.typeSets, + this.#dependencies.types, + this.#dependencies.config, + ); + this.#dependencies.view.clusterEpoch += 1; + } + + /** Destroy all hierarchical layouts and reset render/edge state. */ + tearDown(): void { + this.#dependencies.layoutManager.destroyAllLayouts(); + this.#dependencies.polisher.resetAll(); + this.#dependencies.portCache.clear(); + this.#dependencies.edgeAggregator.reset(); + this.#dependencies.view.clearTopology(); + this.#dependencies.view.clearRendered(); + } + + /** + * Commit the hierarchical topology: mutate the tree if needed, compute the + * visible cut, create/destroy layouts, recompute edge aggregation, and emit + * a StructureFrame plus an initial PositionsFrame. + * + * `wasActive` is whether the previous committed regime was hierarchical + * (a re-entry from a flat tier forces a tree rebuild). + */ + commit( + wasActive: boolean, + opts?: { + readonly deltas?: readonly IngestDelta[]; + readonly rebuildTree?: boolean; + /** Precomputed visible cut; ignored when the tree was mutated by this commit. */ + readonly cut?: readonly LodItem[]; + }, + ): void { + const { view, clusterTree, layoutManager, structureEmitter } = + this.#dependencies; + + // Rebuild the tree on first build, re-entry, or type changes; + // otherwise apply incremental deltas. Either mutates the tree, which + // invalidates any cut the caller precomputed against the old tree. + let treeMutated = false; + if (opts?.rebuildTree || !wasActive || !this.hasClusters) { + this.rebuildClusters(); + treeMutated = true; + } else if (opts?.deltas && opts.deltas.length > 0) { + this.updateClusters(opts.deltas); + treeMutated = true; + } + + if (!this.hasClusters) { + view.clearRendered(); + view.clearTopology(); + structureEmitter.emit([]); + this.#dependencies.positionsEmitter.emit(); + return; + } + + const activeLayouts = new Set(); + + // The macro layout (top-level clusters) always exists; it seeds and settles + // the bubble positions that everything else hangs off of. + if (clusterTree.root.children.length > 0) { + layoutManager.ensureChildrenLayout(clusterTree.root); + activeLayouts.add(ROOT_ID); + } + + const rendered: RenderedEntry[] = []; + + const viewport = this.#dependencies.viewport(); + if (!viewport) { + // Before the first viewport: show the top-level bubbles, no edges. + for (const child of clusterTree.root.children) { + rendered.push({ node: child, depth: 0 }); + } + layoutManager.commitRendered(rendered, activeLayouts); + view.clearTopology(); + structureEmitter.emit([]); + this.#dependencies.positionsEmitter.emit(); + return; + } + + // Reuse the caller's precomputed cut when the tree wasn't mutated + // (a rebuild/incremental update invalidates it). + const cut = + opts?.cut && !treeMutated ? opts.cut : this.#computeCut(viewport); + + // No-op fast path: if tree, links, root status, and cut are all unchanged + // since the last emit, the derived state would be identical. + if ( + view.cutIndex !== undefined && + view.clusterEpoch === view.committedClusterEpoch && + this.#dependencies.links.count === view.committedLinkCount && + !this.#dependencies.rootFlipPending() && + !this.#lodState.wouldChange(cut) + ) { + return; + } + + this.#lodState.applyVisibleCut(cut); + + const openIds = new Set(); + for (const item of cut) { + if (item.mode !== "cluster") { + openIds.add(item.clusterId); + } + } + + const depthOf = (id: ClusterId): number => { + let depth = 0; + let node = clusterTree.get(id); + + while (node?.parent) { + if (openIds.has(node.parent.id)) { + depth++; + } + node = node.parent; + } + + return depth; + }; + + for (const item of cut) { + if (item.mode === "cluster") { + if (openIds.has(item.clusterId)) { + continue; + } + + const cluster = clusterTree.get(item.clusterId); + if (cluster) { + rendered.push({ node: cluster, depth: 0 }); + } + } else if (item.mode === "children") { + const parent = clusterTree.get(item.clusterId); + + if (parent) { + rendered.push({ node: parent, depth: depthOf(item.clusterId) + 1 }); + layoutManager.ensureChildrenLayout(parent); + activeLayouts.add(parent.id); + for (const child of parent.children) { + if (!openIds.has(child.id)) { + rendered.push({ node: child, depth: 0 }); + } + } + } + } else { + // "entities" / "entities-pending": leaf becomes a container of dots. + const cluster = clusterTree.get(item.clusterId); + if (cluster) { + rendered.push({ node: cluster, depth: depthOf(item.clusterId) + 1 }); + layoutManager.ensureEntityLayout(cluster); + activeLayouts.add(cluster.id); + } + } + } + + layoutManager.commitRendered(rendered, activeLayouts); + + // Cache CutIndex + EdgeFrame on the committed view so position-only + // ticks skip recomputation. + const cutIndex = new CutIndex( + cut, + clusterTree, + this.#dependencies.typeSets, + ); + view.cutIndex = cutIndex; + view.edgeFrame = this.#dependencies.edgeAggregator.update( + cutIndex, + this.#dependencies.links, + this.#dependencies.typeSets, + this.#dependencies.types, + this.#dependencies.config, + ); + + // Ports as constraints: pull each opened container's children toward the + // external neighbours they connect to (fixed rim anchors + child links). + this.#dependencies.portConstraints.applyPortConstraints( + view.edgeFrame, + view.cutIndex, + ); + + structureEmitter.emit(structureEmitter.buildEntityLayers(cutIndex)); + this.#dependencies.positionsEmitter.emit(); + + // Record epoch + link count so the next commit can bail out when tree, + // links, and cut are unchanged. + view.committedClusterEpoch = view.clusterEpoch; + view.committedLinkCount = this.#dependencies.links.count; + } + + #computeCut(viewport: ViewportState): readonly LodItem[] { + return computeVisibleCut( + this.#dependencies.clusterTree, + ROOT_ID, + viewport, + this.#lodState, + this.#dependencies.config, + (node) => this.#trySubdivide(node), + this.#pinnedOpenSet(), + ); + } + + #trySubdivide(node: ClusterNode): boolean { + const subdivided = this.#dependencies.clusterTree.ensureSubclusters( + node, + this.#dependencies.typeSets, + this.#dependencies.links, + this.#dependencies.config, + ); + + if (!subdivided) { + return false; + } + this.#dependencies.view.clusterEpoch += 1; + this.#dependencies.embedding.afterSubdivide(node); + + return true; + } + + // The pinned leaf plus all its ancestors (the path the cut must keep open), or empty. + #pinnedOpenSet(): ReadonlySet { + const set = new Set(); + if (this.#pinnedLeaf === undefined) { + return set; + } + + let node: ClusterNode | null = + this.#dependencies.clusterTree.get(this.#pinnedLeaf) ?? null; + + while (node) { + set.add(node.id); + node = node.parent; + } + + return set; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/viewport-anchor.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/viewport-anchor.test.ts new file mode 100644 index 00000000000..20440f8819c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/viewport-anchor.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { VIEWPORT_ANCHOR_FLOOR, viewportAnchorWeight } from "./viewport-anchor"; + +import type { ViewportState } from "../../hierarchy/lod"; + +/** A 1000x1000 viewport centred on the origin at the given zoom. */ +const viewportAt = (zoom: number): ViewportState => ({ + zoom, + centerX: 0, + centerY: 0, + width: 1000, + height: 1000, +}); + +describe("viewportAnchorWeight", () => { + it("pins the viewport centre (~1) and frees the far field (~floor)", () => { + const viewport = viewportAt(0); + expect(viewportAnchorWeight(0, 0, viewport)).toBeCloseTo(1, 5); + expect(viewportAnchorWeight(100_000, 0, viewport)).toBeCloseTo( + VIEWPORT_ANCHOR_FLOOR, + 5, + ); + }); + + it("decreases monotonically with distance from the centre", () => { + const viewport = viewportAt(2); + const w0 = viewportAnchorWeight(0, 0, viewport); + const w1 = viewportAnchorWeight(50, 0, viewport); + const w2 = viewportAnchorWeight(150, 0, viewport); + const w3 = viewportAnchorWeight(400, 0, viewport); + expect(w0).toBeGreaterThan(w1); + expect(w1).toBeGreaterThan(w2); + expect(w2).toBeGreaterThan(w3); + }); + + it("strengthens the centre pin as you zoom in", () => { + // Same centred bubble at increasing zoom must pin harder so + // screen-space movement stays bounded. + const atZoom0 = viewportAnchorWeight(0, 0, viewportAt(0)); + const atZoom4 = viewportAnchorWeight(0, 0, viewportAt(4)); + expect(atZoom4).toBeGreaterThan(atZoom0 * 8); + }); + + it("holds the centre's SCREEN movement ~constant across zoom-in", () => { + // World wobble ~ 1/weight, screen wobble ~ scale/weight. With zoom + // amplification the screen-movement proxy stays ~flat; a weight cap at 1 + // would let it grow with scale (1, 4, 16, ...). Assert it barely varies. + const proxies = [0, 1, 2, 3, 4, 5].map((zoom) => { + const scale = 2 ** zoom; + return scale / viewportAnchorWeight(0, 0, viewportAt(zoom)); + }); + const max = Math.max(...proxies); + const min = Math.min(...proxies); + expect(max / min).toBeLessThan(1.1); + }); + + it("does NOT amplify off-screen bubbles when zoomed in", () => { + // A bubble far outside the view stays at the floor regardless of zoom, so it + // remains free to reflow where the user can't see it. + const far = 100_000; + expect(viewportAnchorWeight(far, 0, viewportAt(0))).toBeCloseTo( + VIEWPORT_ANCHOR_FLOOR, + 5, + ); + expect(viewportAnchorWeight(far, 0, viewportAt(5))).toBeCloseTo( + VIEWPORT_ANCHOR_FLOOR, + 5, + ); + }); + + it("keeps the baseline when zoomed OUT (no de-pinning below 1x)", () => { + // Zooming out must not loosen the anchor below its zoom-0 baseline. + const centreZoom0 = viewportAnchorWeight(0, 0, viewportAt(0)); + const centreZoomOut = viewportAnchorWeight(0, 0, viewportAt(-3)); + expect(centreZoomOut).toBeCloseTo(centreZoom0, 5); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/viewport-anchor.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/viewport-anchor.ts new file mode 100644 index 00000000000..a148cb9e126 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/hierarchical/viewport-anchor.ts @@ -0,0 +1,45 @@ +/** + * How strongly a top-level bubble resists moving during an incremental refine. + * + * Centrality: weight is ~1 at viewport centre, decaying (Gaussian) to + * {@link VIEWPORT_ANCHOR_FLOOR} once a bubble is roughly off-screen. The + * falloff radius is the visible half-diagonal in world units, so it scales + * with zoom. + * + * Zoom amplification: inertia is penalised in world units, but the user + * perceives screen units. A world-space wobble is magnified by `scale` + * when zoomed in. The on-screen pin is amplified by `scale` (clamped >= 1 + * so zooming out keeps the baseline), applied only to the centrality term + * so off-screen bubbles remain free to reflow. + */ + +import type { ViewportState } from "../../hierarchy/lod"; + +/** Off-screen bubbles keep this much anchor: they reflow but don't teleport while + * the user isn't looking. Also the floor of {@link viewportAnchorWeight}. + * The live value comes from `topLevelPolish.viewportAnchorFloor`. */ +export const VIEWPORT_ANCHOR_FLOOR = 0.05; + +export function viewportAnchorWeight( + worldX: number, + worldY: number, + viewport: ViewportState, + floor: number = VIEWPORT_ANCHOR_FLOOR, +): number { + const scale = 2 ** viewport.zoom; + const visibleRadius = + Math.hypot(viewport.width, viewport.height) / 2 / Math.max(scale, 1e-6); + if (!(visibleRadius > 0)) { + return 1; + } + const distance = Math.hypot( + worldX - viewport.centerX, + worldY - viewport.centerY, + ); + const falloff = Math.exp(-((distance / visibleRadius) ** 2)); + + // Amplify only the on-screen (high-falloff) term by the zoom, never below 1×. + // Off-screen bubbles have falloff ≈ 0, so they stay at the floor at any zoom. + const zoomStrength = Math.max(1, scale); + return floor + (1 - floor) * falloff * zoomStrength; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/ingest.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/ingest.ts new file mode 100644 index 00000000000..fd97c20ab24 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/ingest.ts @@ -0,0 +1,260 @@ +import { Column } from "../collections/column"; +import { ReadonlySortedSet } from "../collections/readonly-sorted-set"; + +import type { EntityIndex, TypeSetKey } from "../../ids"; +import type { IngestDelta } from "../hierarchy/cluster-tree"; +import type { + IngestEntity, + PropertySchemaEntry, + TypeSchemaEntry, +} from "../protocol"; +import type { TypeRegistry } from "../store/type-registry"; +import type { EntityStore } from "./store/entity"; +import type { LinkStore } from "./store/link"; +import type { PropertyStore } from "./store/property"; +import type { TypeSetGroup, TypeSetStore } from "./store/type-set"; +import type { EntityId } from "@blockprotocol/type-system"; + +/** Initial capacity for the node-index column (grows by doubling). */ +const NODE_IDX_CAPACITY = 4096; + +export interface IngestStores { + readonly entities: EntityStore; + readonly links: LinkStore; + readonly properties: PropertyStore; + readonly types: TypeRegistry; + readonly typeSets: TypeSetStore; +} + +/** + * Writes ingested entities and links into the stores, tracking the node set + * (link entities are interned but are not nodes) and pending-link resolution. + * + * This is the only writer of the stores; every other collaborator reads them. + */ +export class IngestController { + readonly #stores: IngestStores; + + /** Loaded node entities (excludes interned links). */ + #nodeEntityCount = 0; + + /** + * Node entity indices, always sorted ascending. Interner indices are + * monotonic, so appending on insert preserves the sort invariant. + */ + readonly #nodeEntityIdxs = new Column( + Int32Array, + NODE_IDX_CAPACITY, + ); + + /** Set when an expand flips a rendered frontier node to a root; triggers a restyle. */ + #rootFlipPending = false; + + constructor(stores: IngestStores) { + this.#stores = stores; + } + + /** Node entity count (excludes link entities interned by the EntityStore). */ + get nodeCount(): number { + return this.#nodeEntityCount; + } + + get rootFlipPending(): boolean { + return this.#rootFlipPending; + } + + /** Consume the pending root-flip signal. Returns whether one was pending. */ + consumeRootFlip(): boolean { + const pending = this.#rootFlipPending; + this.#rootFlipPending = false; + return pending; + } + + /** Materialise the packed node-index column into a plain array. */ + snapshotNodeEntityIdxs(): EntityIndex[] { + return [...this.#nodeEntityIdxs]; + } + + /** + * Register type and property schemas. Returns what changed so the caller + * can decide whether a commit is needed. + */ + registerTypes( + schemas: readonly TypeSchemaEntry[], + propertySchemas: readonly PropertySchemaEntry[], + ): { + readonly typesChanged: boolean; + readonly propertyTitlesChanged: boolean; + } { + const typesChanged = this.#stores.types.registerAll(schemas); + const propertyTitlesChanged = + this.#stores.properties.registerTitles(propertySchemas); + + return { typesChanged, propertyTitlesChanged }; + } + + /** + * Insert a node entity. Returns undefined if duplicate. + * + * `knownGroup` skips re-resolving the type-set group when the caller has + * already peeked it for this entity (see {@link ingestBatch}); it must be + * the group {@link #peekGroup} returned for the same entity. + */ + insertNodeEntity( + entity: IngestEntity, + knownGroup?: TypeSetGroup, + ): { entityIdx: EntityIndex; groupKey: TypeSetKey } | undefined { + const { entities, types, typeSets, properties } = this.#stores; + const [created, entityIdx] = entities.insert(entity.entityId); + + // Apply root-ness even for an already-interned entity: an expand re-sends a frontier node as a + // root, and this is what flips it. A flip of an already-rendered node needs a restyle the + // commit alone won't do in the hierarchical tier (see EntityGraphWorker.restyleIfRootsFlipped). + if (entity.isRoot) { + const flippedExisting = entities.insertRoot(entityIdx) && !created; + if (flippedExisting) { + this.#rootFlipPending = true; + } + } + + if (!created) { + return; + } + + this.#nodeEntityCount += 1; + // Interner indices are monotonic, so this stays sorted (see #nodeEntityIdxs). + this.#nodeEntityIdxs.push(entityIdx); + + const group = + knownGroup ?? + typeSets.getOrCreate( + new ReadonlySortedSet( + entity.entityTypeIds.map((url) => types.intern(url)), + (lhs, rhs) => lhs - rhs, + ), + types.size, + ); + + group.addEntity(entityIdx); + entities.setTypeSet(entityIdx, group.id); + // Reduce the entity's properties to its interned features now, while ingesting, so a + // later cluster-naming pass just tallies integers (see {@link PropertyStore}). + properties.ingest(entityIdx, entity.properties); + this.#resolvePendingLinks(entity.entityId, entityIdx); + + return { entityIdx, groupKey: group.key }; + } + + insertLinkEntity(entity: IngestEntity): void { + if (!entity.linkData) { + return; + } + + const { entities, links, types, typeSets } = this.#stores; + const [created, linkEntityIdx] = entities.insert(entity.entityId); + if (!created) { + return; + } + + const leftIdx = entities.lookup(entity.linkData.leftEntityId) ?? -1; + const rightIdx = entities.lookup(entity.linkData.rightEntityId) ?? -1; + + const linkTypeIdxs = new ReadonlySortedSet( + entity.entityTypeIds.map((url) => types.intern(url)), + (lhs, rhs) => lhs - rhs, + ); + const linkGroup = typeSets.getOrCreate(linkTypeIdxs, types.size); + + const linkId = links.insert(leftIdx, rightIdx, linkGroup.id, linkEntityIdx); + + if (leftIdx === -1) { + links.addPending(entity.linkData.leftEntityId, linkId, "left"); + } + if (rightIdx === -1) { + links.addPending(entity.linkData.rightEntityId, linkId, "right"); + } + } + + /** + * Ingest a batch of entities, returning per-group deltas + * for the incremental update path. + */ + ingestBatch(entities: readonly IngestEntity[]): IngestDelta[] { + const groupSnapshots = new Map< + TypeSetKey, + { before: number; isNew: boolean } + >(); + const links: IngestEntity[] = []; + + for (const entity of entities) { + if (entity.isLink) { + links.push(entity); + continue; + } + + // Snapshot each group's count once per batch so deltas stay correct + // when multiple entities share a group; pass the peeked group to + // avoid a second type-set lookup. + const group = this.#peekGroup(entity); + if (group && !groupSnapshots.has(group.key)) { + groupSnapshots.set(group.key, { + before: group.count, + isNew: group.count === 0, + }); + } + + this.insertNodeEntity(entity, group); + } + + for (const entity of links) { + this.insertLinkEntity(entity); + } + + const deltas: IngestDelta[] = []; + for (const [groupKey, { before, isNew }] of groupSnapshots) { + // groupKey came from groupSnapshots populated only for groups touched + // in this batch; the group must still exist. + const group = this.#stores.typeSets.get(groupKey)!; + const delta = group.count - before; + if (delta > 0) { + deltas.push({ + groupKey, + delta, + isNewGroup: isNew, + previousCount: before, + }); + } + } + + return deltas; + } + + /** Peek at which group an entity would land in without inserting. */ + #peekGroup(entity: IngestEntity): TypeSetGroup | undefined { + const { entities, types, typeSets } = this.#stores; + if (entities.lookup(entity.entityId) !== undefined) { + return undefined; // Already inserted, skip. + } + + const directTypeIdxs = new ReadonlySortedSet( + entity.entityTypeIds.map((url) => types.intern(url)), + (lhs, rhs) => lhs - rhs, + ); + + return typeSets.getOrCreate(directTypeIdxs, types.size); + } + + #resolvePendingLinks(entityId: EntityId, entityIdx: EntityIndex): void { + const pending = this.#stores.links.takePending(entityId); + if (!pending) { + return; + } + + // Each pending record names the exact side that referenced this entity. + // Resolving both sides here would rewrite the already-known endpoint to + // this entity, corrupting the link into a self-loop (A->B becoming B->B). + for (const { linkId, side } of pending) { + this.#stores.links.resolveEndpoint(linkId, side, entityIdx); + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/mode-policy.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/mode-policy.test.ts new file mode 100644 index 00000000000..aa3f101b0c0 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/mode-policy.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { defaultVizConfig } from "../../config"; +import { nextVizMode } from "./mode-policy"; + +// Thresholds under test (from defaultVizConfig): flat exits above 250, +// re-enters below 200; community exits above 5000, re-enters below 4000. +const config = defaultVizConfig; + +describe("nextVizMode", () => { + it("keeps flat-force up to its exit threshold", () => { + expect(nextVizMode("flat-force", config.flatLayoutExitNodes, config)).toBe( + "flat-force", + ); + }); + + it("promotes flat-force to community-force just past the exit threshold", () => { + expect( + nextVizMode("flat-force", config.flatLayoutExitNodes + 1, config), + ).toBe("community-force"); + }); + + it("jumps flat-force straight to hierarchical-lod when the count clears both thresholds", () => { + expect( + nextVizMode("flat-force", config.communityColorExitNodes + 1, config), + ).toBe("hierarchical-lod"); + }); + + it("promotes community-force to hierarchical-lod just past its exit threshold", () => { + expect( + nextVizMode( + "community-force", + config.communityColorExitNodes + 1, + config, + ), + ).toBe("hierarchical-lod"); + expect( + nextVizMode("community-force", config.communityColorExitNodes, config), + ).toBe("community-force"); + }); + + it("demotes community-force to flat-force only below the re-entry threshold", () => { + expect( + nextVizMode("community-force", config.flatLayoutMaxNodes - 1, config), + ).toBe("flat-force"); + expect( + nextVizMode("community-force", config.flatLayoutMaxNodes, config), + ).toBe("community-force"); + }); + + it("demotes hierarchical-lod to community-force only below the re-entry threshold", () => { + expect( + nextVizMode( + "hierarchical-lod", + config.communityColorMaxNodes - 1, + config, + ), + ).toBe("community-force"); + expect( + nextVizMode("hierarchical-lod", config.communityColorMaxNodes, config), + ).toBe("hierarchical-lod"); + }); + + it("is hysteretic: both regimes are stable inside the flat boundary band", () => { + for ( + let nodeCount = config.flatLayoutMaxNodes; + nodeCount <= config.flatLayoutExitNodes; + nodeCount++ + ) { + expect(nextVizMode("flat-force", nodeCount, config)).toBe("flat-force"); + expect(nextVizMode("community-force", nodeCount, config)).toBe( + "community-force", + ); + } + }); + + it("is hysteretic: both regimes are stable inside the community boundary band", () => { + for ( + let nodeCount = config.communityColorMaxNodes; + nodeCount <= config.communityColorExitNodes; + nodeCount += 100 + ) { + expect(nextVizMode("community-force", nodeCount, config)).toBe( + "community-force", + ); + expect(nextVizMode("hierarchical-lod", nodeCount, config)).toBe( + "hierarchical-lod", + ); + } + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/mode-policy.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/mode-policy.ts new file mode 100644 index 00000000000..5a1772e37a3 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/mode-policy.ts @@ -0,0 +1,43 @@ +import type { VizConfig } from "../../config"; +import type { VizMode } from "../../ids"; + +/** + * The rendering-regime state machine: which tier (flat-force, + * community-force, hierarchical-lod) should be active for a given node count. + * + * Transitions are hysteretic: the exit threshold upward (`*ExitNodes`) is + * higher than the re-entry threshold downward (`*MaxNodes`), so a graph + * hovering around a boundary doesn't flip-flop between regimes on every + * ingest batch. + */ +export function nextVizMode( + mode: VizMode, + nodeCount: number, + config: VizConfig, +): VizMode { + if (mode === "flat-force" && nodeCount > config.flatLayoutExitNodes) { + return nodeCount > config.communityColorExitNodes + ? "hierarchical-lod" + : "community-force"; + } + + if ( + mode === "community-force" && + nodeCount > config.communityColorExitNodes + ) { + return "hierarchical-lod"; + } + + if (mode === "community-force" && nodeCount < config.flatLayoutMaxNodes) { + return "flat-force"; + } + + if ( + mode === "hierarchical-lod" && + nodeCount < config.communityColorMaxNodes + ) { + return "community-force"; + } + + return mode; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/entity.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/entity.test.ts new file mode 100644 index 00000000000..ce5538f2226 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/entity.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { entityIdFromComponents } from "@blockprotocol/type-system"; + +import { decodeEntityId, ID_HEADER_BYTES } from "../../entity-id-codec"; +import { EntityStore } from "./entity"; + +import type { EntityUuid, WebId } from "@blockprotocol/type-system"; + +const webId = "11111111-1111-4111-8111-111111111111" as WebId; + +const entityIdFor = (index: number) => + entityIdFromComponents( + webId, + `22222222-2222-4222-8222-${index.toString(16).padStart(12, "0")}` as EntityUuid, + ); + +const readMappedId = (store: EntityStore, idx: number) => + decodeEntityId(new Uint8Array(store.lookupBuffer.raw, ID_HEADER_BYTES), idx); + +describe("EntityStore join map", () => { + it("writes each EntityId into the map the instant it is interned", () => { + const store = new EntityStore(); + const idA = entityIdFor(1); + const idB = entityIdFor(2); + + const [createdA, idxA] = store.insert(idA); + const [createdB, idxB] = store.insert(idB); + + expect(createdA).toBe(true); + expect(createdB).toBe(true); + expect(idxB).toBe(idxA + 1); + expect(readMappedId(store, idxA)).toBe(idA); + expect(readMappedId(store, idxB)).toBe(idB); + }); + + it("re-interning an EntityId returns the same idx, map untouched", () => { + const store = new EntityStore(); + const idA = entityIdFor(1); + const [, idx] = store.insert(idA); + + const [createdAgain, idxAgain] = store.insert(idA); + + expect(createdAgain).toBe(false); + expect(idxAgain).toBe(idx); + expect(readMappedId(store, idx)).toBe(idA); + }); + + it("grows the map past its initial capacity, preserving earlier entries", () => { + const store = new EntityStore(() => {}); + const first = entityIdFor(0); + store.insert(first); + + let lastId = first; + let lastIdx = 0; + for (let index = 1; index <= 5000; index++) { + lastId = entityIdFor(index); + const [, idx] = store.insert(lastId); + lastIdx = idx; + } + + expect(store.size).toBe(5001); + expect(store.lookupBuffer.capacity).toBeGreaterThanOrEqual(5001); + expect(readMappedId(store, 0)).toBe(first); + expect(readMappedId(store, lastIdx)).toBe(lastId); + }); +}); + +describe("EntityStore roots", () => { + it("treats a freshly-interned entity as a frontier node (not a root)", () => { + const store = new EntityStore(); + const [, idx] = store.insert(entityIdFor(1)); + expect(store.isRoot(idx)).toBe(false); + }); + + it("promotes an entity to a root, reporting the flip only once", () => { + const store = new EntityStore(); + const [, idx] = store.insert(entityIdFor(1)); + + expect(store.insertRoot(idx)).toBe(true); + expect(store.isRoot(idx)).toBe(true); + expect(store.insertRoot(idx)).toBe(false); + expect(store.isRoot(idx)).toBe(true); + }); + + it("tracks root-ness independently per entity, including past the initial capacity", () => { + const store = new EntityStore(() => {}); + const [, idxA] = store.insert(entityIdFor(1)); + + let highIdx = idxA; + for (let index = 2; index <= 5000; index++) { + const [, idx] = store.insert(entityIdFor(index)); + highIdx = idx; + } + + expect(store.insertRoot(highIdx)).toBe(true); + expect(store.isRoot(highIdx)).toBe(true); + expect(store.isRoot(idxA)).toBe(false); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/entity.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/entity.ts new file mode 100644 index 00000000000..976a99c4536 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/entity.ts @@ -0,0 +1,108 @@ +/** + * Entity interning and per-entity columnar storage. + * + * Column indices are kept in sync with the interner: each new entity + * gets a push on every column, so {@link EntityIndex} indexes into all + * of them. + */ +import { EntityIdBuffer } from "../../buffers/entity-id-buffer"; +import { BitSet } from "../../collections/bitset"; +import { Column } from "../../collections/column"; +import { Interner } from "../../collections/interner"; + +import type { EntityIndex, LabelId, TypeSetId } from "../../../ids"; +import type { RepublishHandler } from "../../buffers/growable-buffer"; +import type { EntityId } from "@blockprotocol/type-system"; + +// Initial column capacity (4096); growable buffers reallocate on republish +// when exceeded. +const INITIAL_CAPACITY = 4096; + +export class EntityStore { + readonly #interner: Interner; + readonly #type: Column; + readonly #label: Column; + /** Query roots. Add-only: roots only grow as the frontier expands. */ + readonly #root: BitSet; + readonly #lookup: EntityIdBuffer; + + constructor(republish?: RepublishHandler) { + this.#interner = new Interner(); + + this.#type = new Column(Int32Array, INITIAL_CAPACITY); + this.#label = new Column(Int32Array, INITIAL_CAPACITY); + this.#root = BitSet.empty(INITIAL_CAPACITY); + this.#lookup = new EntityIdBuffer(INITIAL_CAPACITY, republish); + } + + get size(): number { + return this.#interner.size; + } + + /** + * Shared lookup buffer mapping each {@link EntityIndex} to its + * {@link EntityId}; worker writes on insert, main thread reads for UI + * joins. + */ + get lookupBuffer(): EntityIdBuffer { + return this.#lookup; + } + + /** + * Interns an entity id, appends aligned type/label columns when new, and + * writes the lookup buffer. Returns `[created, index]`. + */ + insert(entityId: EntityId): [created: boolean, index: EntityIndex] { + const [created, index] = this.#interner.tryIntern(entityId); + + if (!created) { + return [false, index]; + } + + this.#lookup.ensureCapacity(index + 1); + this.#lookup.setId(index, entityId); + this.#type.push(-1); + this.#label.push(-1); + + return [true, index]; + } + + lookup(entityId: EntityId): EntityIndex | undefined { + return this.#interner.tryGet(entityId); + } + + get(index: EntityIndex): EntityId | undefined { + return this.#interner.getValue(index); + } + + setTypeSet(index: EntityIndex, type: TypeSetId): void { + this.#type.set(index, type); + } + + /** The entity's type-set group, or -1 if unassigned. */ + getTypeSet(index: EntityIndex): TypeSetId | -1 { + return this.#type.get(index); + } + + setLabel(index: EntityIndex, label: LabelId): void { + this.#label.set(index, label); + } + + getLabel(index: EntityIndex): LabelId | -1 { + return this.#label.get(index); + } + + isRoot(index: EntityIndex): boolean { + return this.#root.has(index); + } + + /** Promote to root. Returns whether the entity was frontier before promotion. */ + insertRoot(index: EntityIndex): boolean { + if (this.#root.has(index)) { + return false; + } + + this.#root.add(index); + return true; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/link.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/link.test.ts new file mode 100644 index 00000000000..ab7e2706b8d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/link.test.ts @@ -0,0 +1,96 @@ +/** + * Pending-endpoint resolution guards for {@link LinkStore}: a link whose + * endpoint entity arrives after the link itself must end up with exactly the + * endpoints it declared -- resolving must never rewrite the side that was + * already known (the A->B becomes B->B corruption), and adjacency must count + * each incident link once. + */ + +import { describe, expect, it } from "vitest"; + +import { entityIdFromComponents } from "@blockprotocol/type-system"; + +import { EntityIndex, TypeSetId } from "../../../ids"; +import { LinkStore } from "./link"; + +import type { EntityUuid, WebId } from "@blockprotocol/type-system"; + +const webId = "11111111-1111-4111-8111-111111111111" as WebId; + +const entityIdFor = (index: number) => + entityIdFromComponents( + webId, + `22222222-2222-4222-8222-${index.toString(16).padStart(12, "0")}` as EntityUuid, + ); + +const TYPE = TypeSetId(0); + +describe("LinkStore pending-endpoint resolution", () => { + it("fills only the missing side when one endpoint arrives late", () => { + const store = new LinkStore(); + const left = EntityIndex(10); + const lateRightId = entityIdFor(2); + + const linkId = store.insert(left, -1, TYPE, EntityIndex(100)); + store.addPending(lateRightId, linkId, "right"); + + const pending = store.takePending(lateRightId)!; + expect(pending).toEqual([{ linkId, side: "right" }]); + + const right = EntityIndex(20); + store.resolveEndpoint(linkId, pending[0]!.side, right); + + expect(store.getLeft(linkId)).toBe(left); + expect(store.getRight(linkId)).toBe(right); + expect(store.degreeOf(left)).toBe(1); + expect(store.degreeOf(right)).toBe(1); + }); + + it("resolves each side independently when both endpoints arrive late", () => { + const store = new LinkStore(); + const leftId = entityIdFor(1); + const rightId = entityIdFor(2); + + const linkId = store.insert(-1, -1, TYPE, EntityIndex(100)); + store.addPending(leftId, linkId, "left"); + store.addPending(rightId, linkId, "right"); + + const left = EntityIndex(10); + for (const { side } of store.takePending(leftId)!) { + store.resolveEndpoint(linkId, side, left); + } + // Half-resolved: the right side must still be pending, not aliased to left. + expect(store.getLeft(linkId)).toBe(left); + expect(store.getRight(linkId)).toBe(-1); + + const right = EntityIndex(20); + for (const { side } of store.takePending(rightId)!) { + store.resolveEndpoint(linkId, side, right); + } + expect(store.getLeft(linkId)).toBe(left); + expect(store.getRight(linkId)).toBe(right); + expect(store.degreeOf(left)).toBe(1); + expect(store.degreeOf(right)).toBe(1); + + const fromLeft = [...store.linksFor(left)]; + expect(fromLeft).toEqual([ + { linkId, otherId: right, typeSetId: TYPE, direction: "out" }, + ]); + const fromRight = [...store.linksFor(right)]; + expect(fromRight).toEqual([ + { linkId, otherId: left, typeSetId: TYPE, direction: "in" }, + ]); + }); + + it("logs resolutions for incremental consumers and clears on drain", () => { + const store = new LinkStore(); + const rightId = entityIdFor(2); + + const linkId = store.insert(EntityIndex(1), -1, TYPE, EntityIndex(100)); + store.addPending(rightId, linkId, "right"); + store.resolveEndpoint(linkId, "right", EntityIndex(20)); + + expect(store.drainResolvedEndpoints()).toEqual([{ linkId, side: "right" }]); + expect(store.drainResolvedEndpoints()).toEqual([]); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/link.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/link.ts new file mode 100644 index 00000000000..f4a937ca7df --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/link.ts @@ -0,0 +1,200 @@ +/** + * Link storage: columnar arrays for endpoints, link type, and entity + * index, plus an adjacency index and a pending-endpoint queue for + * frontier resolution. + */ +import { LinkId } from "../../../ids"; +import { Column } from "../../collections/column"; + +import type { EntityIndex, TypeSetId } from "../../../ids"; +import type { EntityId } from "@blockprotocol/type-system"; + +// Initial link column capacity (1024); columns grow implicitly on push. +const INITIAL_CAPACITY = 1024; + +export interface LinkEndpoint { + readonly linkId: LinkId; + readonly otherId: EntityIndex; + readonly typeSetId: TypeSetId; + readonly direction: "out" | "in"; +} + +/** A link endpoint awaiting the arrival of the entity it references. */ +export interface PendingEndpoint { + readonly linkId: LinkId; + readonly side: "left" | "right"; +} + +/** + * An endpoint that flipped from `-1` to a real index since the last + * {@link LinkStore.drainResolvedEndpoints}. Consumers that cache per-link + * derived state (the edge aggregator) need the exact side to reconstruct + * the values the link carried when they last saw it. + */ +export interface ResolvedEndpoint { + readonly linkId: LinkId; + readonly side: "left" | "right"; +} + +export class LinkStore { + readonly #left: Column; + readonly #right: Column; + readonly #type: Column; + readonly #entity: Column; + readonly #pending: Map; + readonly #adjacency: Map; + #resolvedLog: ResolvedEndpoint[] = []; + + constructor() { + this.#left = new Column(Int32Array, INITIAL_CAPACITY); + this.#right = new Column(Int32Array, INITIAL_CAPACITY); + this.#type = new Column(Uint32Array, INITIAL_CAPACITY); + this.#entity = new Column(Uint32Array, INITIAL_CAPACITY); + this.#pending = new Map(); + this.#adjacency = new Map(); + } + + get count(): number { + return this.#left.length; + } + + /** + * Record a link. Endpoints are -1 when the target entity hasn't been + * ingested yet (frontier case). + */ + insert( + left: EntityIndex | -1, + right: EntityIndex | -1, + typeSetId: TypeSetId, + entityIndex: EntityIndex, + ): LinkId { + this.#left.push(left); + this.#right.push(right); + this.#type.push(typeSetId); + this.#entity.push(entityIndex); + + const id = LinkId(this.#left.length - 1); + + if (left !== -1) { + this.#addToAdjacency(left, id); + } + if (right !== -1) { + this.#addToAdjacency(right, id); + } + + return id; + } + + #addToAdjacency(entityIndex: number, linkId: LinkId): void { + let list = this.#adjacency.get(entityIndex); + if (!list) { + list = []; + this.#adjacency.set(entityIndex, list); + } + list.push(linkId); + } + + addPending( + endpointId: EntityId, + linkId: LinkId, + side: "left" | "right", + ): void { + const pending = this.#pending.get(endpointId) ?? []; + pending.push({ linkId, side }); + this.#pending.set(endpointId, pending); + } + + /** Remove and return pending endpoints for an entity, or `undefined` if none. */ + takePending(endpointId: EntityId): PendingEndpoint[] | undefined { + const pending = this.#pending.get(endpointId); + if (pending) { + this.#pending.delete(endpointId); + } + return pending; + } + + /** + * Resolve a deferred endpoint and add it to the adjacency index. + * + * Only the recorded pending side is written; the other endpoint keeps its + * value. Resolutions are logged for {@link drainResolvedEndpoints} so that + * incremental consumers can undo the classification the link had while the + * side was still `-1`. + */ + resolveEndpoint( + linkId: LinkId, + side: "left" | "right", + entityIndex: EntityIndex, + ): void { + if (side === "left") { + this.#left.set(linkId, entityIndex); + } else { + this.#right.set(linkId, entityIndex); + } + this.#addToAdjacency(entityIndex, linkId); + this.#resolvedLog.push({ linkId, side }); + } + + /** + * Returns endpoint resolutions accumulated since the last drain and + * clears the log; incremental consumers use this to patch cached edge + * state. + */ + drainResolvedEndpoints(): ResolvedEndpoint[] { + const drained = this.#resolvedLog; + this.#resolvedLog = []; + return drained; + } + + getLeft(linkId: number): EntityIndex | -1 { + return this.#left.get(linkId); + } + + getRight(linkId: number): EntityIndex | -1 { + return this.#right.get(linkId); + } + + getTypeSetId(linkId: number): TypeSetId { + return this.#type.get(linkId); + } + + /** The link's own entity index (a link is itself an entity). */ + getEntityIndex(linkId: number): EntityIndex { + return this.#entity.get(linkId); + } + + degreeOf(entityIndex: EntityIndex): number { + return this.#adjacency.get(entityIndex)?.length ?? 0; + } + + /** All links touching an entity, with direction. O(degree). */ + *linksFor( + entityIndex: EntityIndex, + ): Generator { + const linkIds = this.#adjacency.get(entityIndex); + if (!linkIds) { + return; + } + + for (const linkId of linkIds) { + const left = this.#left.get(linkId); + const right = this.#right.get(linkId); + + if (left === entityIndex && right !== -1) { + yield { + linkId, + otherId: right, + typeSetId: this.#type.get(linkId), + direction: "out", + }; + } else if (right === entityIndex && left !== -1) { + yield { + linkId, + otherId: left, + typeSetId: this.#type.get(linkId), + direction: "in", + }; + } + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/property.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/property.ts new file mode 100644 index 00000000000..e3c82dbaac8 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/property.ts @@ -0,0 +1,235 @@ +/** + * Per-entity property features for cluster naming. + * + * Each scalar property value is reduced to a `(baseUrl, formatted-value)` pair + * and interned. Numbers and ISO dates additionally keep their raw value for + * quantile range bucketing. + */ +import { Interner } from "../../collections/interner"; + +import type { EntityIndex } from "../../../ids"; +import type { PropertySchemaEntry } from "../../protocol"; +import type { PropertyObject } from "@blockprotocol/type-system"; + +/** Interned index of a distinct `(baseUrl, formatted-value)` feature. */ +export type FeatureId = number; + +/** Interned index of a property base URL that carries numeric/date values. */ +export type NumericKeyId = number; + +/** Whether a numeric property reads as a plain number or an (epoch-ms) date. */ +export type NumericKind = "number" | "date"; + +const MAX_VALUE_CHARS = 64; + +/** Strict ISO-8601 date/datetime. Rejects bare numbers and partial strings that `Date.parse` accepts. */ +const ISO_DATE_RE = + /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/u; + +interface FeatureInfo { + readonly baseUrl: string; + readonly display: string; +} + +export interface FeatureLabel { + readonly baseUrl: string; + readonly title: string; + readonly display: string; +} + +interface NumericReading { + readonly value: number; + readonly kind: NumericKind; +} + +function truncate(value: string): string { + return value.length > MAX_VALUE_CHARS + ? `${value.slice(0, MAX_VALUE_CHARS)}…` + : value; +} + +/** + * Format a property value for display, or `undefined` when it carries no signal. + * + * Strings are quoted, numbers/booleans are bare, arrays become a count, + * nested objects become `"present"`. + */ +function formatFeatureValue(value: unknown): string | undefined { + if (typeof value === "string") { + const collapsed = value.replace(/\s+/gu, " ").trim(); + return collapsed.length === 0 ? undefined : `"${truncate(collapsed)}"`; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value)) { + return value.length === 0 + ? undefined + : `${value.length} item${value.length === 1 ? "" : "s"}`; + } + if (value !== null && typeof value === "object") { + return "present"; + } + return undefined; +} + +/** + * Numeric value of a property, or `undefined` when not a finite number or ISO date. + * + * Dates collapse to epoch milliseconds. + */ +function numericReading(value: unknown): NumericReading | undefined { + if (typeof value === "number") { + return Number.isFinite(value) ? { value, kind: "number" } : undefined; + } + if (typeof value === "string" && ISO_DATE_RE.test(value)) { + const ms = Date.parse(value); + return Number.isNaN(ms) ? undefined : { value: ms, kind: "date" }; + } + return undefined; +} + +/** Title-case the slug from a property-type base URL as a fallback display title. */ +function slugTitleFromBaseUrl(baseUrl: string): string { + const slug = /\/property-type\/(?[^/]+)\/?$/.exec(baseUrl)?.groups + ?.slug; + if (!slug) { + return baseUrl; + } + return slug + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +export class PropertyStore { + readonly #features: Interner; + readonly #featureInfo: FeatureInfo[]; + readonly #entityFeatures: (Int32Array | undefined)[]; + readonly #titles: Map; + + readonly #numericKeys: Interner; + readonly #numericKeyBaseUrl: string[]; + readonly #numericKeyKind: NumericKind[]; + // Raw (not interned): range bucketing depends on the live distribution of + // a cluster's members, so values can't be pre-interned. + readonly #entityNumericKeys: (Int32Array | undefined)[]; + readonly #entityNumericValues: (Float64Array | undefined)[]; + + constructor() { + this.#features = new Interner(); + this.#featureInfo = []; + this.#entityFeatures = []; + this.#titles = new Map(); + + this.#numericKeys = new Interner(); + this.#numericKeyBaseUrl = []; + this.#numericKeyKind = []; + this.#entityNumericKeys = []; + this.#entityNumericValues = []; + } + + /** Register property display titles. Returns true if any new title was added. */ + registerTitles(entries: readonly PropertySchemaEntry[]): boolean { + let added = false; + for (const { baseUrl, title } of entries) { + if (title && !this.#titles.has(baseUrl)) { + this.#titles.set(baseUrl, title); + added = true; + } + } + return added; + } + + /** Human title for a base URL, falling back to a slug-derived title. */ + title(baseUrl: string): string { + return this.#titles.get(baseUrl) ?? slugTitleFromBaseUrl(baseUrl); + } + + /** + * Extract scalar features and numeric readings from an entity's properties. + * + * No-op when `properties` is undefined. Calling again for the same + * `index` overwrites its previously stored feature and numeric arrays; + * feature ids are stored sorted in ascending order. + */ + ingest(index: EntityIndex, properties: PropertyObject | undefined): void { + if (!properties) { + return; + } + + const featureIds = new Set(); + const numericKeyIds: NumericKeyId[] = []; + const numericValues: number[] = []; + + for (const [baseUrl, value] of Object.entries(properties)) { + const display = formatFeatureValue(value); + if (display !== undefined) { + const [created, featureId] = this.#features.tryIntern( + `${baseUrl}\u0000${display}`, + ); + if (created) { + this.#featureInfo.push({ baseUrl, display }); + } + featureIds.add(featureId); + } + + const reading = numericReading(value); + if (reading) { + const [created, keyId] = this.#numericKeys.tryIntern(baseUrl); + if (created) { + this.#numericKeyBaseUrl[keyId] = baseUrl; + this.#numericKeyKind[keyId] = reading.kind; + } + numericKeyIds.push(keyId); + numericValues.push(reading.value); + } + } + + if (featureIds.size > 0) { + this.#entityFeatures[index] = Int32Array.from( + [...featureIds].sort((left, right) => left - right), + ); + } + if (numericKeyIds.length > 0) { + this.#entityNumericKeys[index] = Int32Array.from(numericKeyIds); + this.#entityNumericValues[index] = Float64Array.from(numericValues); + } + } + + /** Sorted feature indices for an entity, or `undefined` if it has none. */ + featuresOf(index: EntityIndex): Int32Array | undefined { + return this.#entityFeatures[index]; + } + + /** Resolve a feature index to its display label. */ + describe(featureId: FeatureId): FeatureLabel | undefined { + const info = this.#featureInfo[featureId]; + if (!info) { + return undefined; + } + return { + baseUrl: info.baseUrl, + title: this.title(info.baseUrl), + display: info.display, + }; + } + + /** Numeric property key indices, parallel to {@link numericValuesOf}. */ + numericKeysOf(index: EntityIndex): Int32Array | undefined { + return this.#entityNumericKeys[index]; + } + + /** Raw numeric values (numbers, or dates as epoch ms). */ + numericValuesOf(index: EntityIndex): Float64Array | undefined { + return this.#entityNumericValues[index]; + } + + numericBaseUrl(keyId: NumericKeyId): string | undefined { + return this.#numericKeyBaseUrl[keyId]; + } + + numericKind(keyId: NumericKeyId): NumericKind { + return this.#numericKeyKind[keyId] ?? "number"; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/type-set.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/type-set.ts new file mode 100644 index 00000000000..a670d33bcdf --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/store/type-set.ts @@ -0,0 +1,154 @@ +/** + * Type-set grouping: entities that share the exact same set of direct + * entity types belong to one {@link TypeSetGroup}. + */ +import { ClusterId, TypeSetKey } from "../../../ids"; +import { BitSet } from "../../collections/bitset"; +import { Column } from "../../collections/column"; +import { Interner } from "../../collections/interner"; + +import type { EntityIndex, TypeId, TypeSetId } from "../../../ids"; +import type { ReadonlySortedSet } from "../../collections/readonly-sorted-set"; +import type { TypeRegistry } from "../../store/type-registry"; + +const INITIAL_CAPACITY = 256; + +/** + * A group of entities that share the exact same set of direct entity types. + * + * The cluster tree decides whether a group is large enough to be its own + * cluster ({@link isStandalone}) or should be merged into a type-ancestor + * cluster. {@link assignedClusterId} tracks which cluster currently owns + * this group's entities. + */ +export class TypeSetGroup { + readonly key: TypeSetKey; + readonly id: TypeSetId; + readonly directTypeIds: ReadonlySortedSet; + /** Cluster ID used when this group is large enough to stand alone. */ + readonly standaloneClusterId: ClusterId; + + readonly #entities: Column; + /** Union of ancestor closures of all direct types; identifies candidate merge targets by type overlap. */ + #closure: BitSet; + /** Monotonic counter bumped on each entity add so cluster assignment can detect membership changes without scanning entities. */ + #version = 0; + /** + * The cluster that currently owns this group's entities: + * {@link standaloneClusterId} when standalone, or a merge target's ID + * when the group is too small. + */ + #assignedClusterId: ClusterId; + #isStandalone = false; + + constructor( + key: TypeSetKey, + id: TypeSetId, + directTypeIds: ReadonlySortedSet, + typeUniverseSize: number, + ) { + this.key = key; + this.id = id; + this.directTypeIds = directTypeIds; + this.standaloneClusterId = ClusterId(`cluster:type:${key}`); + this.#assignedClusterId = this.standaloneClusterId; + this.#closure = BitSet.empty(typeUniverseSize); + this.#entities = new Column(Int32Array, INITIAL_CAPACITY); + } + + get count(): number { + return this.#entities.length; + } + + get entities(): Column { + return this.#entities; + } + + get closure(): BitSet { + return this.#closure; + } + + get version(): number { + return this.#version; + } + + get assignedClusterId(): ClusterId { + return this.#assignedClusterId; + } + + set assignedClusterId(id: ClusterId) { + this.#assignedClusterId = id; + } + + get isStandalone(): boolean { + return this.#isStandalone; + } + + set isStandalone(value: boolean) { + this.#isStandalone = value; + } + + addEntity(index: EntityIndex): void { + this.#entities.push(index); + this.#version++; + } + + /** Recompute the ancestor closure from the current type registry. */ + recomputeClosure(types: TypeRegistry): void { + let closure = BitSet.empty(types.size); + + for (const typeId of this.directTypeIds) { + const info = types.get(typeId); + if (info) { + closure = closure.or(info.ancestorClosure); + } + } + + this.#closure = closure; + } +} + +/** Manages {@link TypeSetGroup}s, interning by their canonical type-set key. */ +export class TypeSetStore { + readonly #interner: Interner; + readonly #groups: Map; + + constructor() { + this.#interner = new Interner(); + this.#groups = new Map(); + } + + get size(): number { + return this.#groups.size; + } + + get(key: TypeSetKey): TypeSetGroup | undefined { + return this.#groups.get(key); + } + + getById(id: TypeSetId): TypeSetGroup | undefined { + const key = this.#interner.getValue(id); + return this.#groups.get(key); + } + + getOrCreate( + directTypeIds: ReadonlySortedSet, + typeUniverseSize: number, + ): TypeSetGroup { + const key = TypeSetKey(directTypeIds.items.join(",")); + const existing = this.#groups.get(key); + if (existing) { + return existing; + } + + const id = this.#interner.intern(key); + const group = new TypeSetGroup(key, id, directTypeIds, typeUniverseSize); + this.#groups.set(key, group); + + return group; + } + + *[Symbol.iterator](): IterableIterator { + yield* this.#groups.values(); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/worker.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/worker.test.ts new file mode 100644 index 00000000000..5580acb4c7e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/worker.test.ts @@ -0,0 +1,663 @@ +/** + * Guards for the change-aware {@link EntityGraphWorker.commitStructure} + * flat-tier path: a commit with nothing new must do no structural work (no + * rebuild, no re-emit), a colour-only change must restyle without rebuilding + * the layout, and streaming a graph in batches must land in the same + * committed state as ingesting it in one shot. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { defaultVizConfig } from "../../config"; +import { ClusterId } from "../../ids"; +import { + benchEntityId, + benchNodeTypeUrl, + benchTypeSchemas, + buildIngestEntities, +} from "../bench-fixtures"; +import { + CommitCoalescer, + MAX_COALESCED_BATCHES, +} from "../core/commit-coalescer"; +import { EntityGraphWorker } from "./worker"; + +import type { PositionsFrame, StructureFrame } from "../../frames"; +import type { GraphShape } from "../bench-fixtures"; +import type { IngestEntity } from "../protocol"; + +/** flat-force stays under `flatLayoutExitNodes` (250); community-force is above it. */ +const FLAT_FORCE: GraphShape = { + nodeCount: 80, + linkCount: 160, + typeCount: 8, + hubCount: 8, + rootFraction: 1, + seed: 7, +}; + +const COMMUNITY_FORCE: GraphShape = { + nodeCount: 600, + linkCount: 1_500, + typeCount: 16, + hubCount: 30, + rootFraction: 1, + seed: 9, +}; + +interface Harness { + readonly worker: EntityGraphWorker; + readonly structure: ReturnType; + readonly positions: ReturnType; + readonly layout: ReturnType; + /** Count of LAYOUT_CREATED / LAYOUT_DESTROYED messages (i.e. layout rebuilds). */ + layoutLifecycleCalls(): number; + /** The `count` from the most recent flat structure frame. */ + lastFlatCount(): number | undefined; +} + +function newHarness(): Harness { + const worker = new EntityGraphWorker(defaultVizConfig); + const structure = vi.fn(); + const positions = vi.fn(); + const layout = vi.fn(); + worker.onStructureFrame = structure; + worker.onPositionsFrame = positions; + worker.onLayoutMessage = layout; + return { + worker, + structure, + positions, + layout, + layoutLifecycleCalls() { + return layout.mock.calls.filter(([msg]) => { + const { type } = msg as { readonly type: string }; + return type === "LAYOUT_CREATED" || type === "LAYOUT_DESTROYED"; + }).length; + }, + lastFlatCount() { + const calls = structure.mock.calls; + const last = calls[calls.length - 1]?.[0] as + | { flatGraph?: { count: number } } + | undefined; + return last?.flatGraph?.count; + }, + }; +} + +/** Register types, ingest the whole shape, and run the first (building) commit. */ +function prime(shape: GraphShape): Harness { + const harness = newHarness(); + harness.worker.registerTypes(benchTypeSchemas(shape.typeCount), []); + const deltas = harness.worker.ingestBatch(buildIngestEntities(shape)); + harness.worker.commitStructure({ deltas }); + return harness; +} + +interface TopLevelCluster { + readonly x: number; + readonly y: number; + readonly radius: number; + readonly count: number; +} + +/** Top-level cluster id -> world position/size, from the latest emitted frames. */ +function topLevelSnapshot(harness: Harness): Map { + const structureCalls = harness.structure.mock.calls; + const positionsCalls = harness.positions.mock.calls; + const structure = structureCalls[structureCalls.length - 1]?.[0] as + | StructureFrame + | undefined; + const positions = positionsCalls[positionsCalls.length - 1]?.[0] as + | PositionsFrame + | undefined; + const snapshot = new Map(); + if (!structure || !positions) { + return snapshot; + } + for (const [index, cluster] of structure.clusters.entries()) { + snapshot.set(cluster.id, { + x: positions.clusterPositions[index * 2] ?? 0, + y: positions.clusterPositions[index * 2 + 1] ?? 0, + radius: cluster.radius, + count: cluster.count, + }); + } + return snapshot; +} + +/** Total absolute position movement of clusters present in both snapshots. */ +function positionDrift( + before: Map, + after: Map, +): number { + let total = 0; + for (const [id, next] of after) { + const prev = before.get(id); + if (prev) { + total += Math.abs(next.x - prev.x) + Math.abs(next.y - prev.y); + } + } + return total; +} + +describe("EntityGraphWorker.commitStructure, flat tier", () => { + // The trailing-Louvain linger uses setTimeout; fake only timers so it can't + // fire mid-test (MessageChannel scheduling and performance.now stay real). + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + for (const shape of [FLAT_FORCE, COMMUNITY_FORCE]) { + const tier = shape === FLAT_FORCE ? "flat-force" : "community-force"; + + it(`does no work on a redundant commit (${tier})`, () => { + const harness = prime(shape); + // The build commit emitted a structure + positions frame and created the + // flat layout; clear the spies and re-commit with nothing changed. + harness.structure.mockClear(); + harness.positions.mockClear(); + harness.layout.mockClear(); + + harness.worker.commitStructure(); + + expect(harness.structure).not.toHaveBeenCalled(); + expect(harness.positions).not.toHaveBeenCalled(); + expect(harness.layout).not.toHaveBeenCalled(); + }); + + it(`restyles without rebuilding on a type change (${tier})`, () => { + const harness = prime(shape); + harness.structure.mockClear(); + harness.positions.mockClear(); + harness.layout.mockClear(); + + // A type (re)registration recolours nodes without adding any; the flat + // path must re-emit style but must not tear down / recreate the layout. + harness.worker.commitStructure({ rebuildTree: true }); + + expect(harness.structure).toHaveBeenCalledTimes(1); + expect(harness.positions).toHaveBeenCalledTimes(1); + expect(harness.layoutLifecycleCalls()).toBe(0); + }); + } + + it("re-emits when new nodes arrive", () => { + const harness = newHarness(); + const entities = buildIngestEntities(FLAT_FORCE); + // Nodes are the leading `nodeCount` entries; split them across two batches. + const half = FLAT_FORCE.nodeCount / 2; + + harness.worker.commitStructure({ + deltas: harness.worker.ingestBatch(entities.slice(0, half)), + }); + expect(harness.lastFlatCount()).toBe(half); + + harness.structure.mockClear(); + harness.worker.commitStructure({ + deltas: harness.worker.ingestBatch(entities.slice(half)), + }); + + expect(harness.structure).toHaveBeenCalledTimes(1); + expect(harness.lastFlatCount()).toBe(FLAT_FORCE.nodeCount); + }); +}); + +describe("EntityGraphWorker.commitStructure, streaming equals bulk", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("reaches the same committed node/link/flat state whether batched or bulk", () => { + const entities = buildIngestEntities(COMMUNITY_FORCE); + + const bulk = prime(COMMUNITY_FORCE); + + const streamed = newHarness(); + streamed.worker.registerTypes( + benchTypeSchemas(COMMUNITY_FORCE.typeCount), + [], + ); + const batchSize = 100; + for (let start = 0; start < entities.length; start += batchSize) { + const chunk = entities.slice(start, start + batchSize); + streamed.worker.commitStructure({ + deltas: streamed.worker.ingestBatch(chunk), + }); + } + + expect(streamed.worker.nodeCount).toBe(bulk.worker.nodeCount); + expect(streamed.worker.linkCount).toBe(bulk.worker.linkCount); + expect(streamed.worker.mode).toBe(bulk.worker.mode); + expect(streamed.lastFlatCount()).toBe(bulk.lastFlatCount()); + expect(streamed.lastFlatCount()).toBe(COMMUNITY_FORCE.nodeCount); + }); +}); + +/** + * Coalesced streaming path (per-batch commits merged through + * {@link CommitCoalescer}): a burst must land in the same committed state as + * bulk while paying a bounded number of commits, not one per batch. + */ +describe("EntityGraphWorker.commitStructure, coalesced streaming", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + interface StreamedHarness extends Harness { + /** Structure frames emitted while streaming (excludes the type-registration commit). */ + readonly streamFrames: () => number; + } + + /** + * Drive a harness through the production coalescing sequence: type + * registration and every batch's deltas ride the coalescer. The drain + * fires once after the first batch (a cold load's first queue cycle); the + * rest of the stream arrives as one saturated burst, so the batch-count + * cap paces the commits. The clock is frozen so the age cap cannot fire + * mid-loop on a slow machine. + */ + function streamCoalesced( + batches: readonly (readonly IngestEntity[])[], + ): StreamedHarness { + const harness = newHarness(); + let fireDrain: (() => void) | undefined; + const coalescer = new CommitCoalescer({ + commit: ({ deltas, rebuildTree }) => { + harness.worker.commitStructure({ deltas, rebuildTree }); + harness.worker.restyleIfRootsFlipped(); + }, + now: () => 0, + scheduleDrain: (fire) => { + fireDrain = fire; + }, + }); + + harness.worker.registerTypes( + benchTypeSchemas(COMMUNITY_FORCE.typeCount), + [], + ); + coalescer.enqueueRebuildTree(); + const framesBeforeStream = harness.structure.mock.calls.length; + + for (const [batchIndex, batch] of batches.entries()) { + coalescer.enqueueDeltas(harness.worker.ingestBatch(batch)); + if (batchIndex === 0) { + fireDrain?.(); + } + } + coalescer.flush(); + + return { + ...harness, + streamFrames: () => + harness.structure.mock.calls.length - framesBeforeStream, + }; + } + + /** Relabel communities by first occurrence so equal partitions compare equal. */ + function canonicalCommunities(communities: Int32Array | undefined): number[] { + const relabel = new Map(); + const result: number[] = []; + for (const label of communities ?? []) { + let canonical = relabel.get(label); + if (canonical === undefined) { + canonical = relabel.size; + relabel.set(label, canonical); + } + result.push(canonical); + } + return result; + } + + /** The `communities` of the most recent flat structure frame. */ + function lastCommunities(harness: Harness): Int32Array | undefined { + const calls = harness.structure.mock.calls; + const last = calls[calls.length - 1]?.[0] as + | { flatGraph?: { communities?: Int32Array } } + | undefined; + return last?.flatGraph?.communities; + } + + /** + * Re-batch the fixture the way real pages arrive: each batch carries nodes + * AND their links. (`buildIngestEntities` emits all nodes then all links; + * streaming THAT shape ends on link-only absorbs, which refresh no Louvain + * - nodes drive the refresh counters, so communities would lag the final + * links under bulk and streaming alike. Interleaving keeps the trailing + * refresh meaningful, which is what the communities assertion needs.) + */ + function interleaveBatches( + entities: readonly IngestEntity[], + nodeCount: number, + batchCount: number, + ): IngestEntity[][] { + const nodes = entities.slice(0, nodeCount); + const links = entities.slice(nodeCount); + const batches: IngestEntity[][] = []; + for (let index = 0; index < batchCount; index++) { + batches.push([ + ...nodes.slice( + Math.ceil((nodes.length * index) / batchCount), + Math.ceil((nodes.length * (index + 1)) / batchCount), + ), + ...links.slice( + Math.ceil((links.length * index) / batchCount), + Math.ceil((links.length * (index + 1)) / batchCount), + ), + ]); + } + return batches; + } + + it("reaches the bulk committed state with a bounded commit count", () => { + const entities = buildIngestEntities(COMMUNITY_FORCE); + const batchCount = 20; + const batches = interleaveBatches( + entities, + COMMUNITY_FORCE.nodeCount, + batchCount, + ); + + const bulk = prime(COMMUNITY_FORCE); + const streamed = streamCoalesced(batches); + + expect(streamed.worker.nodeCount).toBe(bulk.worker.nodeCount); + expect(streamed.worker.linkCount).toBe(bulk.worker.linkCount); + expect(streamed.worker.mode).toBe(bulk.worker.mode); + expect(streamed.lastFlatCount()).toBe(bulk.lastFlatCount()); + + // The whole point: commits/frames per stream stay bounded by the + // coalescing policy (first-drain + batch-count cap + final flush), far + // below one per batch. They must not grow linearly with batch count. + const expectedCeiling = 2 + Math.ceil(batchCount / MAX_COALESCED_BATCHES); + expect(streamed.streamFrames()).toBeLessThanOrEqual(expectedCeiling); + expect(streamed.streamFrames()).toBeLessThan(batchCount / 2); + // Layout rebuilds stay bounded too (initial build + the flat-force -> + // community-force crossing); the coalesced link-only tails absorb. + expect(streamed.layoutLifecycleCalls()).toBeLessThanOrEqual(4); + + // After the trailing Louvain linger both sides describe the same final + // graph, so their community PARTITIONS must match. + vi.runOnlyPendingTimers(); + expect(canonicalCommunities(lastCommunities(streamed))).toEqual( + canonicalCommunities(lastCommunities(bulk)), + ); + }); + + it("is deterministic: two identical coalesced streams settle identical layouts", () => { + const batches = interleaveBatches( + buildIngestEntities(COMMUNITY_FORCE), + COMMUNITY_FORCE.nodeCount, + 20, + ); + + const first = streamCoalesced(batches); + const second = streamCoalesced(batches); + + const firstFixture = first.worker.captureLayoutFixture(); + const secondFixture = second.worker.captureLayoutFixture(); + expect(firstFixture).not.toBeNull(); + expect(secondFixture?.nodes).toEqual(firstFixture?.nodes); + expect(secondFixture?.communities).toEqual(firstFixture?.communities); + }); +}); + +/** Above `communityColorExitNodes` (5000) the worker enters the hierarchical tier. */ +const HIERARCHICAL: GraphShape = { + nodeCount: 6_000, + linkCount: 12_000, + typeCount: 20, + hubCount: 60, + rootFraction: 1, + seed: 13, +}; + +describe("EntityGraphWorker.commitStructure, hierarchical tier", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("commits clusters and reuses handleViewport's cut without re-emitting stale state", () => { + const harness = prime(HIERARCHICAL); + expect(harness.worker.mode).toBe("hierarchical-lod"); + + // A viewport change computes a cut then commits it; the commit must reuse + // that precomputed cut (not recompute against a stale tree) and emit a + // hierarchical cluster frame, not a flat one. + harness.structure.mockClear(); + harness.worker.handleViewport({ + zoom: 1, + centerX: 0, + centerY: 0, + width: 1600, + height: 900, + }); + + const frames = harness.structure.mock.calls.map( + ([frame]) => frame as { mode: string; flatGraph?: unknown }, + ); + expect(frames.length).toBeGreaterThan(0); + for (const frame of frames) { + expect(frame.mode).toBe("hierarchical-lod"); + expect(frame.flatGraph).toBeUndefined(); + } + }); + + it("does no work on a redundant hierarchical re-commit", () => { + const harness = prime(HIERARCHICAL); + harness.worker.handleViewport({ + zoom: 1, + centerX: 0, + centerY: 0, + width: 1600, + height: 900, + }); + + // Nothing changed since that commit: same tree/epoch, links, and cut. The + // no-op fast path must skip CutIndex/aggregation/layers and emit nothing. + harness.structure.mockClear(); + harness.positions.mockClear(); + harness.worker.commitStructure(); + + expect(harness.structure).not.toHaveBeenCalled(); + expect(harness.positions).not.toHaveBeenCalled(); + }); + + it("does not commit when a pin leaves the cut unchanged", () => { + const harness = prime(HIERARCHICAL); + harness.worker.handleViewport({ + zoom: 1, + centerX: 0, + centerY: 0, + width: 1600, + height: 900, + }); + + // A cluster that isn't in the tree can't open a new path, so the cut is + // identical and pin() must not trigger a commit (it should mirror the + // wouldChange guard handleViewport uses). + harness.structure.mockClear(); + harness.positions.mockClear(); + harness.worker.pin(ClusterId("cluster:does-not-exist")); + + expect(harness.structure).not.toHaveBeenCalled(); + expect(harness.positions).not.toHaveBeenCalled(); + }); + + // Top-level re-layout is driven by INGEST, not by type (re)registration: a + // trickle of new members leaves the overview stable (no churn on every tiny + // expansion), but once a cluster grows past the re-warm threshold the macro + // layout re-settles -- with no rebuildTree / REGISTER_TYPES involved. + it("re-arranges the top level on significant known-type growth, not on a trickle", () => { + const harness = prime(HIERARCHICAL); + // Zoomed out: every top-level cluster stays collapsed, so the emitted + // clusters ARE the macro layout's nodes. + harness.worker.handleViewport({ + zoom: 0.02, + centerX: 0, + centerY: 0, + width: 1600, + height: 900, + }); + + const before = topLevelSnapshot(harness); + expect(before.size).toBeGreaterThan(1); + + // A trickle: a few nodes spread across existing types (already-known). Every + // top-level cluster grows a little, but none crosses the re-warm threshold. + const trickle: IngestEntity[] = []; + for (let index = 0; index < 40; index++) { + trickle.push({ + entityId: benchEntityId(1_000_000 + index), + entityTypeIds: [benchNodeTypeUrl(index % HIERARCHICAL.typeCount)], + isLink: false, + isRoot: true, + }); + } + harness.structure.mockClear(); + harness.positions.mockClear(); + harness.worker.commitStructure({ + deltas: harness.worker.ingestBatch(trickle), + }); + const afterTrickle = topLevelSnapshot(harness); + + // Member counts increased (trickle ingest landed), but no cluster + // crossed the re-warm threshold, so top-level positions stay fixed. + expect( + [...afterTrickle].some( + ([id, cluster]) => cluster.count > (before.get(id)?.count ?? 0), + ), + ).toBe(true); + expect(positionDrift(before, afterTrickle)).toBe(0); + + // A real expansion: many nodes into ONE existing type. That cluster grows + // past the threshold, so the macro layout re-warms and the top level moves + // -- driven purely by ingest, with NO rebuildTree / type registration. + const surge: IngestEntity[] = []; + for (let index = 0; index < 400; index++) { + surge.push({ + entityId: benchEntityId(2_000_000 + index), + entityTypeIds: [benchNodeTypeUrl(0)], + isLink: false, + isRoot: true, + }); + } + harness.worker.commitStructure({ + deltas: harness.worker.ingestBatch(surge), + }); + const afterSurge = topLevelSnapshot(harness); + + expect(positionDrift(afterTrickle, afterSurge)).toBeGreaterThan(0); + }); +}); + +describe("EntityGraphWorker.updateConfig", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("re-lays out the flat tier in place, keeping state and config identity", () => { + const harness = prime(FLAT_FORCE); + const configBefore = harness.worker.config; + const flatForceBefore = harness.worker.config.flatForce; + const nodesBefore = harness.worker.nodeCount; + const linksBefore = harness.worker.linkCount; + harness.structure.mockClear(); + harness.layout.mockClear(); + + harness.worker.updateConfig({ + ...defaultVizConfig, + flatForce: { ...defaultVizConfig.flatForce, idealLinkLength: 80 }, + }); + + // Ingested state survives; the layout is rebuilt under the new tuning. + expect(harness.worker.nodeCount).toBe(nodesBefore); + expect(harness.worker.linkCount).toBe(linksBefore); + expect(harness.lastFlatCount()).toBe(FLAT_FORCE.nodeCount); + expect(harness.layoutLifecycleCalls()).toBeGreaterThan(0); + + // In-place application: collaborators hold these references, so the + // root config and its nested groups must keep their identity... + expect(harness.worker.config).toBe(configBefore); + expect(harness.worker.config.flatForce).toBe(flatForceBefore); + expect(harness.worker.config.flatForce.idealLinkLength).toBe(80); + // ...while the shared defaults (passed to the constructor) stay pristine. + expect(defaultVizConfig.flatForce.idealLinkLength).toBe(40); + }); + + it("re-evaluates the regime under new thresholds", () => { + const harness = prime(COMMUNITY_FORCE); + expect(harness.worker.mode).toBe("community-force"); + + harness.worker.updateConfig({ + ...defaultVizConfig, + flatLayoutMaxNodes: 50, + flatLayoutExitNodes: 80, + communityColorMaxNodes: 100, + communityColorExitNodes: 200, + }); + + // 600 nodes now sit above the hierarchical entry threshold. + expect(harness.worker.mode).toBe("hierarchical-lod"); + const calls = harness.structure.mock.calls; + const lastFrame = calls[calls.length - 1]?.[0] as + | { mode: string } + | undefined; + expect(lastFrame?.mode).toBe("hierarchical-lod"); + }); + + it("rejects an invalid config, keeping the previous one fully in effect", () => { + const worker = new EntityGraphWorker(defaultVizConfig); + + expect(() => + worker.updateConfig({ + ...defaultVizConfig, + // Breaks the flat-tier hysteresis pair. + flatLayoutExitNodes: defaultVizConfig.flatLayoutMaxNodes, + }), + ).toThrow(/flatLayoutMaxNodes/); + + expect(worker.config.flatLayoutExitNodes).toBe( + defaultVizConfig.flatLayoutExitNodes, + ); + }); +}); + +describe("EntityGraphWorker.registerTypes, change classification", () => { + it("reports no change on an identical re-registration", () => { + const worker = new EntityGraphWorker(defaultVizConfig); + const schemas = benchTypeSchemas(8); + + expect(worker.registerTypes(schemas, []).typesChanged).toBe(true); + + const again = worker.registerTypes(schemas, []); + expect(again.typesChanged).toBe(false); + expect(again.propertyTitlesChanged).toBe(false); + }); + + it("reports a change when a genuinely new type is registered", () => { + const worker = new EntityGraphWorker(defaultVizConfig); + worker.registerTypes(benchTypeSchemas(8), []); + + // benchTypeSchemas(10) is a superset -- two new node types -- which can + // change grouping/colour and so must be reported as a change. + expect(worker.registerTypes(benchTypeSchemas(10), []).typesChanged).toBe( + true, + ); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/worker.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/worker.ts new file mode 100644 index 00000000000..06147b81127 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-graph/worker.ts @@ -0,0 +1,592 @@ +/** + * Worker-side orchestrator for graph ingest, regime selection (flat vs + * hierarchical), structure commits, viewport-driven LOD, layout lifecycle, + * and frame emission. Owns the stores, the cluster tree, and the regime + * flag, and wires the collaborators that do the actual work: ingest + * ({@link IngestController}), the flat tier ({@link FlatTierController}), + * the hierarchical tier ({@link HierarchicalTier}), layout lifecycle + * ({@link HierarchicalLayoutManager}), port constraints, settle polish, + * frame emission, the tick loop, and embedding-driven subdivision. + * + * Public methods form the worker message protocol; each routes into a + * collaborator and coordinates cross-tier transitions collaborators cannot + * see alone. + */ +import { + assignVizConfigInPlace, + cloneVizConfig, + validateConfig, +} from "../../config"; +import { CommittedView } from "../core/committed-view"; +import { LeafLocalCache } from "../core/frames/leaf-local-cache"; +import { PositionsFrameEmitter } from "../core/frames/positions-frame"; +import { StructureFrameEmitter } from "../core/frames/structure-frame"; +import { LayoutRegistry } from "../core/layout-registry"; +import { JobScheduler, TickScheduler } from "../core/schedulers"; +import { TickLoop } from "../core/tick-loop"; +import { configureEntityStyle } from "../entity-style"; +import { PortCache } from "../geometry/bubble-ports"; +import { EdgeAggregator } from "../geometry/edge-aggregation"; +import { syncWorldPositions } from "../geometry/world-positions"; +import { ClusterTree } from "../hierarchy/cluster-tree"; +import { TypeRegistry } from "../store/type-registry"; +import { egoTargets } from "./ego"; +import { FlatTierController } from "./flat/tier"; +import { EmbeddingCoordinator } from "./hierarchical/embedding-coordinator"; +import { HierarchicalLayoutManager } from "./hierarchical/layouts"; +import { writeLeafColors } from "./hierarchical/leaf-colors"; +import { PortConstraintController } from "./hierarchical/port-constraints"; +import { SettlePolisher } from "./hierarchical/settle-polish"; +import { HierarchicalTier } from "./hierarchical/tier"; +import { IngestController } from "./ingest"; +import { nextVizMode } from "./mode-policy"; +import { EntityStore } from "./store/entity"; +import { LinkStore } from "./store/link"; +import { PropertyStore } from "./store/property"; +import { TypeSetStore } from "./store/type-set"; + +import type { VizConfig } from "../../config"; +import type { PositionsFrame, StructureFrame } from "../../frames"; +import type { ClusterId, EntityIndex, TypeSetKey, VizMode } from "../../ids"; +import type { RepublishHandler } from "../buffers/growable-buffer"; +import type { IngestDelta } from "../hierarchy/cluster-tree"; +import type { LodItem, ViewportState } from "../hierarchy/lod"; +import type { + CapturedLayoutFixture, + EgoTarget, + EmbeddingClusteringNeededMessage, + IngestEntity, + LayoutSideChannelMessage, + PropertySchemaEntry, + TypeSchemaEntry, +} from "../protocol"; +import type { TypeSetGroup } from "./store/type-set"; + +export class EntityGraphWorker { + readonly config: VizConfig; + + readonly #types: TypeRegistry = new TypeRegistry(); + readonly #typeSets: TypeSetStore = new TypeSetStore(); + /** Re-publishes the EntityIdx->EntityId join map buffer on reallocation. */ + readonly #republishEntityIdMap: RepublishHandler = (raw, capacity) => { + this.#onLayoutMessage?.({ type: "ENTITY_ID_MAP", buffer: raw, capacity }); + }; + + /** The join map is always current: each EntityId is written on intern. */ + readonly #entities: EntityStore = new EntityStore(this.#republishEntityIdMap); + #entityIdMapPublished = false; + readonly #links: LinkStore = new LinkStore(); + /** Per-entity property features + property titles, used to name clusters. */ + readonly #properties: PropertyStore = new PropertyStore(); + + readonly #clusterTree: ClusterTree; + readonly #portCache = new PortCache(); + readonly #edgeAggregator = new EdgeAggregator(); + + readonly #layouts = new LayoutRegistry(); + readonly #view = new CommittedView(); + readonly #leafLocalCache = new LeafLocalCache(); + + #viewport: ViewportState | undefined; + /** Entities kept at full colour while a highlight is active (selection ego today); + * everyone else dims. Empty = no highlight. Set via {@link setHighlight}. */ + #highlightedEntities = new Set(); + + #mode: VizMode = "flat-force"; + /** True when the committed state is the hierarchical (cluster-tree) regime. */ + #hierarchicalActive = false; + + /** + * While true the tick scheduler stays stopped and start requests are + * swallowed (see {@link setSimulationPaused}), so a backgrounded + * visualizer's simulation costs nothing. Everything else (ingest, + * commits, queries) keeps working. + */ + #simulationPaused = false; + + readonly #ticker = new TickScheduler(() => this.#tickLoop.tick()); + readonly #jobs = new JobScheduler(); + + #onLayoutMessage: ((msg: LayoutSideChannelMessage) => void) | undefined; + #onStructureFrame: ((frame: StructureFrame) => void) | undefined; + #onPositionsFrame: ((frame: PositionsFrame) => void) | undefined; + + readonly #ingest = new IngestController({ + entities: this.#entities, + links: this.#links, + properties: this.#properties, + types: this.#types, + typeSets: this.#typeSets, + }); + + readonly #polisher: SettlePolisher; + readonly #portConstraints: PortConstraintController; + + readonly #structureEmitter: StructureFrameEmitter; + readonly #positionsEmitter: PositionsFrameEmitter; + readonly #flatTier: FlatTierController; + readonly #layoutManager: HierarchicalLayoutManager; + readonly #hierarchical: HierarchicalTier; + readonly #embedding: EmbeddingCoordinator; + readonly #tickLoop: TickLoop; + + constructor(rawConfig: VizConfig) { + validateConfig(rawConfig); + // Own a copy: updateConfig mutates it in place, and the caller's object + // (tests pass the shared defaultVizConfig; production passes a shallow + // spread whose groups are still shared) must not observe that. + this.config = cloneVizConfig(rawConfig); + const config = this.config; + + // Colour/size style is module state (hot loops); install it before any + // commit pass runs. + configureEntityStyle(config.entityStyle); + + this.#clusterTree = new ClusterTree(config.clusterSizing); + this.#portConstraints = new PortConstraintController({ + layouts: this.#layouts, + clusterTree: this.#clusterTree, + }); + + this.#polisher = new SettlePolisher({ + viewport: () => this.#viewport, + topLevelPolish: config.topLevelPolish, + untangle: config.untangle, + }); + + this.#structureEmitter = new StructureFrameEmitter({ + config, + view: this.#view, + layouts: this.#layouts, + leafLocalCache: this.#leafLocalCache, + clusterTree: this.#clusterTree, + entities: this.#entities, + typeSets: this.#typeSets, + types: this.#types, + mode: () => this.#mode, + onFrame: (frame) => this.#onStructureFrame?.(frame), + }); + + this.#flatTier = new FlatTierController({ + config, + layouts: this.#layouts, + view: this.#view, + links: this.#links, + entities: this.#entities, + typeSets: this.#typeSets, + types: this.#types, + mode: () => this.#mode, + nodeCount: () => this.#ingest.nodeCount, + snapshotNodeEntityIdxs: () => this.#ingest.snapshotNodeEntityIdxs(), + rootFlipPending: () => this.#ingest.rootFlipPending, + highlightedEntities: () => this.#highlightedEntities, + ensureSchedulerRunning: () => this.#ensureTicking(), + postLayoutMessage: (msg) => this.#onLayoutMessage?.(msg), + emitStructure: (flatGraph) => this.#structureEmitter.emit([], flatGraph), + emitPositions: () => this.#positionsEmitter.emit(), + }); + + this.#positionsEmitter = new PositionsFrameEmitter({ + config, + view: this.#view, + layouts: this.#layouts, + leafLocalCache: this.#leafLocalCache, + clusterTree: this.#clusterTree, + links: this.#links, + edgeAggregator: this.#edgeAggregator, + portCache: this.#portCache, + portConstraints: this.#portConstraints, + flatEdges: this.#flatTier, + syncWorldPositions: () => this.#syncWorldPositions(), + onFrame: (frame) => this.#onPositionsFrame?.(frame), + }); + + this.#layoutManager = new HierarchicalLayoutManager({ + config, + layouts: this.#layouts, + view: this.#view, + polisher: this.#polisher, + portConstraints: this.#portConstraints, + links: this.#links, + entities: this.#entities, + typeSets: this.#typeSets, + types: this.#types, + highlightedEntities: () => this.#highlightedEntities, + ensureSchedulerRunning: () => this.#ensureTicking(), + postLayoutMessage: (msg) => this.#onLayoutMessage?.(msg), + }); + + this.#embedding = new EmbeddingCoordinator({ + config, + clusterTree: this.#clusterTree, + entities: this.#entities, + links: this.#links, + properties: this.#properties, + typeSets: this.#typeSets, + types: this.#types, + jobs: this.#jobs, + bumpClusterEpoch: () => { + this.#view.clusterEpoch += 1; + }, + recommitLabelsOnly: () => this.#recommitLabelsOnly(), + }); + + this.#hierarchical = new HierarchicalTier({ + config, + clusterTree: this.#clusterTree, + view: this.#view, + links: this.#links, + typeSets: this.#typeSets, + types: this.#types, + edgeAggregator: this.#edgeAggregator, + portCache: this.#portCache, + portConstraints: this.#portConstraints, + layoutManager: this.#layoutManager, + polisher: this.#polisher, + embedding: this.#embedding, + structureEmitter: this.#structureEmitter, + positionsEmitter: this.#positionsEmitter, + hierarchicalModeActive: () => this.#mode === "hierarchical-lod", + viewport: () => this.#viewport, + rootFlipPending: () => this.#ingest.rootFlipPending, + requestCommit: (opts) => this.commitStructure(opts), + }); + + this.#tickLoop = new TickLoop({ + config, + layouts: this.#layouts, + clusterTree: this.#clusterTree, + polisher: this.#polisher, + portConstraints: this.#portConstraints, + positionsEmitter: this.#positionsEmitter, + syncWorldPositions: () => this.#syncWorldPositions(), + postLayoutMessage: (msg) => this.#onLayoutMessage?.(msg), + stopScheduler: () => this.#ticker.stop(), + }); + } + + get debug(): boolean { + return this.config.debug; + } + + set onStructureFrame(handler: ((frame: StructureFrame) => void) | undefined) { + this.#onStructureFrame = handler; + } + + set onPositionsFrame(handler: ((frame: PositionsFrame) => void) | undefined) { + this.#onPositionsFrame = handler; + } + + set onLayoutMessage( + handler: ((msg: LayoutSideChannelMessage) => void) | undefined, + ) { + this.#onLayoutMessage = handler; + } + + get mode(): VizMode { + return this.#mode; + } + + /** Node entity count (excludes link entities interned by the EntityStore). */ + get nodeCount(): number { + return this.#ingest.nodeCount; + } + + get linkCount(): number { + return this.#links.count; + } + + /** + * Returns the on-screen representatives of the selected entity's + * neighbors (entities or visible clusters), omitting neighbors outside + * the current cut. + */ + ego(entityIdx: EntityIndex): EgoTarget[] { + return egoTargets(entityIdx, this.#links, this.#view.cutIndex); + } + + /** + * The link entities a clicked highway represents: the union of every + * aggregate lane merged into the same ribbon as `laneId`. + */ + highwayLinks(laneId: number): EntityIndex[] { + return this.#structureEmitter.highwayLinks(laneId); + } + + /** + * Serializes the live flat-tier layout (nodes, deduped edges, Louvain + * communities) for offline replay; null when no flat layout is active. + */ + captureLayoutFixture(): CapturedLayoutFixture | null { + return this.#flatTier.captureFixture(); + } + + /** + * Registers type and property schemas and reports whether either changed + * (so callers can skip no-op commits). + */ + registerTypes( + schemas: readonly TypeSchemaEntry[], + propertySchemas: readonly PropertySchemaEntry[], + ): { + readonly typesChanged: boolean; + readonly propertyTitlesChanged: boolean; + } { + return this.#ingest.registerTypes(schemas, propertySchemas); + } + + /** Inserts one node entity into the stores; returns undefined for duplicates. */ + insertNodeEntity( + entity: IngestEntity, + knownGroup?: TypeSetGroup, + ): { entityIdx: EntityIndex; groupKey: TypeSetKey } | undefined { + return this.#ingest.insertNodeEntity(entity, knownGroup); + } + + /** + * Inserts one link entity and wires endpoints, deferring resolution when + * an endpoint is not interned yet. + */ + insertLinkEntity(entity: IngestEntity): void { + this.#ingest.insertLinkEntity(entity); + } + + /** + * Ingests a batch of entities and returns per-type-set deltas for + * incremental structure commits. + */ + ingestBatch(entities: readonly IngestEntity[]): IngestDelta[] { + return this.#ingest.ingestBatch(entities); + } + + /** Re-evaluate the rendering regime for the current node count. See {@link nextVizMode}. */ + recomputeMode(): VizMode { + this.#mode = nextVizMode(this.#mode, this.nodeCount, this.config); + return this.#mode; + } + + /** + * Replace the live config without recreating the worker: stores, ingest + * state, and the viewport survive; both tiers rebuild their layouts under + * the new tuning and the regime is re-evaluated against the new + * thresholds (crossing a threshold tears down the losing tier as usual). + * + * Mutates {@link config} in place (see {@link assignVizConfigInPlace}) + * because collaborators hold references to it and to its nested groups. + * Values that are read live (LOD fractions, stability thresholds, edge + * budgets, tick diagnostics) apply from here on without further work; the + * forced commit below covers everything the layout engines copied at + * construction. + * + * @throws {Error} When `next` fails {@link validateConfig}; the previous + * config stays fully in effect. + */ + updateConfig(next: VizConfig): void { + validateConfig(next); + assignVizConfigInPlace(this.config, next); + configureEntityStyle(this.config.entityStyle); + + // Layout engines copy their tuning at construction and never re-read + // it, so force a rebuild pass. The flat tier warm-seeds from the live + // layout's positions; the hierarchical tier rebuilds the tree (new + // sizing) and re-seeds top-level bubbles from their persisted + // positions, so both keep the user's mental map. + this.#flatTier.invalidateLayout(); + this.commitStructure({ rebuildTree: this.#hierarchicalActive }); + } + + /** + * Freeze or resume the worker. Any backgrounded worker will be paused, + * worker messages are queued but not resolved until the simulation + * has been resumed again. + */ + setSimulationPaused(paused: boolean): void { + if (this.#simulationPaused === paused) { + return; + } + + this.#simulationPaused = paused; + + if (paused) { + this.#ticker.pause(); + this.#jobs.pause(); + } else { + this.#ticker.resume(); + this.#jobs.resume(); + } + } + + /** + * The scheduler start-gate every layout collaborator routes through: a + * no-op while the simulation is paused, so a backgrounded worker never + * burns ticks. {@link setSimulationPaused} re-arms the scheduler on + * resume when any layout is still running. + */ + #ensureTicking(): void { + this.#ticker.ensureRunning(); + } + + /** Record a new viewport and commit if the LOD cut changed. */ + handleViewport(viewport: ViewportState): void { + // Always recorded, even in flat tiers (which have no worker-side LOD: + // pan/zoom is pure Deck.gl on the main thread), so the first hierarchical + // commit after a scale-up has a viewport to cut against. + this.#viewport = viewport; + this.#hierarchical.handleViewport(viewport); + } + + /** Pin a leaf cluster open (with its ancestors) regardless of zoom. */ + pin(leafId: ClusterId | undefined): void { + this.#hierarchical.pin(leafId); + } + + /** + * Set the highlighted entities. They keep full colour while everyone else + * dims. Empty set restores full colour. + */ + setHighlight(entityIdxs: readonly EntityIndex[]): void { + this.#highlightedEntities = new Set(entityIdxs); + this.#applyHighlight(); + } + + // Highlight is colour-only: mutate shared colour buffers in place and + // re-emit positions so edge beziers pick up the dimming without a layout + // rebuild. + #applyHighlight(): void { + this.#flatTier.restyle(); + + if (this.#view.cutIndex) { + for (const leafId of this.#view.cutIndex.entityModeIds) { + const cluster = this.#clusterTree.get(leafId); + const layout = this.#layouts.get(leafId); + if (cluster && layout) { + writeLeafColors(cluster, layout, { + types: this.#types, + isRoot: (entityIdx) => this.#entities.isRoot(entityIdx), + highlightedEntities: () => this.#highlightedEntities, + }); + } + } + } + + this.#positionsEmitter.emit(); + } + + /** Re-style after an expand flipped a frontier node to a root. */ + restyleIfRootsFlipped(): void { + if (!this.#ingest.consumeRootFlip()) { + return; + } + if (this.#mode === "hierarchical-lod") { + this.#applyHighlight(); + } + } + + /** + * Commit a new topology. Re-evaluates the regime, tears down the tier that + * lost it, and hands the commit to the winning tier ({@link FlatTierController.commit} + * or {@link HierarchicalTier.commit}). Runs only on topology changes, never + * on a position tick. + */ + commitStructure(opts?: { + readonly deltas?: readonly IngestDelta[]; + readonly rebuildTree?: boolean; + /** Precomputed visible cut; ignored when the tree was mutated by this commit. */ + readonly cut?: readonly LodItem[]; + }): void { + this.recomputeMode(); + this.#publishEntityIdMapOnce(); + + // Flat tiers render the whole entity set as one entity graph. Only + // crossing the hierarchical boundary tears the other regime's state down. + if (this.#mode !== "hierarchical-lod") { + if (this.#hierarchicalActive) { + this.#hierarchical.tearDown(); + } + + this.#hierarchicalActive = false; + this.#flatTier.commit(opts); + return; + } + + if (!this.#hierarchicalActive) { + this.#flatTier.tearDown(); + } + + const wasActive = this.#hierarchicalActive; + this.#hierarchicalActive = true; + this.#hierarchical.commit(wasActive, opts); + } + + /** Publish the join-map SharedArrayBuffer to the main thread on first use. */ + #publishEntityIdMapOnce(): void { + if (this.#entityIdMapPublished) { + return; + } + + const map = this.#entities.lookupBuffer; + this.#onLayoutMessage?.({ + type: "ENTITY_ID_MAP", + buffer: map.raw, + capacity: map.capacity, + }); + this.#entityIdMapPublished = true; + } + + /** + * Returns and clears pending embedding-clustering requests queued during + * subdivision; intended to run after each structure frame so the main + * thread can service them. + */ + drainEmbeddingRequests(): EmbeddingClusteringNeededMessage[] { + return this.#embedding.drainRequests(); + } + + /** Apply server-side embedding clustering results. */ + applyEmbeddingResult( + clusterId: ClusterId, + clusters: readonly { + readonly clusterId: number; + readonly entityIds: readonly string[]; + }[], + ): void { + this.#embedding.applyResult(clusterId, clusters); + } + + /** + * Re-emit the structure frame with the current cluster labels, reusing the + * cached cut, {@link CutIndex}, and edge aggregation. Labels are read fresh + * from the tree, so this shows updated names without recomputing topology. + * Falls back to a full commit if the cached topology is gone (mode + * switched, or nothing committed yet). + */ + #recommitLabelsOnly(): void { + const cutIndex = this.#view.cutIndex; + + if ( + this.#mode !== "hierarchical-lod" || + !cutIndex || + !this.#view.edgeFrame + ) { + this.commitStructure(); + return; + } + + this.#structureEmitter.emit( + this.#structureEmitter.buildEntityLayers(cutIndex), + ); + this.#positionsEmitter.emit(); + } + + /** + * Recomputes world-space child circles top-down after cluster layouts + * move, so port anchors and aggregation read propagated positions. + */ + #syncWorldPositions(): void { + syncWorldPositions( + this.#clusterTree.root, + (id) => this.#layouts.get(id), + (id) => this.#layouts.kindOf(id) === "clusters", + ); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-id-codec.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-id-codec.test.ts new file mode 100644 index 00000000000..325cae86fd2 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-id-codec.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { entityIdFromComponents } from "@blockprotocol/type-system"; + +import { + ENTITY_ID_BYTES, + decodeEntityId, + encodeEntityId, +} from "./entity-id-codec"; + +import type { DraftId, EntityUuid, WebId } from "@blockprotocol/type-system"; + +const webIdA = "11111111-1111-4111-8111-111111111111" as WebId; +const uuidA = "22222222-2222-4222-8222-222222222222" as EntityUuid; +const webIdB = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" as WebId; +const uuidB = "12345678-90ab-4cde-8f01-234567890abc" as EntityUuid; +const draftId = "33333333-3333-4333-8333-333333333333" as DraftId; + +const recordBytes = (records: number) => + new Uint8Array(ENTITY_ID_BYTES * records); + +describe("entity-id-codec", () => { + it("round-trips a non-draft EntityId at an arbitrary index", () => { + const bytes = recordBytes(4); + const id = entityIdFromComponents(webIdA, uuidA); + encodeEntityId(bytes, 2, id); + expect(decodeEntityId(bytes, 2)).toBe(id); + }); + + it("preserves the draftId through a round-trip", () => { + const bytes = recordBytes(4); + const id = entityIdFromComponents(webIdA, uuidA, draftId); + encodeEntityId(bytes, 0, id); + expect(decodeEntityId(bytes, 0)).toBe(id); + }); + + it("keeps records independent by index", () => { + const bytes = recordBytes(8); + const first = entityIdFromComponents(webIdA, uuidA); + const second = entityIdFromComponents(webIdB, uuidB, draftId); + encodeEntityId(bytes, 0, first); + encodeEntityId(bytes, 7, second); + expect(decodeEntityId(bytes, 0)).toBe(first); + expect(decodeEntityId(bytes, 7)).toBe(second); + }); + + it("zeroes the draftId slot when overwriting a draft with a non-draft id", () => { + const bytes = recordBytes(2); + encodeEntityId(bytes, 1, entityIdFromComponents(webIdA, uuidA, draftId)); + const plain = entityIdFromComponents(webIdB, uuidB); + encodeEntityId(bytes, 1, plain); + // if the stale draftId leaked, the decoded id would still carry it + expect(decodeEntityId(bytes, 1)).toBe(plain); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-id-codec.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-id-codec.ts new file mode 100644 index 00000000000..aa82e64fda3 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-id-codec.ts @@ -0,0 +1,117 @@ +/* eslint-disable no-bitwise, no-param-reassign */ +/** + * Codec for the EntityIdx to EntityId shared buffer. + * + * Per-record layout: `webId (16) | entityUuid (16) | draftId (16)`, + * each UUID packed to 16 bytes. The draftId slot is all-zero when the + * entity is not a draft. + */ + +import { + type DraftId, + type EntityId, + type EntityUuid, + type WebId, + entityIdFromComponents, + splitEntityId, +} from "@blockprotocol/type-system"; + +const UUID_BYTES = 16; +/** Bytes per record: webId + entityUuid + draftId. */ +export const ENTITY_ID_BYTES = UUID_BYTES * 3; +/** Byte offset where records begin (preceded by an int32 version counter). */ +export const ID_HEADER_BYTES = 4; + +/** Char code to 4-bit nibble value. Valid for '0'-'9', 'A'-'F', 'a'-'f'. */ +// prettier-ignore +const HEX_VAL = new Uint8Array([ +// 0 1 2 3 4 5 6 7 8 9 A B C D E F + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x00 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x10 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x20 + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 0, 0, 0, 0, 0, // 0x30 '0'-'9' + 0,10,11,12,13,14,15, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x40 'A'-'F' + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x50 + 0,10,11,12,13,14,15, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x60 'a'-'f' + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x70 +]); + +/** Byte value to two-char hex string. */ +const BYTE_HEX: readonly string[] = Array.from({ length: 256 }, (_, i) => + i.toString(16).padStart(2, "0"), +); + +const HYPHEN = 0x2d; + +/** Pack a hyphenated UUID string into 16 bytes at `offset` in place. */ +function writeUuid(target: Uint8Array, offset: number, uuid: string): void { + let index = 0; + for (let i = 0; i < UUID_BYTES; i++) { + if (uuid.charCodeAt(index) === HYPHEN) { + index += 1; + } + + // writeUuid only consumes hyphenated UUID hex digits; non-hex input is a + // caller bug. + target[offset + i] = + (HEX_VAL[uuid.charCodeAt(index)]! << 4) | + HEX_VAL[uuid.charCodeAt(index + 1)]!; + index += 2; + } +} + +/** Reconstruct a hyphenated UUID string from 16 bytes at `offset`. */ +function readUuid(source: Uint8Array, offset: number): string { + const b = (i: number): string => BYTE_HEX[source[offset + i]!]!; + return `${b(0)}${b(1)}${b(2)}${b(3)}-${b(4)}${b(5)}-${b(6)}${b(7)}-${b(8)}${b(9)}-${b(10)}${b(11)}${b(12)}${b(13)}${b(14)}${b(15)}`; +} + +/** + * Writes `webId`, `entityUuid`, and `draftId` (zero-filled when absent) into + * the shared lookup buffer at `entityIdx`. Overwrites any prior record at + * that slot. + */ +export function encodeEntityId( + bytes: Uint8Array, + entityIdx: number, + entityId: EntityId, +): void { + const [webId, entityUuid, draftId] = splitEntityId(entityId); + const base = entityIdx * ENTITY_ID_BYTES; + writeUuid(bytes, base, webId); + writeUuid(bytes, base + UUID_BYTES, entityUuid); + const draftOffset = base + UUID_BYTES * 2; + if (draftId) { + writeUuid(bytes, draftOffset, draftId); + } else { + bytes.fill(0, draftOffset, draftOffset + UUID_BYTES); + } +} + +function isZeroSlot(bytes: Uint8Array, offset: number): boolean { + for (let i = 0; i < UUID_BYTES; i++) { + if (bytes[offset + i] !== 0) { + return false; + } + } + return true; +} + +/** + * Reads the packed record at `entityIdx` and rebuilds an {@link EntityId}; + * an all-zero draftId slot means the entity is not a draft. + */ +export function decodeEntityId(bytes: Uint8Array, entityIdx: number): EntityId { + const base = entityIdx * ENTITY_ID_BYTES; + // Bytes were written by encodeEntityId/writeUuid from valid type-system + // UUID strings, so readUuid output satisfies WebId/EntityUuid/DraftId + // branding. + const webId = readUuid(bytes, base) as WebId; + const entityUuid = readUuid(bytes, base + UUID_BYTES) as EntityUuid; + const draftOffset = base + UUID_BYTES * 2; + const isDraft = !isZeroSlot(bytes, draftOffset); + const draftId = isDraft + ? (readUuid(bytes, draftOffset) as DraftId) + : undefined; + return entityIdFromComponents(webId, entityUuid, draftId); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-style.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-style.test.ts new file mode 100644 index 00000000000..8dca670a2e7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-style.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; + +import { + colorForType, + edgeColorForType, + primaryTypeOfSet, + radiusForDegree, +} from "./entity-style"; +import { TypeRegistry } from "./store/type-registry"; + +import type { TypeId } from "../ids"; +import type { TypeSchemaEntry } from "./protocol"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +const url = (slug: string): VersionedUrl => + `https://example.com/types/entity-type/${slug}/v/1` as VersionedUrl; + +/** Recover the HSL hue (degrees) from an RGB colour, to assert "hue by root". */ +function hueOf(color: readonly number[]): number { + const red = color[0]! / 255; + const green = color[1]! / 255; + const blue = color[2]! / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + if (delta < 1e-6) { + return 0; + } + let hue: number; + if (max === red) { + hue = ((green - blue) / delta) % 6; + } else if (max === green) { + hue = (blue - red) / delta + 2; + } else { + hue = (red - green) / delta + 4; + } + hue *= 60; + return hue < 0 ? hue + 360 : hue; +} + +function buildRegistry(): { + registry: TypeRegistry; + company: TypeId; + customer: TypeId; + supplier: TypeId; + person: TypeId; +} { + const company = url("company"); + const customer = url("customer"); + const supplier = url("supplier"); + const person = url("person"); + + const schemas: TypeSchemaEntry[] = [ + { url: customer, title: "Customer", allOfRefs: [company] }, + { url: supplier, title: "Supplier", allOfRefs: [company] }, + { url: company, title: "Company", allOfRefs: [] }, + { url: person, title: "Person", allOfRefs: [] }, + ]; + + const registry = new TypeRegistry(); + registry.registerAll(schemas); + + return { + registry, + company: registry.intern(company), + customer: registry.intern(customer), + supplier: registry.intern(supplier), + person: registry.intern(person), + }; +} + +describe("entity-style colour", () => { + it("picks the most specific type as the primary", () => { + const { registry, company, customer } = buildRegistry(); + expect(primaryTypeOfSet([company, customer], registry)).toBe(customer); + expect(primaryTypeOfSet([company], registry)).toBe(company); + }); + + it("gives siblings the SAME family hue but DIFFERENT shades", () => { + const { registry, customer, supplier } = buildRegistry(); + const customerColor = colorForType(customer, registry); + const supplierColor = colorForType(supplier, registry); + + // Same root (Company) → same hue; lightness jitter keeps them distinct. + expect(Math.abs(hueOf(customerColor) - hueOf(supplierColor))).toBeLessThan( + 5, + ); + expect(customerColor).not.toEqual(supplierColor); + }); + + it("gives different roots clearly different hues", () => { + const { registry, customer, person } = buildRegistry(); + const customerHue = hueOf(colorForType(customer, registry)); + const personHue = hueOf(colorForType(person, registry)); + expect(Math.abs(customerHue - personHue)).toBeGreaterThan(10); + }); + + it("gives link types enough hue separation to distinguish relationship lanes", () => { + const hasMember = url("has-member"); + const manages = url("manages"); + const registry = new TypeRegistry(); + registry.registerAll([ + { url: hasMember, title: "Has Member", allOfRefs: [] }, + { url: manages, title: "Manages", allOfRefs: [] }, + ]); + + const firstHue = hueOf( + edgeColorForType(registry.intern(hasMember), registry), + ); + const secondHue = hueOf( + edgeColorForType(registry.intern(manages), registry), + ); + + expect(Math.abs(firstHue - secondHue)).toBeGreaterThan(40); + }); + + it("is deterministic", () => { + const { registry, customer } = buildRegistry(); + expect(colorForType(customer, registry)).toEqual( + colorForType(customer, registry), + ); + }); + + it("gives a type the same colour regardless of type arrival order", () => { + // Colour slots are sorted by base URL within each batch, so registration + // order must not change the hue for the same type. + const company = url("company"); + const customer = url("customer"); + const person = url("person"); + + const forward = new TypeRegistry(); + forward.registerAll([ + { url: customer, title: "Customer", allOfRefs: [company] }, + { url: company, title: "Company", allOfRefs: [] }, + { url: person, title: "Person", allOfRefs: [] }, + ]); + + const reversed = new TypeRegistry(); + reversed.registerAll([ + { url: person, title: "Person", allOfRefs: [] }, + { url: company, title: "Company", allOfRefs: [] }, + { url: customer, title: "Customer", allOfRefs: [company] }, + ]); + + expect(colorForType(forward.intern(customer), forward)).toEqual( + colorForType(reversed.intern(customer), reversed), + ); + expect(colorForType(forward.intern(person), forward)).toEqual( + colorForType(reversed.intern(person), reversed), + ); + }); + + it("falls back to a neutral grey for an unknown type or root", () => { + const { registry } = buildRegistry(); + expect(colorForType(undefined, registry)).toEqual([126, 142, 160, 220]); + }); + + it("sizes by degree subtly and monotonically", () => { + const radius0 = radiusForDegree(0); + const radius5 = radiusForDegree(5); + const radius50 = radiusForDegree(50); + expect(radius0).toBeGreaterThan(0); + expect(radius5).toBeGreaterThan(radius0); + expect(radius50).toBeGreaterThan(radius5); + // radiusForDegree uses ln scaling so high degree does not dominate dot size. + expect(radius50).toBeLessThan(radius0 * 3); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-style.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-style.ts new file mode 100644 index 00000000000..afe1cc47d0b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entity-style.ts @@ -0,0 +1,206 @@ +/** + * Per-entity visual style: colour by type hierarchy, size by degree. + * + * Hue is assigned per root type via the golden angle. Lightness encodes + * depth in the `allOf` tree, so subtypes within the same family are + * distinguishable shades of one hue. A hash-based jitter separates + * siblings at the same depth. + */ +import { extractBaseUrl } from "@blockprotocol/type-system"; + +import { oklchToRgb } from "../math/color"; +import { murmur3StringUnit } from "../math/hash"; +import { graphColors } from "../visual-style"; + +import type { Color } from "../frames"; +import type { TypeId } from "../ids"; +import type { TypeRegistry } from "./store/type-registry"; + +/** Hue step between successive colour slots (degrees). */ +const GOLDEN_ANGLE_DEG = 137.508; + +const NEUTRAL: Color = [...graphColors.fallbackEntity]; +const EDGE_NEUTRAL: Color = [...graphColors.fallbackEntity]; + +/** Colour for a frontier node (fetched link endpoint, not itself a query root). */ +export const FRONTIER_COLOR: Color = [...graphColors.frontier]; + +/** + * Visual style of entity dots and flat-tier edges (colour by type hierarchy, + * size by degree), including the hub-link fading applied by + * {@link "./entity-graph/flat/edges"}. + */ +export interface EntityStyleConfig { + /** OKLCH chroma of entity dots. @defaultValue 0.13. */ + readonly nodeChroma: number; + /** OKLCH lightness of a root type's dots. @defaultValue 0.55. */ + readonly rootLightness: number; + /** Lightness added per `allOf` depth step, shading subtypes. @defaultValue 0.04. */ + readonly lightnessPerDepth: number; + /** Hash-jitter half-range separating same-depth sibling types. @defaultValue 0.035. */ + readonly siblingJitter: number; + /** Lightness clamp floor. @defaultValue 0.4. */ + readonly minLightness: number; + /** Lightness clamp ceiling. @defaultValue 0.78. */ + readonly maxLightness: number; + /** Depth beyond which shading stops darkening. @defaultValue 4. */ + readonly maxShadeDepth: number; + /** Dot alpha (0-255). @defaultValue 220. */ + readonly dotAlpha: number; + /** Base dot radius in world units. @defaultValue 4. */ + readonly dotBaseRadius: number; + /** Degree factor: radius = base × (1 + ln(1 + degree) × this). @defaultValue 0.35. */ + readonly dotDegreeScale: number; + /** OKLCH chroma of typed edges. @defaultValue 0.09. */ + readonly edgeChroma: number; + /** OKLCH lightness of typed edges. @defaultValue 0.58. */ + readonly edgeLightness: number; + /** Edge alpha (0-255). @defaultValue 180. */ + readonly edgeAlpha: number; + /** Flat-tier edge stroke width in world units (the layer scales it with zoom). @defaultValue 1.2. */ + readonly flatEdgeWidth: number; + /** Endpoint degree at which hub-link fading starts. @defaultValue 8. */ + readonly hubLinkFadeStartDegree: number; + /** Endpoint degree at which hub-link fading saturates. @defaultValue 128. */ + readonly hubLinkFadeEndDegree: number; + /** Alpha-scale floor for fully-faded hub links. @defaultValue 0.3. */ + readonly hubLinkFadeMinScale: number; +} + +export const defaultEntityStyleConfig: EntityStyleConfig = { + nodeChroma: 0.13, + rootLightness: 0.55, + lightnessPerDepth: 0.04, + siblingJitter: 0.035, + minLightness: 0.4, + maxLightness: 0.78, + maxShadeDepth: 4, + dotAlpha: 220, + dotBaseRadius: 4, + dotDegreeScale: 0.35, + edgeChroma: 0.09, + edgeLightness: 0.58, + edgeAlpha: 180, + flatEdgeWidth: 1.2, + hubLinkFadeStartDegree: 8, + hubLinkFadeEndDegree: 128, + hubLinkFadeMinScale: 0.3, +}; + +/** + * The active style, read by the colour/radius functions below. Module state + * rather than a threaded parameter because these run in per-node hot loops on + * both threads (worker commit passes, main-thread hub labels); each thread + * calls {@link configureEntityStyle} once at init with the live config. + */ +let style: EntityStyleConfig = defaultEntityStyleConfig; + +/** Install the live style on this thread (worker init / visualizer mount). */ +export function configureEntityStyle(config: EntityStyleConfig): void { + style = config; +} + +/** The active {@link EntityStyleConfig} on this thread. */ +export function entityStyle(): EntityStyleConfig { + return style; +} + +function slotHue( + typeIdx: TypeId | undefined, + types: TypeRegistry, +): number | undefined { + if (typeIdx === undefined) { + return undefined; + } + const slot = types.colorSlot(typeIdx); + return slot === undefined ? undefined : (slot * GOLDEN_ANGLE_DEG) % 360; +} + +/** + * The most specific type in a set: greatest `allOf` depth, ties broken + * by the smallest idx. + */ +export function primaryTypeOfSet( + typeIdxs: Iterable, + types: TypeRegistry, +): TypeId | undefined { + let best: TypeId | undefined; + let bestDepth = -1; + for (const typeIdx of typeIdxs) { + const depth = types.get(typeIdx)?.depth ?? 0; + if ( + best === undefined || + depth > bestDepth || + (depth === bestDepth && typeIdx < best) + ) { + best = typeIdx; + bestDepth = depth; + } + } + return best; +} + +/** + * Maps a type to an OKLCH RGBA dot colour from its root hue slot, depth + * shading, and sibling jitter; returns neutral grey when the type or root + * slot is missing. + */ +export function colorForType( + typeIdx: TypeId | undefined, + types: TypeRegistry, +): Color { + if (typeIdx === undefined) { + return NEUTRAL; + } + const info = types.get(typeIdx); + if (info === undefined) { + return NEUTRAL; + } + const hue = slotHue(info.rootIds[0], types); + if (hue === undefined) { + return NEUTRAL; + } + const depth = Math.min(info.depth, style.maxShadeDepth); + + const fraction = murmur3StringUnit(extractBaseUrl(info.url)); + + const jitter = (fraction - 0.5) * 2 * style.siblingJitter; + const lightness = Math.min( + style.maxLightness, + Math.max( + style.minLightness, + style.rootLightness + depth * style.lightnessPerDepth + jitter, + ), + ); + const [red, green, blue] = oklchToRgb(lightness, style.nodeChroma, hue); + return [red, green, blue, style.dotAlpha]; +} + +/** + * Colour for an edge by its link type. + * + * Uses the link type's own colour slot rather than its root's, because + * all link types share a single root. + */ +export function edgeColorForType( + typeIdx: TypeId | undefined, + types: TypeRegistry, +): Color { + const hue = slotHue(typeIdx, types); + if (hue === undefined) { + return EDGE_NEUTRAL; + } + const [red, green, blue] = oklchToRgb( + style.edgeLightness, + style.edgeChroma, + hue, + ); + return [red, green, blue, style.edgeAlpha]; +} + +/** By-degree dot radius in world units: `base * (1 + ln(1 + deg) * k)`. */ +export function radiusForDegree(degree: number): number { + return ( + style.dotBaseRadius * (1 + Math.log(1 + degree) * style.dotDegreeScale) + ); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entry.ts new file mode 100644 index 00000000000..8be9b41ce21 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/entry.ts @@ -0,0 +1,399 @@ +/** + * Web worker entry point: the first message selects the lifecycle + * (`INIT_ENTITY` boots an {@link EntityGraphWorker}, `INIT_TYPE` a + * {@link TypeGraphWorker}), then every later message dispatches into it and + * its replies and frames post back to the main thread. Each connection + * spawns its own Worker, so exactly one lifecycle ever runs per worker + * process. + * + * Entity lifecycle: `INGEST_BATCH` applies immediately to the stores, but + * the resulting structure commit is coalesced across a burst (see + * {@link CommitCoalescer}); every other handler that reads committed state + * (viewport, queries, embedding results, pin/highlight) flushes the + * coalescer first so it never observes a stale cut or layout. The type + * lifecycle commits per `INGEST_TYPES` message (no coalescer: type graphs + * arrive in a few batches, not streamed). Buffers attached to + * `STRUCTURE_FRAME` and `POSITIONS_FRAME` are exclusively worker-owned until + * transferred in `postMessage`. + */ + +import { CommitCoalescer } from "./core/commit-coalescer"; +import { EntityGraphWorker } from "./entity-graph/worker"; +import { TypeGraphWorker } from "./type-graph/worker"; + +import type { PositionsFrame, StructureFrame } from "../frames"; +import type { MainToWorkerMessage, WorkerToMainMessage } from "./protocol"; +import type { + MainToTypeWorkerMessage, + TypeEgoResultMessage, + TypeIdTableMessage, +} from "./type-graph/protocol"; + +const workerScope = globalThis as unknown as DedicatedWorkerGlobalScope; + +function post( + message: WorkerToMainMessage | TypeIdTableMessage | TypeEgoResultMessage, + transfer?: Transferable[], +): void { + workerScope.postMessage(message, transfer ?? []); +} + +function postStructure(frame: StructureFrame): void { + // The per-leaf edge-topology arrays are freshly built and owned by no one + // else, so transfer their backing buffers. + const transfer: Transferable[] = []; + for (const layer of frame.entityLayers) { + transfer.push(layer.internalEdges.buffer); + } + post({ type: "STRUCTURE_FRAME", frame }, transfer); +} + +function postPositions(frame: PositionsFrame): void { + const transfer: Transferable[] = [ + frame.clusterPositions.buffer, + frame.beziers.positions.buffer, + frame.beziers.colors.buffer, + frame.beziers.widths.buffer, + frame.beziers.clips.buffer, + frame.beziers.ids.buffer, + ]; + + if (frame.flatArrows) { + transfer.push( + frame.flatArrows.positions.buffer, + frame.flatArrows.angles.buffer, + frame.flatArrows.sizes.buffer, + frame.flatArrows.chords.buffer, + frame.flatArrows.colors.buffer, + ); + } + + // Fan-out buffers are not shared with the main thread, so transfer avoids + // a copy each tick. + for (const entry of frame.entityFanOut) { + transfer.push(entry.fanOut.buffer); + } + + post({ type: "POSITIONS_FRAME", frame }, transfer); +} + +let worker: EntityGraphWorker | undefined; +let typeWorker: TypeGraphWorker | undefined; + +/** + * Coalesces per-batch commits during an ingest burst: batches are + * ingested into the stores on arrival, but the commit (layout absorb, render + * edges, structure frame -> main-thread rescans) runs once per burst. See + * {@link CommitCoalescer} for the latency policy. + */ +let commits: CommitCoalescer | undefined; + +let drainScheduled = false; + +function scheduleDrain(): void { + if (drainScheduled) { + return; + } + drainScheduled = true; + void Promise.resolve().then(() => { + drainScheduled = false; + if (!worker) { + return; + } + for (const request of worker.drainEmbeddingRequests()) { + post(request); + } + }); +} + +/** + * Build the coalescer against the worker's live ingest tuning. Called at + * INIT and again on UPDATE_CONFIG (after a flush) because the coalescer + * copies its caps at construction. + */ +function createCommitCoalescer(created: EntityGraphWorker): CommitCoalescer { + return new CommitCoalescer({ + maxCoalescedBatches: created.config.ingest.maxCoalescedBatches, + maxCoalesceDelayMs: created.config.ingest.maxCoalesceDelayMs, + commit: ({ deltas, rebuildTree }) => { + const t0 = performance.now(); + created.commitStructure({ deltas, rebuildTree }); + // Re-style if an expand flipped an already-rendered frontier node to a root + // (hierarchical tier only; the flat tier already restyled in the commit). + // Must run after the commit: it consumes the pending-flip flag the flat + // commit's style pass reads. + created.restyleIfRootsFlipped(); + if (created.debug) { + // eslint-disable-next-line no-console + console.debug( + `[graph-worker][commit] ${created.nodeCount} nodes, ` + + `${created.linkCount} links | ` + + `${deltas.length} group deltas` + + `${rebuildTree ? " + tree rebuild" : ""} | ` + + `${(performance.now() - t0).toFixed(1)}ms`, + ); + } + // commitStructure may enqueue EMBEDDING_CLUSTERING_NEEDED messages + // that must post before the next ingest batch is coalesced. + scheduleDrain(); + }, + }); +} + +globalThis.onmessage = ({ + data, +}: MessageEvent) => { + switch (data.type) { + case "INIT_ENTITY": { + try { + const created = new EntityGraphWorker(data.config); + worker = created; + worker.registerTypes(data.typeSchemas, data.propertySchemas); + worker.onLayoutMessage = (msg) => post(msg); + worker.onStructureFrame = (frame) => postStructure(frame); + worker.onPositionsFrame = (frame) => postPositions(frame); + commits = createCommitCoalescer(created); + + post({ type: "READY" }); + } catch (err) { + post({ type: "ERROR", message: String(err) }); + } + break; + } + + case "INIT_TYPE": { + try { + const created = new TypeGraphWorker(data.config); + typeWorker = created; + created.onSideMessage = (msg) => post(msg); + created.onStructureFrame = (frame) => postStructure(frame); + created.onPositionsFrame = (frame) => postPositions(frame); + + post({ type: "READY" }); + } catch (err) { + post({ type: "ERROR", message: String(err) }); + } + break; + } + + case "INGEST_TYPES": { + if (!typeWorker) { + post({ type: "ERROR", message: "Worker not initialized" }); + break; + } + + try { + typeWorker.ingest(data.nodes, data.edges, data.linkTypeSchemas); + } catch (err) { + post({ type: "ERROR", message: String(err), context: "INGEST_TYPES" }); + } + break; + } + + case "SET_TYPE_HIGHLIGHT": { + typeWorker?.setHighlight(data.typeIds); + break; + } + + case "QUERY_TYPE_EGO": { + if (!typeWorker) { + break; + } + post({ + type: "TYPE_EGO_RESULT", + requestId: data.requestId, + typeIds: typeWorker.ego(data.typeId), + }); + break; + } + + case "UPDATE_CONFIG": { + if (typeWorker) { + try { + typeWorker.updateConfig(data.config); + } catch (err) { + post({ + type: "ERROR", + message: String(err), + context: "UPDATE_CONFIG", + }); + } + break; + } + + if (!worker) { + post({ type: "ERROR", message: "Worker not initialized" }); + break; + } + + try { + // Batches already ingested under the old tuning must commit before + // the config-driven re-layout so it covers them. + commits?.flush(); + worker.updateConfig(data.config); + // The coalescer copied its caps at construction; recreate it (it is + // empty after the flush above) so new ingest tuning takes effect. + commits = createCommitCoalescer(worker); + } catch (err) { + post({ type: "ERROR", message: String(err), context: "UPDATE_CONFIG" }); + } + break; + } + + case "REGISTER_TYPES": { + if (!worker) { + post({ type: "ERROR", message: "Worker not initialized" }); + break; + } + + try { + const { typesChanged } = worker.registerTypes( + data.typeSchemas, + data.propertySchemas, + ); + // Only a genuinely new type changes grouping/colour, so only then does + // the tree need rebuilding. Identical re-registrations (the common + // case) and property-only additions do not: top-level cluster + // re-layout on graph growth is triggered by ingest when a child + // layout outgrows its parent bubble (clusterLayoutOutgrown), not by + // type registration alone. Skipping rebuild on identical + // re-registration keeps the overview responsive while ingest drives + // top-level re-layout. + // + // The rebuild rides the coalescer so the frontier-expansion flow + // (REGISTER_TYPES immediately followed by INGEST_BATCHes) lands as one + // commit carrying both the rebuildTree flag and the merged deltas. A + // lone registration flushes on the next queue drain (~a macrotask). + if (typesChanged) { + commits?.enqueueRebuildTree(); + } + } catch (err) { + post({ type: "ERROR", message: String(err) }); + } + break; + } + + case "INGEST_BATCH": { + if (!worker) { + post({ type: "ERROR", message: "Worker not initialized" }); + break; + } + + const t0 = performance.now(); + const deltas = worker.ingestBatch(data.entities); + const tIngest = performance.now(); + // The stores now hold the batch; the commit (and its restyle/debug log) + // is deferred to the coalescer so a burst of queued batches pays for one. + commits?.enqueueDeltas(deltas); + if (worker.debug) { + // eslint-disable-next-line no-console + console.debug( + `[graph-worker][ingest] +${data.entities.length} -> ${worker.nodeCount} nodes, ` + + `${worker.linkCount} links | ` + + `ingest ${(tIngest - t0).toFixed(1)}ms`, + ); + } + scheduleDrain(); + break; + } + + case "VIEWPORT_CHANGED": { + if (!worker) { + break; + } + // Everything below INGEST_BATCH observes committed state (cuts, layouts, + // the cluster tree), so a pending coalesced commit must land first. + commits?.flush(); + worker.handleViewport({ + zoom: data.zoom, + centerX: data.center[0], + centerY: data.center[1], + width: data.width, + height: data.height, + }); + scheduleDrain(); + break; + } + + case "EMBEDDING_CLUSTERING_RESULT": { + if (!worker) { + break; + } + commits?.flush(); + worker.applyEmbeddingResult(data.clusterId, data.clusters); + worker.commitStructure(); + break; + } + + case "QUERY_EGO": { + if (!worker) { + break; + } + commits?.flush(); + post({ + type: "EGO_RESULT", + requestId: data.requestId, + targets: worker.ego(data.entityIdx), + }); + break; + } + + case "QUERY_HIGHWAY_LINKS": { + if (!worker) { + break; + } + commits?.flush(); + post({ + type: "HIGHWAY_LINKS_RESULT", + requestId: data.requestId, + linkEntityIdxs: worker.highwayLinks(data.laneId), + }); + break; + } + + case "CAPTURE_LAYOUT_FIXTURE": { + if (!worker) { + break; + } + commits?.flush(); + post({ + type: "LAYOUT_FIXTURE_RESULT", + requestId: data.requestId, + fixture: worker.captureLayoutFixture(), + }); + break; + } + + case "SET_PINNED": { + if (!worker) { + break; + } + commits?.flush(); + worker.pin(data.clusterId ?? undefined); + break; + } + + case "SET_HIGHLIGHT": { + if (!worker) { + break; + } + commits?.flush(); + worker.setHighlight(data.entityIdxs); + break; + } + + case "SET_SIMULATION_PAUSED": { + // No coalescer flush: pausing gates the tick scheduler only and reads + // no committed state; a pending commit lands (without ticking) on the + // next queue drain as usual. + worker?.setSimulationPaused(data.paused); + typeWorker?.setSimulationPaused(data.paused); + break; + } + + default: { + const _: never = data; + break; + } + } +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/bubble-ports.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/bubble-ports.ts new file mode 100644 index 00000000000..a8a45d2e833 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/bubble-ports.ts @@ -0,0 +1,466 @@ +/* eslint-disable id-length, no-param-reassign */ +/** + * Bubble ports: dedicated boundary points where edges enter or exit a cluster. + * + * For each pair of connected visible clusters, a port on each cluster faces + * the other. Minimum angular separation is enforced; when too many neighbors + * lie in similar directions, ports merge into angular sectors. Port assignments + * are cached and only recompute when the connected neighbor set changes. + */ + +import type { VizConfig } from "../../config"; +import type { Circle, Position } from "../../geometry"; +import type { ClusterId } from "../../ids"; +import type { ClusterTree } from "../hierarchy/cluster-tree"; + +export interface PairInfo { + readonly sourceId: ClusterId; + readonly targetId: ClusterId; + readonly totalCount: number; + readonly byType: ReadonlySet; +} + +export interface Port extends Position { + readonly clusterId: ClusterId; + readonly neighborId: ClusterId; + /** + * Every neighbor this port serves. For unmerged ports this is a + * single-element array equal to `[neighborId]`. For ports merged by + * angular sector it holds all neighbors collapsed into the sector, so + * lookups keyed by neighbor (e.g. entity fan-out feeders) can resolve + * every neighbor to its port instead of only the representative. + */ + readonly allNeighborIds: readonly ClusterId[]; + readonly angle: number; + + readonly edgeCount: number; + readonly distinctTypes: number; +} + +interface RawPort { + readonly neighborId: ClusterId; + angle: number; + readonly edgeCount: number; + readonly distinctTypes: number; +} + +function makePort( + clusterId: ClusterId, + neighborId: ClusterId, + allNeighborIds: readonly ClusterId[], + angle: number, + circle: Circle, + padding: number, + edgeCount: number, + distinctTypes: number, +): Port { + const r = circle.radius + padding; + return { + clusterId, + neighborId, + allNeighborIds, + angle, + x: circle.x + r * Math.cos(angle), + y: circle.y + r * Math.sin(angle), + edgeCount, + distinctTypes, + }; +} + +/** A single port's reserved arc is capped here so one can't hog the rim. */ +const MAX_PORT_ARC = Math.PI / 3; + +/** + * Pool Adjacent Violators (PAVA): optimal monotone non-decreasing least-squares + * fit of `desired`, in O(n). See {@link placePortsOnPerimeter} for how the + * isotonic regression maps to port placement. + */ +function poolAdjacentViolators(desired: readonly number[]): number[] { + const blocks: { sum: number; count: number }[] = []; + for (const value of desired) { + let block = { sum: value, count: 1 }; + while ( + blocks.length > 0 && + blocks[blocks.length - 1]!.sum / blocks[blocks.length - 1]!.count > + block.sum / block.count + ) { + const prev = blocks.pop()!; + block = { sum: prev.sum + block.sum, count: prev.count + block.count }; + } + blocks.push(block); + } + + const result: number[] = []; + for (const block of blocks) { + const mean = block.sum / block.count; + for (let i = 0; i < block.count; i++) { + result.push(mean); + } + } + return result; +} + +/** + * Place ports on the bubble's perimeter with minimum displacement, preserving + * cyclic order and enforcing arc-based non-overlap. + * + * Each port wants its ideal angle (straight toward its neighbor). Ports reserve + * an arc proportional to lane count and may not overlap. The cycle is cut at the + * pair with the most slack, then PAVA solves the resulting 1-D isotonic problem + * exactly. + * + * `ports` is pre-sorted ascending by ideal angle; mutates `port.angle`. + */ +function placePortsOnPerimeter( + ports: RawPort[], + minArc: number, + maxArc: number, +): void { + const n = ports.length; + if (n < 2) { + return; + } + + // Scale reserved arcs uniformly when their sum exceeds the rim budget so + // PAVA always has a feasible domain. + const arc = ports.map((port) => + Math.min(maxArc, Math.max(minArc, minArc * port.distinctTypes)), + ); + let total = 0; + for (const value of arc) { + total += value; + } + // Leave ~2% angular slack on the rim so numeric packing does not pin the + // last port to 2π exactly. + const budget = 2 * Math.PI * 0.98; + if (total > budget) { + const scale = budget / total; + for (let i = 0; i < n; i++) { + arc[i] = arc[i]! * scale; + } + } + + // Minimum angular separation between port i and i+1 (half the sum of + // their reserved arcs). + const gapAfter = (i: number): number => (arc[i]! + arc[(i + 1) % n]!) / 2; + + // Cut the cycle at the consecutive pair with the most slack, so no separation + // constraint spans the cut and the remainder is a linear chain. + let cutAfter = n - 1; + let bestSlack = -Infinity; + for (let i = 0; i < n; i++) { + const next = (i + 1) % n; + let gap = ports[next]!.angle - ports[i]!.angle; + if (next === 0) { + gap += 2 * Math.PI; + } + const slack = gap - gapAfter(i); + if (slack > bestSlack) { + bestSlack = slack; + cutAfter = i; + } + } + + // Unwrap angles monotonically so the cut cycle becomes a chain for + // isotonic regression. + const order: number[] = []; + for (let k = 0; k < n; k++) { + order.push((cutAfter + 1 + k) % n); + } + const theta: number[] = []; + let running = ports[order[0]!]!.angle; + theta.push(running); + for (let k = 1; k < n; k++) { + let angle = ports[order[k]!]!.angle; + while (angle < running) { + angle += 2 * Math.PI; + } + theta.push(angle); + running = angle; + } + + // Subtract required gaps to get desired centres, run PAVA, then add gaps + // back to recover feasible angles. + const prefix: number[] = [0]; + for (let k = 1; k < n; k++) { + prefix.push(prefix[k - 1]! + gapAfter(order[k - 1]!)); + } + const fitted = poolAdjacentViolators( + theta.map((value, k) => value - prefix[k]!), + ); + for (let k = 0; k < n; k++) { + ports[order[k]!]!.angle = fitted[k]! + prefix[k]!; + } +} + +/** + * Collapses excess neighbor ports into fixed angular sectors, keyed so every + * neighbor in a sector resolves to the same merged port. + */ +function mergeByAngularSector( + ports: readonly RawPort[], + sectorCount: number, + clusterId: ClusterId, + circle: Circle, + padding: number, +): Map { + const sectorWidth = (2 * Math.PI) / sectorCount; + const sectors: RawPort[][] = Array.from({ length: sectorCount }, () => []); + + for (const port of ports) { + // Map angle from [-π, π] to [0, 2π) for sector assignment. + let normalized = port.angle + Math.PI; + if (normalized < 0) { + normalized += 2 * Math.PI; + } + if (normalized >= 2 * Math.PI) { + normalized -= 2 * Math.PI; + } + const sector = Math.min( + Math.floor(normalized / sectorWidth), + sectorCount - 1, + ); + sectors[sector]!.push(port); + } + + const result = new Map(); + + for (const group of sectors) { + if (group.length === 0) { + continue; + } + + // atan2 of summed unit vectors so merged ports sit at the sector's mean + // direction, not a linear average that wraps badly. + let sinSum = 0; + let cosSum = 0; + let totalEdges = 0; + let totalTypes = 0; + for (const p of group) { + sinSum += Math.sin(p.angle); + cosSum += Math.cos(p.angle); + totalEdges += p.edgeCount; + totalTypes += p.distinctTypes; + } + const meanAngle = Math.atan2(sinSum / group.length, cosSum / group.length); + + const merged = makePort( + clusterId, + // group is non-empty (we skipped empty sectors). neighborId is only + // the map representative: allNeighborIds lists every neighbor in the + // sector. + group[0]!.neighborId, + group.map((p) => p.neighborId), + meanAngle, + circle, + padding, + totalEdges, + totalTypes, + ); + + for (const p of group) { + result.set(p.neighborId, merged); + } + } + + return result; +} + +/** + * Places or sector-merges neighbor ports on one cluster's rim, enforcing + * minimum angular separation from config. + */ +function slotPorts( + clusterId: ClusterId, + rawPorts: RawPort[], + circle: Circle, + config: VizConfig, +): Map { + if (rawPorts.length === 0) { + return new Map(); + } + + rawPorts.sort((a, b) => a.angle - b.angle); + + // Slotting is zoom-independent: ports recompute only when neighbor set + // or cluster positions change, not on pan/zoom. + const portCap = config.maxPortsPerCluster; + const minSepAngle = (2 * Math.PI) / portCap; + + if (rawPorts.length <= portCap) { + placePortsOnPerimeter(rawPorts, minSepAngle, MAX_PORT_ARC); + + const result = new Map(); + for (const p of rawPorts) { + result.set( + p.neighborId, + makePort( + clusterId, + p.neighborId, + [p.neighborId], + p.angle, + circle, + config.portPaddingWorld, + p.edgeCount, + p.distinctTypes, + ), + ); + } + return result; + } + + return mergeByAngularSector( + rawPorts, + portCap, + clusterId, + circle, + config.portPaddingWorld, + ); +} + +/** + * Caches port assignments per cluster, keyed by a signature of the + * cluster's circle, each neighbor's center + lane counts, and the + * neighbor set. Ports reuse cached positions until an input changes. + */ +export class PortCache { + readonly #cache = new Map< + ClusterId, + { readonly key: string; readonly ports: Map } + >(); + + get(clusterId: ClusterId, key: string): Map | undefined { + const entry = this.#cache.get(clusterId); + if (!entry) { + return undefined; + } + return entry.key === key ? entry.ports : undefined; + } + + set(clusterId: ClusterId, key: string, ports: Map): void { + this.#cache.set(clusterId, { key, ports }); + } + + clear(): void { + this.#cache.clear(); + } +} + +interface NeighborInfo { + readonly neighborId: ClusterId; + readonly edgeCount: number; + readonly distinctTypes: number; +} + +function addNeighborInfo( + map: Map, + from: ClusterId, + to: ClusterId, + edgeCount: number, + distinctTypes: number, +): void { + let list = map.get(from); + if (!list) { + list = []; + map.set(from, list); + } + list.push({ neighborId: to, edgeCount, distinctTypes }); +} + +/** + * Builds per-cluster port maps (with cache reuse) and pairs source/target + * ports for every connected cluster pair. + */ +export function computeAllPorts( + pairs: ReadonlyMap, + clusterTree: ClusterTree, + config: VizConfig, + cache: PortCache, +): Map { + const clusterNeighbors = new Map(); + + for (const pair of pairs.values()) { + addNeighborInfo( + clusterNeighbors, + pair.sourceId, + pair.targetId, + pair.totalCount, + pair.byType.size, + ); + addNeighborInfo( + clusterNeighbors, + pair.targetId, + pair.sourceId, + pair.totalCount, + pair.byType.size, + ); + } + + // Compute slotted ports per cluster; reuse PortCache entries until the + // cache key (circle + neighbor positions + lane counts) changes. + const portsByCluster = new Map>(); + + for (const [clusterId, neighbors] of clusterNeighbors) { + const cluster = clusterTree.get(clusterId); + if (!cluster) { + continue; + } + + const rawPorts: RawPort[] = []; + const sigParts: string[] = []; + for (const info of neighbors) { + const neighbor = clusterTree.get(info.neighborId); + if (!neighbor) { + continue; + } + + rawPorts.push({ + neighborId: info.neighborId, + angle: Math.atan2( + neighbor.circle.y - cluster.circle.y, + neighbor.circle.x - cluster.circle.x, + ), + edgeCount: info.edgeCount, + distinctTypes: info.distinctTypes, + }); + // The signature covers everything slotPorts reads: neighbor positions + // (angles) and per-neighbor lane counts (reserved arc widths). Counts + // change without movement when links stream in between two settled + // clusters; a position-only key would keep serving stale arcs. + sigParts.push( + `${info.neighborId}@${neighbor.circle.x.toFixed(2)},${neighbor.circle.y.toFixed(2)}#${info.edgeCount},${info.distinctTypes}`, + ); + } + + sigParts.sort(); + const cacheKey = `${cluster.circle.x.toFixed(2)},${cluster.circle.y.toFixed(2)},${cluster.circle.radius.toFixed(2)}|${sigParts.join( + ";", + )}`; + + const cached = cache.get(clusterId, cacheKey); + if (cached) { + portsByCluster.set(clusterId, cached); + continue; + } + + const portMap = slotPorts(clusterId, rawPorts, cluster.circle, config); + cache.set(clusterId, cacheKey, portMap); + portsByCluster.set(clusterId, portMap); + } + + const result = new Map< + string, + { readonly source: Port; readonly target: Port } + >(); + + for (const [pairKey, pair] of pairs) { + const sourcePort = portsByCluster.get(pair.sourceId)?.get(pair.targetId); + const targetPort = portsByCluster.get(pair.targetId)?.get(pair.sourceId); + + if (sourcePort && targetPort) { + result.set(pairKey, { source: sourcePort, target: targetPort }); + } + } + + return result; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/edge-aggregation.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/edge-aggregation.bench.ts new file mode 100644 index 00000000000..9236960c612 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/edge-aggregation.bench.ts @@ -0,0 +1,53 @@ +/** + * Edge-aggregation pair keying. `makePairKey` allocates a template-string key + * plus a result object for every cluster pair it classifies, and it runs inside + * the aggregation loop on every structure commit (and the bezier build reads the + * same pair keys per frame). Cluster-pair counts are far smaller than edge + * counts, so this bench sizes that allocation cost against realistic pair counts. + * This bench isolates makePairKey allocation only; full aggregator/bezier cost + * needs a live cut + cluster tree (see the hierarchical case in + * `core/commit-rebuild.bench.ts` for that path). + * + * Run: `cd apps/hash-frontend && ../../node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer/worker/geometry/edge-aggregation.bench.ts` + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { ClusterId } from "../../ids"; +import { makePairKey } from "./edge-aggregation"; + +const PAIR_COUNTS: readonly number[] = [500, 2_000, 8_000]; + +/** A pool of cluster ids shaped like the real ones (`cluster:type:`). */ +function clusterIdPool(size: number): ClusterId[] { + const pool: ClusterId[] = []; + for (let index = 0; index < size; index++) { + pool.push(ClusterId(`cluster:type:${index},${(index * 7) % 97}`)); + } + return pool; +} + +for (const pairCount of PAIR_COUNTS) { + // A distinct (a, b) index pair per iteration of the inner loop, drawn from a + // modest id pool so keys collide the way sibling cluster pairs do in practice. + const pool = clusterIdPool(Math.max(32, Math.ceil(Math.sqrt(pairCount)) * 4)); + const pairs: [number, number][] = []; + for (let index = 0; index < pairCount; index++) { + const a = (index * 3) % pool.length; + const b = (index * 5 + 1) % pool.length; + pairs.push([a, b === a ? (b + 1) % pool.length : b]); + } + + describe(`makePairKey (${pairCount} pairs)`, () => { + bench("classify every pair", () => { + let keyLengthSum = 0; + for (const [a, b] of pairs) { + keyLengthSum += makePairKey(pool[a]!, pool[b]!).key.length; + } + if (keyLengthSum < 0) { + throw new Error("unreachable"); + } + }); + }); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/edge-aggregation.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/edge-aggregation.test.ts new file mode 100644 index 00000000000..c0ad290cc54 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/edge-aggregation.test.ts @@ -0,0 +1,156 @@ +/** + * Incremental-update guards for {@link EdgeAggregator}. Aggregates are + * additive: any sequence of incremental updates must match a full recompute + * (module header invariant 5). The regressions covered: + * + * - a link-only batch (both endpoints already visible) must show up without + * an owner change to trigger it, + * - a pending endpoint resolving must move the link from hidden to its real + * classification without corrupting an unrelated pair's count, + * - hidden bookkeeping must not drift below the true count. + */ + +import { describe, expect, it } from "vitest"; + +import { defaultVizConfig } from "../../config"; +import { ClusterId, EntityIndex, TypeSetId } from "../../ids"; +import { LinkStore } from "../entity-graph/store/link"; +import { TypeRegistry } from "../store/type-registry"; +import { EdgeAggregator } from "./edge-aggregation"; + +import type { + TypeSetGroup, + TypeSetStore, +} from "../entity-graph/store/type-set"; +import type { CutView } from "./edge-aggregation"; +import type { EntityId } from "@blockprotocol/type-system"; + +const TYPE = TypeSetId(0); + +/** + * Builds a {@link CutView} from a test fixture; optional entityModeIds mark + * clusters in entity (non-block) mode. + */ +function cutOf( + assignments: Record, + entityModeIds: readonly string[] = [], +): CutView { + const owners = new Map(); + for (const [entityIdx, owner] of Object.entries(assignments)) { + owners.set(EntityIndex(Number(entityIdx)), ClusterId(owner)); + } + const entityMode = new Set(entityModeIds.map(ClusterId)); + return { + size: owners.size, + ownerOf: (entityIdx) => owners.get(entityIdx), + isEntityMode: (clusterId) => entityMode.has(clusterId), + entries: () => owners.entries(), + }; +} + +/** Minimal TypeSetStore stub returning undefined groups (labels fall back to "Unknown"). */ +const typeSets = { + getById: (): TypeSetGroup | undefined => undefined, +} as unknown as TypeSetStore; + +const types = new TypeRegistry(); + +function update(aggregator: EdgeAggregator, cut: CutView, links: LinkStore) { + return aggregator.update(cut, links, typeSets, types, defaultVizConfig); +} + +const entityId = (index: number) => + `11111111-1111-4111-8111-111111111111~22222222-2222-4222-8222-${index + .toString(16) + .padStart(12, "0")}` as EntityId; + +describe("EdgeAggregator incremental updates", () => { + it("aggregates a link-only batch between already-visible entities", () => { + const links = new LinkStore(); + const cut = cutOf({ 0: "A", 1: "B" }); + const aggregator = new EdgeAggregator(); + + links.insert(EntityIndex(0), EntityIndex(1), TYPE, EntityIndex(10)); + const first = update(aggregator, cut, links); + expect(first.exactLogicalEdgeCount).toBe(1); + + // Same cut, one new link: no entity changed owner, so only the + // appended-tail pass can pick this up. + links.insert(EntityIndex(0), EntityIndex(1), TYPE, EntityIndex(11)); + const second = update(aggregator, cut, links); + + expect(second.exactLogicalEdgeCount).toBe(2); + const lane = second.visualEdges.find((edge) => edge.kind === "aggregate"); + expect(lane?.count).toBe(2); + }); + + it("moves a link from hidden to aggregate when its endpoint resolves", () => { + const links = new LinkStore(); + const aggregator = new EdgeAggregator(); + + // A->B present from the start; A->C pending on C's arrival. + links.insert(EntityIndex(0), EntityIndex(1), TYPE, EntityIndex(10)); + const pendingLink = links.insert(EntityIndex(0), -1, TYPE, EntityIndex(11)); + links.addPending(entityId(2), pendingLink, "right"); + + const before = update(aggregator, cutOf({ 0: "A", 1: "B" }), links); + expect(before.exactLogicalEdgeCount).toBe(2); + expect(before.omittedLogicalEdgeCount).toBe(1); + + // C arrives: resolve the pending side, C enters the cut owned by "C". + for (const { linkId, side } of links.takePending(entityId(2))!) { + links.resolveEndpoint(linkId, side, EntityIndex(2)); + } + const after = update(aggregator, cutOf({ 0: "A", 1: "B", 2: "C" }), links); + + expect(after.omittedLogicalEdgeCount).toBe(0); + expect(after.exactLogicalEdgeCount).toBe(2); + + // Both lanes intact: the A-B pair must not have been decremented by the + // undo of the formerly-hidden A-C link. + const counts = after.visualEdges + .filter((edge) => edge.kind === "aggregate") + .map((edge) => edge.count) + .sort(); + expect(counts).toEqual([1, 1]); + }); + + it("matches a from-scratch recompute after mixed incremental batches", () => { + const links = new LinkStore(); + const incremental = new EdgeAggregator(); + + const cut1 = cutOf({ 0: "A", 1: "A", 2: "B" }, ["A"]); + links.insert(EntityIndex(0), EntityIndex(1), TYPE, EntityIndex(10)); + links.insert(EntityIndex(1), EntityIndex(2), TYPE, EntityIndex(11)); + update(incremental, cut1, links); + + // Batch 2: a new entity arrives with a pending link resolving to it, + // plus a link-only addition between existing entities. + const pending = links.insert(-1, EntityIndex(2), TYPE, EntityIndex(12)); + links.addPending(entityId(3), pending, "left"); + for (const { linkId, side } of links.takePending(entityId(3))!) { + links.resolveEndpoint(linkId, side, EntityIndex(3)); + } + links.insert(EntityIndex(0), EntityIndex(2), TYPE, EntityIndex(13)); + + const cut2 = cutOf({ 0: "A", 1: "A", 2: "B", 3: "C" }, ["A"]); + const incrementalFrame = update(incremental, cut2, links); + + // Reference: fresh aggregator on the same final store and cut. Drain the + // log first, since a fresh instance reads current endpoints only. + links.drainResolvedEndpoints(); + const fresh = new EdgeAggregator(); + const freshFrame = update(fresh, cut2, links); + + expect(incrementalFrame.exactLogicalEdgeCount).toBe( + freshFrame.exactLogicalEdgeCount, + ); + expect(incrementalFrame.omittedLogicalEdgeCount).toBe( + freshFrame.omittedLogicalEdgeCount, + ); + + const laneCounts = (frame: typeof freshFrame) => + frame.visualEdges.map((edge) => `${edge.visualKey}:${edge.count}`).sort(); + expect(laneCounts(incrementalFrame)).toEqual(laneCounts(freshFrame)); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/edge-aggregation.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/edge-aggregation.ts new file mode 100644 index 00000000000..a239dc1cd28 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/edge-aggregation.ts @@ -0,0 +1,970 @@ +/** + * Edge aggregation: classifies stored links under the current LOD cut + * into aggregate (between clusters), individual (between visible entities), + * or hidden (internal to collapsed clusters). + * + * The rendered edge set is the quotient multigraph of stored links under + * the visible-owner equivalence relation induced by the LOD cut. + * + * Invariants: + * 1. Every stored link is classified as exactly one of + * aggregate / individual / hidden (no double counting). + * 2. For every aggregate edge (A, B, type), its count equals + * the number of links whose endpoint visible owners are A and B + * with that TypeSetId. + * 3. Visual keys are based on semantic identity (cluster pair + type), + * not array order. + * 4. Even when rendering a collapsed edge, exact byType counts are kept. + * 5. Aggregates are additive: affected links can be subtracted and + * re-added exactly for incremental updates. + */ +import { PairKey, VisualEdgeKey } from "../../ids"; +import { graphColors } from "../../visual-style"; +import { edgeColorForType, primaryTypeOfSet } from "../entity-style"; + +import type { VizConfig } from "../../config"; +import type { Color } from "../../frames"; +import type { ClusterId, EntityIndex, LinkId, TypeSetId } from "../../ids"; +import type { LinkStore } from "../entity-graph/store/link"; +import type { + TypeSetGroup, + TypeSetStore, +} from "../entity-graph/store/type-set"; +import type { ClusterNode, ClusterTree } from "../hierarchy/cluster-tree"; +import type { LodItem } from "../hierarchy/lod"; +import type { TypeRegistry } from "../store/type-registry"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +export function widthForCount(count: number): number { + return Math.max(1, Math.min(8, 1 + Math.log2(count + 1))); +} + +export function typeLabelForGroup( + group: TypeSetGroup | undefined, + types: TypeRegistry, +): string { + if (!group) { + return "Unknown"; + } + + const titles: string[] = []; + for (const typeIdx of group.directTypeIds) { + const info = types.get(typeIdx); + if (info) { + titles.push(info.title); + } + } + + return titles.length > 0 ? titles.join(" × ") : "Unknown"; +} + +/** + * Returns the sole VersionedUrl when the group has exactly one direct type; + * null for multi-type rollups or missing registry entries. + */ +export function typeUrlForGroup( + group: TypeSetGroup | undefined, + types: TypeRegistry, +): VersionedUrl | null { + if (!group) { + return null; + } + + let only: VersionedUrl | null = null; + let seen = 0; + + for (const typeIdx of group.directTypeIds) { + const url = types.getUrl(typeIdx); + if (url === undefined) { + continue; + } + + seen += 1; + if (seen > 1) { + return null; + } + + only = url; + } + + return only; +} + +/** + * The reverse (target -> source) label for a type-set group. Each type's + * inverse title ("Member Of") replaces its forward title ("Has Member"); + * falls back to the forward title for types with no inverse. + */ +export function inverseLabelForGroup( + group: TypeSetGroup | undefined, + types: TypeRegistry, +): string { + if (!group) { + return "Unknown"; + } + + const titles: string[] = []; + for (const typeIdx of group.directTypeIds) { + const info = types.get(typeIdx); + + if (info) { + titles.push(info.inverseTitle ?? info.title); + } + } + + return titles.length > 0 ? titles.join(" × ") : "Unknown"; +} + +function collectEntityOwnership( + node: ClusterNode, + typeSets: TypeSetStore, + ownerId: ClusterId, + result: Map, +): void { + if (node.membership.source === "direct") { + for (const idx of node.membership.members.subarray()) { + result.set(idx, ownerId); + } + } else { + for (const key of node.membership.keys) { + const group = typeSets.get(key); + if (group) { + for (const idx of group.entities) { + result.set(idx, ownerId); + } + } + } + } + + // Rollup/family nodes carry no direct membership of their own. + // When collapsed into a single block, they must still own all + // entities in their subtree, otherwise those links are dropped. + for (const child of node.children) { + collectEntityOwnership(child, typeSets, ownerId, result); + } +} + +/** + * Maps every entity to its owning cluster at the current LOD level. + * + * Built from all clusters in the tree (not viewport-filtered); + * off-screen clusters default to "cluster" mode. Frustum culling + * is left to Deck.gl. + */ +export class CutIndex { + readonly #entityModeIds: ReadonlySet; + readonly #containerIds: ReadonlySet; + readonly #entityOwner: ReadonlyMap; + + constructor( + viewportCut: readonly LodItem[], + clusterTree: ClusterTree, + typeSets: TypeSetStore, + ) { + const blockIds = new Set(); + const entityModeIds = new Set(); + const containerIds = new Set(); + const entityOwner = new Map(); + + const cutModes = new Map(); + for (const item of viewportCut) { + cutModes.set(item.clusterId, item.mode); + } + + this.#walkTree( + clusterTree.root, + cutModes, + typeSets, + blockIds, + entityModeIds, + containerIds, + entityOwner, + ); + + this.#entityModeIds = entityModeIds; + this.#containerIds = containerIds; + this.#entityOwner = entityOwner; + } + + get containerIds(): ReadonlySet { + return this.#containerIds; + } + + #walkTree( + node: ClusterNode, + cutModes: ReadonlyMap, + typeSets: TypeSetStore, + blockIds: Set, + entityModeIds: Set, + containerIds: Set, + entityOwner: Map, + ): void { + if (node.kind === "root") { + for (const child of node.children) { + this.#walkTree( + child, + cutModes, + typeSets, + blockIds, + entityModeIds, + containerIds, + entityOwner, + ); + } + return; + } + + const mode = cutModes.get(node.id); + + if (mode === "children" && node.children.length > 0) { + containerIds.add(node.id); + for (const child of node.children) { + this.#walkTree( + child, + cutModes, + typeSets, + blockIds, + entityModeIds, + containerIds, + entityOwner, + ); + } + return; + } + + blockIds.add(node.id); + + if (mode === "entities" || mode === "entities-pending") { + entityModeIds.add(node.id); + } + + collectEntityOwnership(node, typeSets, node.id, entityOwner); + } + + get size(): number { + return this.#entityOwner.size; + } + + ownerOf(entityIdx: EntityIndex): ClusterId | undefined { + return this.#entityOwner.get(entityIdx); + } + + isEntityMode(clusterId: ClusterId): boolean { + return this.#entityModeIds.has(clusterId); + } + + get entityModeIds(): ReadonlySet { + return this.#entityModeIds; + } + + *entries(): IterableIterator<[EntityIndex, ClusterId]> { + yield* this.#entityOwner; + } +} + +/** + * The cut surface {@link EdgeAggregator} classifies against: entity -> owner + * lookups plus the entity-mode flag per owner. {@link CutIndex} satisfies it; + * tests can substitute a hand-built cut without a cluster tree. + */ +export interface CutView { + readonly size: number; + ownerOf(entityIdx: EntityIndex): ClusterId | undefined; + isEntityMode(clusterId: ClusterId): boolean; + entries(): IterableIterator<[EntityIndex, ClusterId]>; +} + +const SEP = "\u001f"; + +export function makePairKey( + a: ClusterId, + b: ClusterId, +): { + readonly key: PairKey; + readonly sourceId: ClusterId; + readonly targetId: ClusterId; +} { + if (a > b) { + return { key: PairKey(`${b}${SEP}${a}`), sourceId: b, targetId: a }; + } + return { key: PairKey(`${a}${SEP}${b}`), sourceId: a, targetId: b }; +} + +interface TypeAggregation { + readonly typeSetId: TypeSetId; + readonly forward: Set; + readonly reverse: Set; +} + +interface MutablePairAggregation { + readonly sourceId: ClusterId; + readonly targetId: ClusterId; + readonly byType: Map; + totalCount: number; +} + +interface StoredIndividualEdge { + readonly linkId: LinkId; + readonly ownerClusterId: ClusterId; + readonly leftEntityIndex: EntityIndex; + readonly rightEntityIndex: EntityIndex; + readonly typeSetId: TypeSetId; +} + +interface ClusterEndpointRef { + readonly kind: "cluster"; + readonly id: ClusterId; +} + +interface EntityEndpointRef { + readonly kind: "entity"; + readonly entityIdx: EntityIndex; + readonly ownerClusterId: ClusterId; +} + +/** + * Direction of an aggregate lane relative to PairKey's sort order. + * "forward" = links from the lower-sorted cluster to the higher one, + * "reverse" = the opposite, "both" = a collapsed lane mixing directions. + */ +export type EdgeDirection = "forward" | "reverse" | "both"; + +export interface AggregatedVisualEdge { + readonly kind: "aggregate"; + readonly visualKey: VisualEdgeKey; + readonly source: ClusterEndpointRef; + readonly target: ClusterEndpointRef; + readonly pairKey: PairKey; + readonly typeSetId: TypeSetId | undefined; + /** + * Single-type lanes carry that type's VersionedUrl; collapsed rollups use + * null. The worker ships only the identity; icon/title resolution stays on + * the main thread from closed type schemas. + */ + readonly typeId: VersionedUrl | null; + readonly direction: EdgeDirection; + readonly collapsed: boolean; + readonly count: number; + readonly totalPairCount: number; + readonly distinctTypeCount: number; + readonly color: Color; + readonly widthWorld: number; + readonly typeLabel: string; + /** + * Stable per-commit lane index in `EdgeFrame.visualEdges`; stamped after + * sort/truncation so pick hits and highway lane tables stay aligned. + */ + readonly laneId: number; + /** + * The link entities this lane aggregates. A forward lane carries the + * forward links, a reverse lane the reverse, a collapsed/"both" lane the + * union of both. Lets a clicked highway resolve to its underlying links. + */ + readonly entities: Set; +} + +export interface IndividualVisualEdge { + readonly kind: "individual"; + readonly visualKey: VisualEdgeKey; + readonly source: EntityEndpointRef; + readonly target: EntityEndpointRef; + readonly linkId: LinkId; + readonly typeSetId: TypeSetId; + readonly count: 1; + readonly color: Color; + readonly widthWorld: number; + readonly typeLabel: string; +} + +export type VisualEdge = AggregatedVisualEdge | IndividualVisualEdge; + +export interface EdgeFrame { + readonly visualEdges: readonly VisualEdge[]; + readonly exactLogicalEdgeCount: number; + readonly renderedLogicalEdgeCount: number; + readonly omittedLogicalEdgeCount: number; + readonly truncated: boolean; +} + +function explodePair( + pairKey: PairKey, + pair: MutablePairAggregation, + typeSets: TypeSetStore, + types: TypeRegistry, + config: VizConfig, +): AggregatedVisualEdge[] { + const aggregations = [...pair.byType.values()].sort( + (a, b) => + b.forward.size + b.reverse.size - (a.forward.size + a.reverse.size) || + a.typeSetId - b.typeSetId, + ); + + const source: ClusterEndpointRef = { kind: "cluster", id: pair.sourceId }; + const target: ClusterEndpointRef = { kind: "cluster", id: pair.targetId }; + + if (aggregations.length > config.maxParallelEdgeTypes) { + // A collapsed/"both" lane carries the union of both directions' links. + // Built by accumulation: Set.union would allocate a fresh copy per step. + const collapsedLinks = new Set(); + + for (const aggregation of aggregations) { + for (const linkEntityIdx of aggregation.forward) { + collapsedLinks.add(linkEntityIdx); + } + for (const linkEntityIdx of aggregation.reverse) { + collapsedLinks.add(linkEntityIdx); + } + } + + return [ + { + kind: "aggregate", + visualKey: VisualEdgeKey(`agg:${pairKey}:__collapsed__`), + source, + target, + pairKey, + typeSetId: undefined, + typeId: null, + direction: "both", + collapsed: true, + count: pair.totalCount, + totalPairCount: pair.totalCount, + distinctTypeCount: aggregations.length, + color: graphColors.collapsedEdge, + widthWorld: widthForCount(pair.totalCount), + typeLabel: `${aggregations.length} link types`, + // Assigned once the final visualEdges order is known (#buildFrame). + laneId: -1, + entities: collapsedLinks, + }, + ]; + } + + // A type with links in both directions becomes two separate lanes + // (one per direction), each sized by its own count. + const edges: AggregatedVisualEdge[] = []; + + for (const aggregate of aggregations) { + const group = typeSets.getById(aggregate.typeSetId); + + const typeLabel = typeLabelForGroup(group, types); + const inverseLabel = inverseLabelForGroup(group, types); + const typeId = typeUrlForGroup(group, types); + + const color = edgeColorForType( + group ? primaryTypeOfSet(group.directTypeIds, types) : undefined, + types, + ); + + if (aggregate.forward.size > 0) { + edges.push({ + kind: "aggregate", + // TypeSetId is a numeric brand; cast for stable visualKey string + // formatting. + visualKey: VisualEdgeKey( + `agg:${pairKey}:${aggregate.typeSetId as number}:forward`, + ), + source, + target, + pairKey, + typeSetId: aggregate.typeSetId, + typeId, + direction: "forward", + collapsed: false, + count: aggregate.forward.size, + totalPairCount: pair.totalCount, + distinctTypeCount: aggregations.length, + color, + widthWorld: widthForCount(aggregate.forward.size), + typeLabel, + // Assigned once the final visualEdges order is known (#buildFrame). + laneId: -1, + // Copied (not aliased): the aggregation sets mutate incrementally + // while emitted frames must stay stable snapshots. + entities: new Set(aggregate.forward), + }); + } + + if (aggregate.reverse.size > 0) { + edges.push({ + kind: "aggregate", + // Same TypeSetId numeric-brand cast as the forward lane above. + visualKey: VisualEdgeKey( + `agg:${pairKey}:${aggregate.typeSetId as number}:reverse`, + ), + source, + target, + pairKey, + typeSetId: aggregate.typeSetId, + typeId, + direction: "reverse", + collapsed: false, + count: aggregate.reverse.size, + totalPairCount: pair.totalCount, + distinctTypeCount: aggregations.length, + color, + widthWorld: widthForCount(aggregate.reverse.size), + typeLabel: inverseLabel, + // Assigned once the final visualEdges order is known (#buildFrame). + laneId: -1, + // Copied (not aliased), as with the forward lane above. + entities: new Set(aggregate.reverse), + }); + } + } + + return edges; +} + +/** + * Maintains edge aggregation state across frames. When the LOD cut changes, + * only links whose visible owner changed are reclassified; falls back to + * full recomputation when >35% of entities changed owners. + * + * Incremental updates rely on the {@link LinkStore} being append-only: links + * with id below `#appliedLinkCount` are already folded into the state, the + * tail is applied fresh each update. Endpoint resolutions (a pending `-1` + * side filling in) are replayed from the store's resolution log so the undo + * uses the values the link was originally applied with -- undoing with the + * freshly-resolved endpoints would decrement a pair bucket the link never + * contributed to. + */ +export class EdgeAggregator { + readonly #pairs = new Map(); + readonly #individuals = new Map(); + #hiddenCount = 0; + #previousCutIndex: CutView | undefined; + /** Links with id below this are folded into the aggregation state. */ + #appliedLinkCount = 0; + + reset(): void { + this.#pairs.clear(); + this.#individuals.clear(); + this.#hiddenCount = 0; + this.#previousCutIndex = undefined; + this.#appliedLinkCount = 0; + } + + /** + * Reclassifies links for a new LOD cut incrementally when owner-change + * ratio stays at or below 35%; otherwise full recompute. + */ + update( + cutIndex: CutView, + linkStore: LinkStore, + typeSets: TypeSetStore, + types: TypeRegistry, + config: VizConfig, + ): EdgeFrame { + // Consume the resolution log unconditionally: after a full recompute its + // entries are stale (the rebuild reads current endpoint values directly). + const resolvedEndpoints = linkStore.drainResolvedEndpoints(); + + if (!this.#previousCutIndex) { + this.#fullRecompute(cutIndex, linkStore); + } else { + const changedEntities = this.#findChangedEntities(cutIndex); + const changeRatio = changedEntities.size / Math.max(1, cutIndex.size); + + // Full recompute when more than 35% of entities change owners + // (incremental undo/reapply cost crosses full-scan breakeven). + if (changeRatio > 0.35) { + this.#fullRecompute(cutIndex, linkStore); + } else { + // Sides that were still -1 when their link was applied, keyed by + // link. Resolutions of links at or beyond #appliedLinkCount are + // dropped: those links were never applied and the tail pass below + // reads their current (already resolved) endpoints. + const resolvedSides = new Map>(); + for (const { linkId, side } of resolvedEndpoints) { + if (linkId >= this.#appliedLinkCount) { + continue; + } + const sides = resolvedSides.get(linkId); + if (sides) { + sides.push(side); + } else { + resolvedSides.set(linkId, [side]); + } + } + + if (changedEntities.size > 0) { + this.#incrementalUpdate( + changedEntities, + cutIndex, + linkStore, + resolvedSides, + ); + } + this.#reapplyResolved(resolvedSides, cutIndex, linkStore); + this.#applyTail(cutIndex, linkStore); + } + } + + this.#previousCutIndex = cutIndex; + return this.#buildFrame(typeSets, types, config); + } + + // Expose mutable pair buckets for geometry fan-out without copying + // aggregation state. + + get pairs(): ReadonlyMap { + return this.#pairs; + } + + /** Apply (sign = +1) or undo (sign = -1) a link's aggregation contribution. */ + #applyLink( + linkId: LinkId, + leftIndex: EntityIndex | -1, + rightIndex: EntityIndex | -1, + typeSetId: TypeSetId, + linkEntityIdx: EntityIndex, + cutIndex: CutView, + sign: 1 | -1, + ): void { + if (leftIndex === -1 || rightIndex === -1) { + this.#hiddenCount += sign; + return; + } + + const leftOwner = cutIndex.ownerOf(leftIndex); + const rightOwner = cutIndex.ownerOf(rightIndex); + + if (!leftOwner || !rightOwner) { + this.#hiddenCount += sign; + return; + } + + // Same visible owner: individual (if entity mode) or hidden. + if (leftOwner === rightOwner) { + if (cutIndex.isEntityMode(leftOwner)) { + if (sign === 1) { + this.#individuals.set(linkId, { + linkId, + ownerClusterId: leftOwner, + leftEntityIndex: leftIndex, + rightEntityIndex: rightIndex, + typeSetId, + }); + } else { + this.#individuals.delete(linkId); + } + } else { + this.#hiddenCount += sign; + } + return; + } + + const { key, sourceId, targetId } = makePairKey(leftOwner, rightOwner); + + // "forward" = flow matches PairKey sort order (source is the lower-sorted cluster). + const forward = leftOwner === sourceId; + + if (sign === 1) { + let pair = this.#pairs.get(key); + if (!pair) { + pair = { sourceId, targetId, byType: new Map(), totalCount: 0 }; + this.#pairs.set(key, pair); + } + + let aggregation = pair.byType.get(typeSetId); + + if (!aggregation) { + aggregation = { + typeSetId, + forward: new Set(), + reverse: new Set(), + }; + + pair.byType.set(typeSetId, aggregation); + } + + if (forward) { + aggregation.forward.add(linkEntityIdx); + } else { + aggregation.reverse.add(linkEntityIdx); + } + + pair.totalCount += 1; + return; + } + + const pair = this.#pairs.get(key); + if (pair === undefined) { + return; + } + + const aggregation = pair.byType.get(typeSetId); + if (aggregation) { + if (forward) { + aggregation.forward.delete(linkEntityIdx); + } else { + aggregation.reverse.delete(linkEntityIdx); + } + + if (aggregation.forward.size === 0 && aggregation.reverse.size === 0) { + pair.byType.delete(typeSetId); + } + } + + pair.totalCount -= 1; + if (pair.totalCount <= 0) { + this.#pairs.delete(key); + } + } + + #fullRecompute(cutIndex: CutView, linkStore: LinkStore): void { + this.#pairs.clear(); + this.#individuals.clear(); + this.#hiddenCount = 0; + + for (let link = 0; link < linkStore.count; link++) { + // LinkStore ids are dense 0..count-1; loop index is a valid LinkId. + this.#applyLink( + link as LinkId, + linkStore.getLeft(link), + linkStore.getRight(link), + linkStore.getTypeSetId(link), + linkStore.getEntityIndex(link), + cutIndex, + 1, + ); + } + this.#appliedLinkCount = linkStore.count; + } + + #incrementalUpdate( + changedEntities: Set, + newCutIndex: CutView, + linkStore: LinkStore, + resolvedSides: ReadonlyMap>, + ): void { + const oldCutIndex = this.#previousCutIndex!; + const processedLinks = new Set(); + + for (const entityIdx of changedEntities) { + for (const link of linkStore.linksFor(entityIdx)) { + if (processedLinks.has(link.linkId)) { + continue; + } + + processedLinks.add(link.linkId); + + // Not yet applied (tail) or applied with a since-resolved -1 side: + // #applyTail / #reapplyResolved handle these with the exact values + // the state saw; undoing them here with current endpoints would + // corrupt counts they never contributed to. + if ( + link.linkId >= this.#appliedLinkCount || + resolvedSides.has(link.linkId) + ) { + continue; + } + + const leftIdx = linkStore.getLeft(link.linkId); + const rightIdx = linkStore.getRight(link.linkId); + const typeSetId = linkStore.getTypeSetId(link.linkId); + const linkEntityIdx = linkStore.getEntityIndex(link.linkId); + + // Reclassify against the previous cut, then the new cut (additive + // invariant). + this.#applyLink( + link.linkId, + leftIdx, + rightIdx, + typeSetId, + linkEntityIdx, + oldCutIndex, + -1, + ); + + this.#applyLink( + link.linkId, + leftIdx, + rightIdx, + typeSetId, + linkEntityIdx, + newCutIndex, + 1, + ); + } + } + } + + /** + * Reclassify links whose pending endpoint resolved since the last update: + * undo with the as-applied endpoints (resolved sides restored to -1, which + * classified the link as hidden), then apply with the real endpoints. + */ + #reapplyResolved( + resolvedSides: ReadonlyMap>, + newCutIndex: CutView, + linkStore: LinkStore, + ): void { + const oldCutIndex = this.#previousCutIndex!; + + for (const [linkId, sides] of resolvedSides) { + const leftIdx = linkStore.getLeft(linkId); + const rightIdx = linkStore.getRight(linkId); + const typeSetId = linkStore.getTypeSetId(linkId); + const linkEntityIdx = linkStore.getEntityIndex(linkId); + + this.#applyLink( + linkId, + sides.includes("left") ? -1 : leftIdx, + sides.includes("right") ? -1 : rightIdx, + typeSetId, + linkEntityIdx, + oldCutIndex, + -1, + ); + + this.#applyLink( + linkId, + leftIdx, + rightIdx, + typeSetId, + linkEntityIdx, + newCutIndex, + 1, + ); + } + } + + /** + * Fold in links inserted since the last update. Catches link-only batches + * between already-visible entities, which `#findChangedEntities` cannot + * see: it tracks entity ownership, and a new link changes no owners. + */ + #applyTail(cutIndex: CutView, linkStore: LinkStore): void { + for (let link = this.#appliedLinkCount; link < linkStore.count; link++) { + // LinkStore ids are dense 0..count-1; loop index is a valid LinkId. + this.#applyLink( + link as LinkId, + linkStore.getLeft(link), + linkStore.getRight(link), + linkStore.getTypeSetId(link), + linkStore.getEntityIndex(link), + cutIndex, + 1, + ); + } + this.#appliedLinkCount = linkStore.count; + } + + #findChangedEntities(newCutIndex: CutView): Set { + const changed = new Set(); + const oldCutIndex = this.#previousCutIndex!; + + // Entities whose owner changed or who left the view. + for (const [entityIdx, oldOwner] of oldCutIndex.entries()) { + const newOwner = newCutIndex.ownerOf(entityIdx); + if (newOwner !== oldOwner) { + changed.add(entityIdx); + } else if ( + // Owner unchanged, but the owner flipped between entity mode + // and block mode (e.g. a leaf bubble zoomed open into "entities"). + // Internal links must move between `individual` and `hidden`. + oldCutIndex.isEntityMode(oldOwner) !== + newCutIndex.isEntityMode(oldOwner) + ) { + changed.add(entityIdx); + } + } + + // Entities that entered the view. + for (const [entityIdx] of newCutIndex.entries()) { + if (oldCutIndex.ownerOf(entityIdx) === undefined) { + changed.add(entityIdx); + } + } + + return changed; + } + + #buildFrame( + typeSets: TypeSetStore, + types: TypeRegistry, + config: VizConfig, + ): EdgeFrame { + const visualEdges: VisualEdge[] = []; + + for (const [pairKey, pair] of this.#pairs) { + // Map keys are PairKey strings produced by makePairKey; safe to + // brand on read. + const exploded = explodePair( + pairKey as PairKey, + pair, + typeSets, + types, + config, + ); + visualEdges.push(...exploded); + } + + for (const edge of this.#individuals.values()) { + const group = typeSets.getById(edge.typeSetId); + visualEdges.push({ + kind: "individual", + visualKey: VisualEdgeKey(`link:${edge.linkId}`), + source: { + kind: "entity", + entityIdx: edge.leftEntityIndex, + ownerClusterId: edge.ownerClusterId, + }, + target: { + kind: "entity", + entityIdx: edge.rightEntityIndex, + ownerClusterId: edge.ownerClusterId, + }, + linkId: edge.linkId, + typeSetId: edge.typeSetId, + count: 1, + color: edgeColorForType( + group ? primaryTypeOfSet(group.directTypeIds, types) : undefined, + types, + ), + widthWorld: 1, + typeLabel: typeLabelForGroup(group, types), + }); + } + + let aggregateTotal = 0; + for (const pair of this.#pairs.values()) { + aggregateTotal += pair.totalCount; + } + const exactLogicalEdgeCount = + aggregateTotal + this.#individuals.size + this.#hiddenCount; + + // Truncate to config.maxRenderedEdges, keeping highest-count lanes so + // dense graphs stay drawable. + const truncated = visualEdges.length > config.maxRenderedEdges; + if (truncated) { + visualEdges.sort((lhs, rhs) => rhs.count - lhs.count); + visualEdges.length = config.maxRenderedEdges; + } + + // The visualEdges order is now final. Stamp each aggregate lane with its + // index as a stable per-commit `laneId`: carried out to the rendered bezier + // segments and used to index `StructureFrame.highwayLanes`. + for (let i = 0; i < visualEdges.length; i++) { + const edge = visualEdges[i]!; + if (edge.kind === "aggregate") { + visualEdges[i] = { ...edge, laneId: i }; + } + } + + const renderedLogicalEdgeCount = visualEdges.reduce( + (sum, edge) => sum + edge.count, + 0, + ); + + return { + visualEdges, + exactLogicalEdgeCount, + renderedLogicalEdgeCount, + omittedLogicalEdgeCount: this.#hiddenCount, + truncated, + }; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/edge-geometry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/edge-geometry.ts new file mode 100644 index 00000000000..0ea2d911212 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/edge-geometry.ts @@ -0,0 +1,1851 @@ +/* eslint-disable id-length */ +/** + * Edge geometry: hierarchical highway model. + * + * The edge rendering follows a highway metaphor: + * + * - Highway: a single curved path between two cluster ports. + * Each link type is a lane (parallel offset from center line). + * Lanes merge at endpoints via a funnel ramp. + * + * - Feeders: inside an open container, lines run from each + * sub-cluster (or entity) to the container's port, where they + * merge into the highway. Feeder width is proportional to + * the sub-cluster's contribution to the highway. + * + * This composes recursively: sub-clusters have their own ports, + * their feeders merge at the parent port, which connects to + * the highway. It's feeders all the way down until we reach + * the indivisible unit: individual entities. + * + * Container crossings: when an edge crosses a container boundary, + * it's split into a feeder (inside) and a shared highway (outside). + * Multiple children sharing the same container and external target + * merge into one highway, avoiding duplicate edge rendering. + */ + +import { + BEZIER_NO_LINK, + type Color, + type RenderBezierBuffers, + type RenderEdgeArrow, + type RenderEdgeLabel, + type RenderEndpointArrowBuffers, +} from "../../frames"; +import { makePairKey, widthForCount } from "./edge-aggregation"; + +import type { VizConfig } from "../../config"; +import type { Circle, Position } from "../../geometry"; +import type { ClusterId, PairKey } from "../../ids"; +import type { ClusterTree } from "../hierarchy/cluster-tree"; +import type { Port } from "./bubble-ports"; +import type { + AggregatedVisualEdge, + CutIndex, + EdgeFrame, +} from "./edge-aggregation"; + +interface Waypoint extends Position { + readonly angle: number; +} + +interface CubicCurve { + readonly p0: readonly [number, number]; + readonly p1: readonly [number, number]; + readonly p2: readonly [number, number]; + readonly p3: readonly [number, number]; +} + +const FLOATS_PER_SEGMENT = 8; +const BYTES_PER_COLOR = 4; +/** Two clip circles per segment: `(cx, cy, signedRadius)` x 2 (one per end). */ +const FLOATS_PER_CLIP = 6; +const EDGE_LABEL_TEXT_WIDTH_EM = 0.58; +/** Keep labels as a lane annotation, not a banner that consumes the whole chord. */ +const EDGE_LABEL_MAX_CHORD_FRACTION = 0.68; +/** Approximate the render layer's horizontal capsule padding in text-size units. */ +const EDGE_LABEL_HORIZONTAL_PADDING_EM = 1.6; +/** Approximate the render layer's vertical capsule padding in text-size units. */ +const EDGE_LABEL_VERTICAL_PADDING_EM = 0.46; + +/** + * A circle that erases the edge on one side of itself in the fragment shader, so + * the edge ends flush on a bubble's perimeter rather than poking through it (the + * round SDF cap overshoots the wall, and since the edge layer is translucent the + * overshoot shows on both sides, draw order can't hide it, only a true clip). + * `signedRadius > 0` erases inside the circle (a highway ending on a bubble's + * outer wall); `< 0` erases outside (a feeder ending on its container's inner + * wall); `0` (or omitted) means no clip on that end. + */ +export interface ClipCircle extends Position { + readonly signedRadius: number; +} + +/** Clip that ends an edge flush on a bubble's outer wall (erase inside it), for + * a highway/feeder that approaches a cluster from outside. */ +function clipInside(circle: Circle): ClipCircle { + return { x: circle.x, y: circle.y, signedRadius: circle.radius }; +} + +/** Clip that ends an edge flush on a container's inner wall (erase outside it), + * for a feeder that reaches its container's port from inside. */ +function clipOutside(circle: Circle): ClipCircle { + return { x: circle.x, y: circle.y, signedRadius: -circle.radius }; +} + +/** + * Append-only sink for cubic Bezier segments in flat typed-array scratch. + * + * Instead of producing one segment object per curve (thousands of + * short-lived objects + tuple arrays per frame), each segment is written + * directly into three parallel scratch buffers: + * + * - `positions`: 8 floats/segment (p0..p3, interleaved as vec2 pairs) + * - `colors`: 4 bytes/segment (r, g, b, a) + * - `widths`: 1 float/segment (px) + * + * The buffers are owned by the worker and reused across frames: `reset()` + * rewinds the write cursor, and capacity only ever grows (never shrinks). + * Because the underlying buffers are reused, `snapshot()` returns exact-sized + * *copies* whose `ArrayBuffer`s can be transferred to the main thread without + * detaching the scratch. + */ +export class BezierSegmentSink { + #positions: Float32Array; + #colors: Uint8Array; + #widths: Float32Array; + #clips: Float32Array; + #ids: Uint32Array; + #count = 0; + #capacity: number; + + constructor(initialCapacity = 1024) { + this.#capacity = Math.max(1, initialCapacity); + this.#positions = new Float32Array(this.#capacity * FLOATS_PER_SEGMENT); + this.#colors = new Uint8Array(this.#capacity * BYTES_PER_COLOR); + this.#widths = new Float32Array(this.#capacity); + this.#clips = new Float32Array(this.#capacity * FLOATS_PER_CLIP); + this.#ids = new Uint32Array(this.#capacity); + } + + /** Number of segments written since the last `reset()`. */ + get count(): number { + return this.#count; + } + + /** Rewind the write cursor. Retains allocated capacity. */ + reset(): void { + this.#count = 0; + } + + /** Append one segment. Grows the backing buffers if needed. `clipA`/`clipB` + * erase the edge near its two ends so it ends flush on a bubble wall. */ + push( + curve: CubicCurve, + color: Color, + width: number, + clipA?: ClipCircle, + clipB?: ClipCircle, + id: number = BEZIER_NO_LINK, + ): void { + this.pushUnclipped( + curve.p0[0], + curve.p0[1], + curve.p1[0], + curve.p1[1], + curve.p2[0], + curve.p2[1], + curve.p3[0], + curve.p3[1], + color, + width, + id, + ); + + const k = (this.#count - 1) * FLOATS_PER_CLIP; + const clips = this.#clips; + clips[k] = clipA?.x ?? 0; + clips[k + 1] = clipA?.y ?? 0; + clips[k + 2] = clipA?.signedRadius ?? 0; + clips[k + 3] = clipB?.x ?? 0; + clips[k + 4] = clipB?.y ?? 0; + clips[k + 5] = clipB?.signedRadius ?? 0; + } + + /** + * Append one clip-free segment from scalar control points. The flat tier's + * per-tick hot path: no curve/tuple objects per call (22k+ segments per + * frame on large graphs made the object form a measurable GC tax). + */ + pushUnclipped( + p0x: number, + p0y: number, + p1x: number, + p1y: number, + p2x: number, + p2y: number, + p3x: number, + p3y: number, + color: Color, + width: number, + id: number = BEZIER_NO_LINK, + ): void { + if (this.#count >= this.#capacity) { + this.#grow(this.#count + 1); + } + + const i = this.#count; + const p = i * FLOATS_PER_SEGMENT; + const pos = this.#positions; + pos[p] = p0x; + pos[p + 1] = p0y; + pos[p + 2] = p1x; + pos[p + 3] = p1y; + pos[p + 4] = p2x; + pos[p + 5] = p2y; + pos[p + 6] = p3x; + pos[p + 7] = p3y; + + const c = i * BYTES_PER_COLOR; + const colors = this.#colors; + colors[c] = color[0]; + colors[c + 1] = color[1]; + colors[c + 2] = color[2]; + colors[c + 3] = color[3]; + + this.#widths[i] = width; + + const k = i * FLOATS_PER_CLIP; + const clips = this.#clips; + clips[k] = 0; + clips[k + 1] = 0; + clips[k + 2] = 0; + clips[k + 3] = 0; + clips[k + 4] = 0; + clips[k + 5] = 0; + + this.#ids[i] = id; + this.#count = i + 1; + } + + #grow(minCapacity: number): void { + let next = this.#capacity * 2; + while (next < minCapacity) { + next *= 2; + } + + const positions = new Float32Array(next * FLOATS_PER_SEGMENT); + positions.set(this.#positions); + const colors = new Uint8Array(next * BYTES_PER_COLOR); + colors.set(this.#colors); + const widths = new Float32Array(next); + widths.set(this.#widths); + const clips = new Float32Array(next * FLOATS_PER_CLIP); + clips.set(this.#clips); + const ids = new Uint32Array(next); + ids.set(this.#ids); + + this.#positions = positions; + this.#colors = colors; + this.#widths = widths; + this.#clips = clips; + this.#ids = ids; + this.#capacity = next; + } + + /** + * Produce exact-sized, transferable copies of the current contents. The + * scratch buffers are left intact (and detached-safe) for the next frame. + */ + snapshot(): RenderBezierBuffers { + const count = this.#count; + return { + positions: this.#positions.slice(0, count * FLOATS_PER_SEGMENT), + colors: this.#colors.slice(0, count * BYTES_PER_COLOR), + widths: this.#widths.slice(0, count), + clips: this.#clips.slice(0, count * FLOATS_PER_CLIP), + ids: this.#ids.slice(0, count), + segmentCount: count, + }; + } +} + +/** + * Append-only sink for the flat tier's packed per-edge endpoint arrows + * ({@link RenderEndpointArrowBuffers}), the same reuse discipline as + * {@link BezierSegmentSink}: worker-owned scratch reused across frames, + * `reset()` rewinds, `snapshot()` copies exact-sized transferable buffers. + */ +export class EndpointArrowSink { + #positions: Float32Array; + #angles: Float32Array; + #sizes: Float32Array; + #chords: Float32Array; + #colors: Uint8Array; + #count = 0; + #capacity: number; + + constructor(initialCapacity = 1024) { + this.#capacity = Math.max(1, initialCapacity); + this.#positions = new Float32Array(this.#capacity * 2); + this.#angles = new Float32Array(this.#capacity); + this.#sizes = new Float32Array(this.#capacity); + this.#chords = new Float32Array(this.#capacity); + this.#colors = new Uint8Array(this.#capacity * BYTES_PER_COLOR); + } + + get count(): number { + return this.#count; + } + + /** Rewind the write cursor. Retains allocated capacity. */ + reset(): void { + this.#count = 0; + } + + push( + x: number, + y: number, + angle: number, + size: number, + chord: number, + color: Color, + ): void { + if (this.#count >= this.#capacity) { + this.#grow(this.#count + 1); + } + const i = this.#count; + this.#positions[i * 2] = x; + this.#positions[i * 2 + 1] = y; + this.#angles[i] = angle; + this.#sizes[i] = size; + this.#chords[i] = chord; + const c = i * BYTES_PER_COLOR; + const colors = this.#colors; + colors[c] = color[0]; + colors[c + 1] = color[1]; + colors[c + 2] = color[2]; + colors[c + 3] = color[3]; + this.#count = i + 1; + } + + #grow(minCapacity: number): void { + let next = this.#capacity * 2; + while (next < minCapacity) { + next *= 2; + } + const positions = new Float32Array(next * 2); + positions.set(this.#positions); + const angles = new Float32Array(next); + angles.set(this.#angles); + const sizes = new Float32Array(next); + sizes.set(this.#sizes); + const chords = new Float32Array(next); + chords.set(this.#chords); + const colors = new Uint8Array(next * BYTES_PER_COLOR); + colors.set(this.#colors); + this.#positions = positions; + this.#angles = angles; + this.#sizes = sizes; + this.#chords = chords; + this.#colors = colors; + this.#capacity = next; + } + + /** Exact-sized transferable copies; scratch stays intact for the next frame. */ + snapshot(): RenderEndpointArrowBuffers { + const count = this.#count; + return { + positions: this.#positions.slice(0, count * 2), + angles: this.#angles.slice(0, count), + sizes: this.#sizes.slice(0, count), + chords: this.#chords.slice(0, count), + colors: this.#colors.slice(0, count * BYTES_PER_COLOR), + count, + }; + } +} + +/** + * Max perpendicular bow as a fraction of chord length when handles are + * collinear. + * + * @defaultValue 0.15. Larger values separate opposing lanes more + * aggressively but exaggerate the curve on ports that face each other + * nearly head-on. + */ +const COLLINEAR_BEND_FRACTION = 0.15; + +/** + * World-space gap between parallel lanes; 0 means lanes touch. + * + * @defaultValue 1. Increase for clearer separation at high zoom. + */ +const LANE_GAP_WORLD = 1; + +/** + * Cubic Bezier between two waypoints. Both control points extend + * outward along each waypoint's outward normal. Tension clamped + * to 45% of chord length to prevent overshoot. + * + * When the two handles are (near-)collinear with the chord (the common case, + * since ports aim straight at each other) the cubic degenerates to a line. A + * perpendicular bow is then injected toward the chord's left normal, ramping + * smoothly to zero once the natural curvature is large enough. The side is + * derived purely from chord direction, so: + * + * - it is continuous (no snap) as the layout settles, and stays stable across + * LOD endpoint swaps (chord-derived side does not flip when source/target + * exchange roles); + * - opposing flows separate for free: A->B and B->A have opposite chords, so + * they bow to opposite sides instead of overlapping. + */ +function cubicBetweenWaypoints( + from: Waypoint, + to: Waypoint, + rawTension: number, + bow = true, +): CubicCurve { + const chordX = to.x - from.x; + const chordY = to.y - from.y; + const chord = Math.hypot(chordX, chordY); + const tension = Math.min(rawTension, 0.45 * chord); + + let p1x = from.x + Math.cos(from.angle) * tension; + let p1y = from.y + Math.sin(from.angle) * tension; + let p2x = to.x + Math.cos(to.angle) * tension; + let p2y = to.y + Math.sin(to.angle) * tension; + + if (bow && chord > 1e-6) { + // Unit normal to the chord (chord rotated +90 degrees): the consistent bow side. + const nx = -chordY / chord; + const ny = chordX / chord; + + // Signed perpendicular deviation of each handle from the chord. Both + // are ~0 exactly when the curve has degenerated to a straight line. + const perp1 = (p1x - from.x) * nx + (p1y - from.y) * ny; + const perp2 = (p2x - to.x) * nx + (p2y - to.y) * ny; + + const bendMetric = perp1 + perp2; + const target = COLLINEAR_BEND_FRACTION * chord; + + // collinearity in [0, 1]: 1 = perfectly collinear, 0 = already curved + // enough. The injected offset ramps to zero at the threshold, so the final + // bend is continuous in the natural curvature. + const collinearity = 1 - Math.min(1, Math.abs(bendMetric) / target); + if (collinearity > 0) { + // Split the bow equally across both handles for a symmetric arc. + const dq = (target * collinearity) / 2; + p1x += dq * nx; + p1y += dq * ny; + p2x += dq * nx; + p2y += dq * ny; + } + } + + return { + p0: [from.x, from.y], + p1: [p1x, p1y], + p2: [p2x, p2y], + p3: [to.x, to.y], + }; +} + +interface ContainerCrossing { + readonly containerId: ClusterId; + readonly circle: Circle; +} + +interface HierarchyInfo { + /** Source-side containers (inner -> outer), excluding shared. */ + readonly sourceContainers: readonly ContainerCrossing[]; + /** Target-side containers (inner -> outer), excluding shared. */ + readonly targetContainers: readonly ContainerCrossing[]; +} + +/** + * Returns source-side and target-side container crossings for a cluster + * pair, omitting containers that enclose both endpoints. + */ +export function analyzeHierarchy( + sourceId: ClusterId, + targetId: ClusterId, + clusterTree: ClusterTree, + containerIds: ReadonlySet, +): HierarchyInfo { + const sourceContainers: ContainerCrossing[] = []; + let node = clusterTree.get(sourceId); + while (node?.parent) { + if (containerIds.has(node.parent.id)) { + sourceContainers.push({ + containerId: node.parent.id, + circle: node.parent.circle, + }); + } + node = node.parent; + } + + const targetContainers: ContainerCrossing[] = []; + node = clusterTree.get(targetId); + while (node?.parent) { + if (containerIds.has(node.parent.id)) { + targetContainers.push({ + containerId: node.parent.id, + circle: node.parent.circle, + }); + } + node = node.parent; + } + + const sourceSet = new Set(sourceContainers.map((cc) => cc.containerId)); + const sharedIds = new Set( + targetContainers + .filter((cc) => sourceSet.has(cc.containerId)) + .map((cc) => cc.containerId), + ); + + return { + sourceContainers: sourceContainers.filter( + (cc) => !sharedIds.has(cc.containerId), + ), + targetContainers: targetContainers.filter( + (cc) => !sharedIds.has(cc.containerId), + ), + }; +} + +export function containerBoundaryWaypoint( + circle: Circle, + towardX: number, + towardY: number, + padding: number, +): Waypoint { + const angle = Math.atan2(towardY - circle.y, towardX - circle.x); + const r = circle.radius + padding; + return { + x: circle.x + r * Math.cos(angle), + y: circle.y + r * Math.sin(angle), + angle, + }; +} + +/** + * Direction of a highway/feeder lane, normalized so that "forward" + * always means flow from the highway source side to the target side, + * regardless of each child pair's own PairKey sort order. + */ +type LaneDirection = "forward" | "reverse" | "both"; + +/** + * Normalize a directed aggregate edge to the highway orientation. + * + * `nearId` is the child cluster on the side we are merging from, and + * `side` says whether that side is the highway's source or target. + * An edge whose physical source is the near child flows away from the + * source side (highway "forward"); on the target side the test inverts. + * Collapsed edges carry no single direction and stay "both". + */ +function highwayDirection( + edge: AggregatedVisualEdge, + nearId: ClusterId, + side: "source" | "target", +): LaneDirection { + if (edge.direction === "both") { + return "both"; + } + // Physical "from" cluster of the link. + const fromId = edge.direction === "forward" ? edge.source.id : edge.target.id; + if (side === "source") { + return fromId === nearId ? "forward" : "reverse"; + } + return fromId === nearId ? "reverse" : "forward"; +} + +interface MergedLane { + readonly count: number; + readonly color: Color; + readonly widthWorld: number; + readonly typeLabel: string; + readonly direction: LaneDirection; +} + +function mergeLanes( + children: readonly HighwayGroupChild[], + side: "source" | "target", +): MergedLane[] { + const byLane = new Map< + string, + { + count: number; + color: Color; + typeLabel: string; + direction: LaneDirection; + } + >(); + + for (const child of children) { + for (const edge of child.edges) { + // Branded TypeSetId | undefined -> number for lane merge key; -1 + // denotes collapsed lanes. + const typeKey = (edge.typeSetId as number | undefined) ?? -1; + const direction = highwayDirection(edge, child.childId, side); + const key = `${typeKey}:${direction}`; + const existing = byLane.get(key); + if (existing) { + existing.count += edge.count; + } else { + byLane.set(key, { + count: edge.count, + color: edge.color, + typeLabel: edge.typeLabel, + direction, + }); + } + } + } + + return [...byLane.values()].map((info) => ({ + count: info.count, + color: info.color, + widthWorld: widthForCount(info.count), + typeLabel: info.typeLabel, + direction: info.direction, + })); +} + +interface FeederTypeInfo { + count: number; + color: Color; + typeLabel: string; + readonly direction: LaneDirection; +} + +interface FeederSegment { + sourceId: ClusterId; + sourceCircle: Circle; + targetWp: Waypoint; + targetId: ClusterId; + /** + * Per (type, direction) counts and colors. Each entry becomes a + * colored lane, keyed by `${typeKey}:${direction}` so forward and + * reverse flows stay on separate lanes that match the highway. + */ + types: Map; +} + +function getOrCreateSegment( + segments: Map, + key: string, + init: Omit, +): FeederSegment { + let segment = segments.get(key); + + if (!segment) { + segment = { ...init, types: new Map() }; + segments.set(key, segment); + } + + return segment; +} + +function mergeFeederTypes( + target: Map, + source: ReadonlyMap, +): void { + for (const [typeKey, info] of source) { + const existing = target.get(typeKey); + + if (existing) { + existing.count += info.count; + } else { + target.set(typeKey, { ...info }); + } + } +} + +interface ClassifiedPair { + readonly pairKey: PairKey; + readonly edges: AggregatedVisualEdge[]; + readonly sourceId: ClusterId; + readonly targetId: ClusterId; + readonly hierarchy: HierarchyInfo; +} + +/** + * A highway group: multiple block-level pairs that share + * the same outermost container exit and external target. + * Their edges merge into one highway at the container boundary. + */ +interface HighwayGroupChild { + readonly childId: ClusterId; + readonly childCircle: Circle; + readonly edges: AggregatedVisualEdge[]; +} + +interface HighwayGroup { + readonly highwaySourceId: ClusterId; + readonly highwaySourceCircle: Circle; + readonly highwayTargetId: ClusterId; + readonly highwayTargetCircle: Circle; + /** Children on the source side (inside source container). */ + readonly sourceChildren: HighwayGroupChild[]; + /** Children on the target side (inside target container). */ + readonly targetChildren: HighwayGroupChild[]; +} + +export interface EdgeGeometryContext { + readonly clusterTree: ClusterTree; + readonly cutIndex: CutIndex; + /** Visible solid bubbles a highway should route around (excludes its own ends). */ + readonly obstacles: readonly { + readonly id: ClusterId; + readonly circle: Circle; + }[]; +} + +/** + * Keep a routed highway this far (x obstacle radius) outside the bubble. + * + * @defaultValue 1.15. Lower values hug bubbles tighter but increase clip + * risk; higher values push detours further out. + */ +const ROUTE_CLEARANCE_MUL = 1.15; +/** + * Ignore obstacles this close (fraction of chord) to either endpoint. + * + * @defaultValue 0.08. Higher values reduce spurious detours near endpoints + * but can let a highway skim closer to a near-endpoint obstacle. + */ +const ROUTE_END_MARGIN = 0.08; + +/** + * Compute raw cubic Bezier curves between consecutive waypoints. + * Returns one CubicCurve per segment (no tessellation). + */ +function computeRawCurves( + source: Waypoint, + target: Waypoint, + crossings: readonly ContainerCrossing[], + config: VizConfig, + bow = true, +): CubicCurve[] { + const waypoints: Waypoint[] = [source]; + + for (let ci = 0; ci < crossings.length; ci++) { + const crossing = crossings[ci]!; + const nextX = + ci < crossings.length - 1 ? crossings[ci + 1]!.circle.x : target.x; + const nextY = + ci < crossings.length - 1 ? crossings[ci + 1]!.circle.y : target.y; + + waypoints.push( + containerBoundaryWaypoint( + crossing.circle, + nextX, + nextY, + config.portPaddingWorld, + ), + ); + } + + waypoints.push(target); + + // Bow only a single straight segment (for parallel-edge separation); routed + // or boundary-crossing paths already get their shape from their waypoints, so + // bowing each segment there just makes them wiggle. + const useBow = bow && waypoints.length === 2; + + const curves: CubicCurve[] = []; + for (let wi = 0; wi < waypoints.length - 1; wi++) { + const from = waypoints[wi]!; + const to = waypoints[wi + 1]!; + const segmentLength = Math.hypot(to.x - from.x, to.y - from.y); + const tension = config.portTension * segmentLength; + curves.push(cubicBetweenWaypoints(from, to, tension, useBow)); + } + + return curves; +} + +/** + * Offset a cubic curve perpendicular to its chord (p0->p3). + * This is an approximation; the exact parallel of a cubic Bezier + * is not itself a cubic Bezier. Accurate for small offsets. + */ +function offsetCurve(curve: CubicCurve, offset: number): CubicCurve { + if (offset === 0) { + return curve; + } + const dx = curve.p3[0] - curve.p0[0]; + const dy = curve.p3[1] - curve.p0[1]; + const len = Math.hypot(dx, dy) || 1; + const nx = (-dy / len) * offset; + const ny = (dx / len) * offset; + + return { + p0: [curve.p0[0] + nx, curve.p0[1] + ny], + p1: [curve.p1[0] + nx, curve.p1[1] + ny], + p2: [curve.p2[0] + nx, curve.p2[1] + ny], + p3: [curve.p3[0] + nx, curve.p3[1] + ny], + }; +} + +/** + * Offset a chain of cubics (a routed polyline-of-beziers) by `offset`. The key + * difference from offsetting each segment independently: at a shared waypoint + * the two adjacent segments offset their common endpoint along the same + * (bisector) normal, so a routed lane stays continuous instead of zig-zagging + * sideways at every join. The control handles use each segment's own normal. + */ +function offsetPolyBezier( + curves: readonly CubicCurve[], + offset: number, +): CubicCurve[] { + if (offset === 0) { + return curves.slice(); + } + + const normals = curves.map((curve) => { + const dx = curve.p3[0] - curve.p0[0]; + const dy = curve.p3[1] - curve.p0[1]; + const len = Math.hypot(dx, dy) || 1; + return [-dy / len, dx / len] as const; + }); + + const bisector = ( + n1: readonly [number, number], + n2: readonly [number, number], + ): readonly [number, number] => { + const sumX = n1[0] + n2[0]; + const sumY = n1[1] + n2[1]; + const len = Math.hypot(sumX, sumY) || 1; + return [sumX / len, sumY / len]; + }; + + return curves.map((curve, idx) => { + const own = normals[idx]!; + const start = idx > 0 ? bisector(normals[idx - 1]!, own) : own; + const end = + idx < curves.length - 1 ? bisector(own, normals[idx + 1]!) : own; + return { + p0: [curve.p0[0] + start[0] * offset, curve.p0[1] + start[1] * offset], + p1: [curve.p1[0] + own[0] * offset, curve.p1[1] + own[1] * offset], + p2: [curve.p2[0] + own[0] * offset, curve.p2[1] + own[1] * offset], + p3: [curve.p3[0] + end[0] * offset, curve.p3[1] + end[1] * offset], + }; + }); +} + +function formatCount(count: number): string { + if (count >= 1_000_000) { + return `${(count / 1_000_000).toFixed(1)}M`; + } + if (count >= 1_000) { + return `${(count / 1_000).toFixed(1)}k`; + } + return String(count); +} + +/** Evaluates the cubic at parameter t in [0, 1] using the Bernstein basis. */ +function cubicPoint(curve: CubicCurve, t: number): readonly [number, number] { + const u = 1 - t; + const w0 = u * u * u; + const w1 = 3 * u * u * t; + const w2 = 3 * u * t * t; + const w3 = t * t * t; + return [ + w0 * curve.p0[0] + w1 * curve.p1[0] + w2 * curve.p2[0] + w3 * curve.p3[0], + w0 * curve.p0[1] + w1 * curve.p1[1] + w2 * curve.p2[1] + w3 * curve.p3[1], + ]; +} + +/** + * Tangent direction of a cubic at t, as a TextLayer angle in degrees. + * Negated because OrthographicView renders y-down (flipY), so a world-space + * counterclockwise angle is mirrored on screen. Kept in [-90, 90] so the text + * never reads upside down. + */ +function cubicTangentAngle(curve: CubicCurve, t: number): number { + const u = 1 - t; + const dx = + 3 * u * u * (curve.p1[0] - curve.p0[0]) + + 6 * u * t * (curve.p2[0] - curve.p1[0]) + + 3 * t * t * (curve.p3[0] - curve.p2[0]); + const dy = + 3 * u * u * (curve.p1[1] - curve.p0[1]) + + 6 * u * t * (curve.p2[1] - curve.p1[1]) + + 3 * t * t * (curve.p3[1] - curve.p2[1]); + let deg = (-Math.atan2(dy, dx) * 180) / Math.PI; + if (deg > 90) { + deg -= 180; + } else if (deg < -90) { + deg += 180; + } + return deg; +} + +/** World-space tangent angle of a cubic at t, used for geometric marks such as arrowheads. */ +function cubicTangentRadians(curve: CubicCurve, t: number): number { + const u = 1 - t; + const dx = + 3 * u * u * (curve.p1[0] - curve.p0[0]) + + 6 * u * t * (curve.p2[0] - curve.p1[0]) + + 3 * t * t * (curve.p3[0] - curve.p2[0]); + const dy = + 3 * u * u * (curve.p1[1] - curve.p0[1]) + + 6 * u * t * (curve.p2[1] - curve.p1[1]) + + 3 * t * t * (curve.p3[1] - curve.p2[1]); + return Math.atan2(dy, dx); +} + +function arrowSizeForLane(widthWorld: number): number { + return Math.max(widthWorld * 1.15, 1.6); +} + +function labelSizeForLane( + widthWorld: number, + chord: number, + text: string, +): number { + const heightBudget = widthWorld + 4; + const heightFit = heightBudget / (1 + EDGE_LABEL_VERTICAL_PADDING_EM); + const widthFit = + (chord * EDGE_LABEL_MAX_CHORD_FRACTION) / + Math.max( + 1, + text.length * EDGE_LABEL_TEXT_WIDTH_EM + EDGE_LABEL_HORIZONTAL_PADDING_EM, + ); + + return Math.max(1.4, Math.min(heightFit, widthFit)); +} + +/** + * Emit one lane per (type, direction) as offset parallel curves, and one label + * per lane riding it: at the lane midpoint, rotated to the curve tangent, sized + * to the lane's width. The lanes carry typeLabel + count so each colored lane + * is annotated with its own type and flow, not a single dominant summary. + */ +function emitCurveLanes( + out: BezierSegmentSink, + curves: readonly CubicCurve[], + lanes: readonly { + readonly color: Color; + readonly widthWorld: number; + readonly typeLabel: string; + readonly count: number; + readonly direction?: LaneDirection; + /** + * Aggregate lane id written into each segment's pick id; omitted when + * the caller supplies a merged-group representative. + */ + readonly laneId?: number; + }[], + labelsOut: RenderEdgeLabel[], + arrowsOut: RenderEdgeArrow[], + clipStart?: ClipCircle, + clipEnd?: ClipCircle, +): void { + const gap = LANE_GAP_WORLD; + const midIdx = Math.floor(curves.length / 2); + + // Pack lanes snug and centered on the chord in common/world units. The render layer projects + // widths and offsets with the camera, so zoom does not require worker-side Bezier regeneration. + let bundleWidth = -gap; + for (const info of lanes) { + bundleWidth += info.widthWorld + gap; + } + let cursor = -bundleWidth / 2; + + for (let lane = 0; lane < lanes.length; lane++) { + const info = lanes[lane]!; + const width = info.widthWorld; + const laneOffset = cursor + width / 2; + cursor += width + gap; + + // Offset the whole chain at once (bisector normals at the joins) so a routed + // multi-segment lane stays continuous instead of zig-zagging at waypoints. + const laneCurves = + laneOffset === 0 ? curves : offsetPolyBezier(curves, laneOffset); + let midCurve: CubicCurve | undefined; + const lastCi = laneCurves.length - 1; + for (let ci = 0; ci < laneCurves.length; ci++) { + const curve = laneCurves[ci]!; + // Only the path's first/last segments touch a bubble (the ends); the + // interior routes between them, so it gets no clip. The lane's id rides + // every segment so a clicked highway resolves back to its links. + out.push( + curve, + info.color, + width, + ci === 0 ? clipStart : undefined, + ci === lastCi ? clipEnd : undefined, + info.laneId, + ); + if (ci === midIdx) { + midCurve = curve; + } + } + + if (midCurve && info.count > 0) { + const chord = Math.hypot( + midCurve.p3[0] - midCurve.p0[0], + midCurve.p3[1] - midCurve.p0[1], + ); + const [mx, my] = cubicPoint(midCurve, 0.5); + const text = `${info.typeLabel} ${formatCount(info.count)}`; + labelsOut.push({ + x: mx, + y: my, + text, + angle: cubicTangentAngle(midCurve, 0.5), + // World-sized to stay in the same coordinate system as the lane it annotates. Fitted + // against both lane thickness and the visible chord so long labels don't become banners. + size: labelSizeForLane(width, chord, text), + chord, + }); + if (info.direction === "forward" || info.direction === "reverse") { + const t = info.direction === "forward" ? 0.68 : 0.32; + const [x, y] = cubicPoint(midCurve, t); + arrowsOut.push({ + kind: "lane", + x, + y, + angle: + cubicTangentRadians(midCurve, t) + + (info.direction === "forward" ? 0 : Math.PI), + size: arrowSizeForLane(width), + color: info.color, + chord, + }); + } + } + } +} + +/** + * Emits nested feeder cubics from child clusters to a container port, + * tagging segments with the highway group's representative lane id. + */ +function emitRecursiveBezierFeeders( + out: BezierSegmentSink, + arrowsOut: RenderEdgeArrow[], + children: readonly HighwayGroupChild[], + outermostWp: Waypoint, + outermostContainerId: ClusterId, + side: "source" | "target", + clusterTree: ClusterTree, + containerIds: ReadonlySet, + config: VizConfig, + laneId: number, +): void { + const segments = new Map(); + + for (const child of children) { + const childTypes = new Map(); + for (const edge of child.edges) { + const typeKey = (edge.typeSetId as number | undefined) ?? -1; + const direction = highwayDirection(edge, child.childId, side); + const key = `${typeKey}:${direction}`; + const existing = childTypes.get(key); + if (existing) { + existing.count += edge.count; + } else { + childTypes.set(key, { + count: edge.count, + color: edge.color, + typeLabel: edge.typeLabel, + direction, + }); + } + } + + let currentId = child.childId; + let currentCircle = child.childCircle; + let node = clusterTree.get(currentId); + + while (node?.parent) { + const parentId = node.parent.id; + if (parentId === outermostContainerId) { + const key = `${currentId}:${outermostContainerId}`; + const segment = getOrCreateSegment(segments, key, { + sourceId: currentId, + sourceCircle: currentCircle, + targetWp: outermostWp, + targetId: outermostContainerId, + }); + + mergeFeederTypes(segment.types, childTypes); + break; + } + if (containerIds.has(parentId)) { + const parentCluster = clusterTree.get(parentId); + if (!parentCluster) { + break; + } + const parentPortWp = containerBoundaryWaypoint( + parentCluster.circle, + outermostWp.x, + outermostWp.y, + config.portPaddingWorld, + ); + const key = `${currentId}:${parentId}`; + const segment = getOrCreateSegment(segments, key, { + sourceId: currentId, + sourceCircle: currentCircle, + targetWp: parentPortWp, + targetId: parentId, + }); + + mergeFeederTypes(segment.types, childTypes); + currentId = parentId; + currentCircle = parentCluster.circle; + node = parentCluster; + } else { + node = node.parent; + } + } + } + + const gap = LANE_GAP_WORLD; + const childIds = new Set(children.map((child) => child.childId)); + + for (const segment of segments.values()) { + // Pass-through containers aim at the outermost port so the hop endpoint + // matches the incoming boundary; aiming at the next hop's target + // re-projects the rim point and breaks continuity when depth >= 2. + const aimToward = childIds.has(segment.sourceId) + ? segment.targetWp + : outermostWp; + const sourceAngle = Math.atan2( + aimToward.y - segment.sourceCircle.y, + aimToward.x - segment.sourceCircle.x, + ); + const feederSource: Waypoint = { + x: + segment.sourceCircle.x + + segment.sourceCircle.radius * Math.cos(sourceAngle), + y: + segment.sourceCircle.y + + segment.sourceCircle.radius * Math.sin(sourceAngle), + angle: sourceAngle, + }; + const inwardAngle = Math.atan2( + segment.sourceCircle.y - segment.targetWp.y, + segment.sourceCircle.x - segment.targetWp.x, + ); + const feederEnd: Waypoint = { + x: segment.targetWp.x, + y: segment.targetWp.y, + angle: inwardAngle, + }; + + const segmentLength = Math.hypot( + feederEnd.x - feederSource.x, + feederEnd.y - feederSource.y, + ); + const tension = config.portTension * segmentLength; + const baseCurve = cubicBetweenWaypoints(feederSource, feederEnd, tension); + + // Clip every hop flush at both its container walls: leave the source's outer + // wall (erase inside the source) and reach the target container's inner wall + // (erase outside the target). Applied at every nesting level so the feeder + // is flush at intermediate container walls too -- without it, two hops' + // round caps overlap into a blob poking through the (translucent) wall. + const targetCircle = clusterTree.get(segment.targetId)?.circle; + const clipStart = clipInside(segment.sourceCircle); + const clipEnd = targetCircle ? clipOutside(targetCircle) : undefined; + + const types = [...segment.types.values()]; + let bundleWidth = -gap; + for (const info of types) { + bundleWidth += widthForCount(info.count) + gap; + } + let cursor = -bundleWidth / 2; + + for (const info of types) { + const laneWidth = widthForCount(info.count); + const laneOffset = cursor + laneWidth / 2; + cursor += laneWidth + gap; + const curve = + laneOffset === 0 ? baseCurve : offsetCurve(baseCurve, laneOffset); + + out.push(curve, info.color, laneWidth, clipStart, clipEnd, laneId); + if (info.direction === "forward" || info.direction === "reverse") { + // Place feeder arrows slightly past mid-curve (0.58) so they sit + // clear of port clips. + const t = 0.58; + const [x, y] = cubicPoint(curve, t); + const forwardAlongCurve = + (side === "source" && info.direction === "forward") || + (side === "target" && info.direction === "reverse"); + arrowsOut.push({ + kind: "lane", + x, + y, + angle: + cubicTangentRadians(curve, t) + (forwardAlongCurve ? 0 : Math.PI), + size: arrowSizeForLane(laneWidth), + color: info.color, + chord: segmentLength, + }); + } + } + } +} + +/** + * Highway-level endpoints of a base pair: the outermost rendered containers the + * edge actually travels between (the leaf endpoints if neither is nested in an + * open container). Ports are computed at this level so a cluster's port toward a + * neighbor subtree stays put whether that subtree's container is open or closed. + */ +export function highwayEndpoints( + sourceId: ClusterId, + targetId: ClusterId, + clusterTree: ClusterTree, + containerIds: ReadonlySet, +): { + readonly highwaySourceId: ClusterId; + readonly highwayTargetId: ClusterId; +} { + const { sourceContainers, targetContainers } = analyzeHierarchy( + sourceId, + targetId, + clusterTree, + containerIds, + ); + return { + highwaySourceId: + sourceContainers.length > 0 + ? sourceContainers[sourceContainers.length - 1]!.containerId + : sourceId, + highwayTargetId: + targetContainers.length > 0 + ? targetContainers[targetContainers.length - 1]!.containerId + : targetId, + }; +} + +/** + * The port on each of two clusters for their connecting highway, by id. + * + * Returns `undefined` when no pair is registered for the ids. The per-emit + * port pass registers a pair for every aggregated pair with distinct, + * tree-resolvable highway endpoints, so in a consistent frame a lookup at + * highway level always resolves and a miss indicates frame/tree desync. + */ +export function portsFor( + portPairs: ReadonlyMap< + string, + { readonly source: Port; readonly target: Port } + >, + aId: ClusterId, + bId: ClusterId, +): { readonly a: Port; readonly b: Port } | undefined { + const pair = portPairs.get(makePairKey(aId, bId).key); + if (!pair) { + return undefined; + } + // Pair map keys sort clusters lexicographically; swap ports when the + // caller's aId > bId. + return aId < bId + ? { a: pair.source, b: pair.target } + : { a: pair.target, b: pair.source }; +} + +/** The worst obstacle intrusion on one polyline segment, if any. */ +interface SegmentHit { + readonly segmentIndex: number; + /** Foot-of-perpendicular position along the segment. */ + readonly footX: number; + readonly footY: number; + /** Unit normal of the segment (left side). */ + readonly nx: number; + readonly ny: number; + /** Signed perpendicular offset of the obstacle centre from the segment. */ + readonly perp: number; + /** Clearance the waypoint must reach to clear the obstacle. */ + readonly clear: number; + /** How far inside the clearance the obstacle reaches (>0 means it clips). */ + readonly intrusion: number; +} + +/** + * The single worst (deepest-clipping) obstacle across every segment of the + * current polyline `path`, or null if nothing clips. Endpoint clusters and + * obstacles hugging a segment's ends are excluded. + */ +function worstObstacleOnPath( + path: readonly Waypoint[], + exempt: ReadonlySet, + obstacles: readonly { readonly id: ClusterId; readonly circle: Circle }[], +): SegmentHit | null { + let worst: SegmentHit | null = null; + + for (let segment = 0; segment < path.length - 1; segment++) { + const a = path[segment]!; + const b = path[segment + 1]!; + const dx = b.x - a.x; + const dy = b.y - a.y; + const chord = Math.hypot(dx, dy); + if (chord < 1e-6) { + continue; + } + const ux = dx / chord; + const uy = dy / chord; + const nx = -uy; + const ny = ux; + + for (const obstacle of obstacles) { + if (exempt.has(obstacle.id)) { + continue; + } + const ox = obstacle.circle.x - a.x; + const oy = obstacle.circle.y - a.y; + const param = (ox * ux + oy * uy) / chord; + if (param <= ROUTE_END_MARGIN || param >= 1 - ROUTE_END_MARGIN) { + continue; + } + const perp = ox * nx + oy * ny; + const dist = Math.abs(perp); + // Only detour when the segment actually enters the bubble, merely grazing + // its clearance margin shouldn't spawn a waypoint (that zig-zags the path + // through a dense field of bubbles). The waypoint is still placed out at + // the clearance ring, so a real detour keeps its gap. + if (dist >= obstacle.circle.radius) { + continue; + } + const clear = obstacle.circle.radius * ROUTE_CLEARANCE_MUL; + const intrusion = clear - dist; + if (worst === null || intrusion > worst.intrusion) { + worst = { + segmentIndex: segment, + footX: a.x + ux * (param * chord), + footY: a.y + uy * (param * chord), + nx, + ny, + perp, + clear, + intrusion, + }; + } + } + } + + return worst; +} + +/** + * Max detour waypoints inserted per routed highway before giving up. + * Termination guard, not a quality target: in a dense field each inserted + * waypoint can expose a new clip on a neighbouring bubble, so the pass loop + * has no structural exit of its own. + * + * @defaultValue 8. Settled layouts rarely need more than a few passes (the + * top-level optimiser penalises edge-through-bubble intrusions directly, so a + * typical highway detours around zero to two bubbles); cap-hits are mostly + * transient mid-animation frames that self-heal as bubbles spread apart. + * Raising it clears residual clips in denser fields at the cost of extra + * whole-path obstacle sweeps per routed edge per frame; lowering it saves that + * compute but returns more `capped` (still-clipping) routes. + */ +const ROUTE_MAX_PASSES = 8; +/** A point counts as inside a bubble within this multiple of its radius. */ +const ROUTE_CONTAIN_TOLERANCE = 1.02; + +/** Is `pt` inside (or essentially on the edge of) the obstacle circle? */ +function containsPoint(circle: Circle, pt: Position): boolean { + return ( + Math.hypot(circle.x - pt.x, circle.y - pt.y) <= + circle.radius * ROUTE_CONTAIN_TOLERANCE + ); +} + +/** A routed highway: the curve chain to draw plus the routing health flag. */ +interface RoutedHighway { + readonly curves: CubicCurve[]; + /** + * True when {@link ROUTE_MAX_PASSES} was exhausted with an obstacle still + * clipping the path (the same convention as the majorization solver's + * `settleCapped`): the curves are drawable but may cross a bubble they + * should bend around. + */ + readonly capped: boolean; +} + +/** + * Routes a highway from `source` to `target` that bends around every intervening + * bubble (one waypoint per obstacle, on the shorter side), or the straight cubic + * if nothing is in the way. Multi-pass: after inserting a detour the whole + * polyline is re-tested, so a waypoint that newly clips another bubble is itself + * routed around; unless `capped` reports otherwise, the returned path is clear + * of every (circle) obstacle. A bubble that encloses an endpoint is exempt: the + * edge has to enter/leave it, so an endpoint's own (highway-level) container and + * every ancestor enclosing it (opened clusters included) are skipped, tested + * purely geometrically. Curves bow gently off the polyline, so segment clearance + * closely approximates curve clearance. + * + * Best-effort under the pass cap: a field dense enough to exhaust + * {@link ROUTE_MAX_PASSES} (typically a mid-animation frame whose bubbles have + * not spread apart yet) returns the still-clipping path with `capped: true` + * instead of dropping the edge, because a missing highway misreads as a missing + * relationship while a residual clip only draws the edge across a bubble + * (rendered under the translucent bubble fill, it reads as passing behind it). + * The flag is the only signal of the degradation; the geometry itself does not + * distinguish a capped route. + */ +function routeAround( + source: Waypoint, + target: Waypoint, + sourceId: ClusterId, + targetId: ClusterId, + obstacles: readonly { readonly id: ClusterId; readonly circle: Circle }[], + config: VizConfig, +): RoutedHighway { + if (obstacles.length === 0) { + return { + curves: computeRawCurves(source, target, [], config), + capped: false, + }; + } + + // A bubble is not an obstacle for an edge whose endpoint lives inside it (its + // own container and every enclosing ancestor, opened clusters included). + const exempt = new Set([sourceId, targetId]); + for (const obstacle of obstacles) { + if ( + containsPoint(obstacle.circle, source) || + containsPoint(obstacle.circle, target) + ) { + exempt.add(obstacle.id); + } + } + + // Build the avoidance polyline by repeatedly routing around the worst clip. + // A hit surviving the final pass means the cap was exhausted mid-fix; it + // latches into `capped` so the degraded route is distinguishable from a + // clean one. + const path: Waypoint[] = [source, target]; + let residualHit = worstObstacleOnPath(path, exempt, obstacles); + for (let pass = 0; residualHit !== null && pass < ROUTE_MAX_PASSES; pass++) { + // Waypoint beside the obstacle, on the side opposite its centre (shorter + // detour), just past its clearance ring. + const side = residualHit.perp >= 0 ? -1 : 1; + const wpPerp = residualHit.perp + side * residualHit.clear; + path.splice(residualHit.segmentIndex + 1, 0, { + x: residualHit.footX + residualHit.nx * wpPerp, + y: residualHit.footY + residualHit.ny * wpPerp, + angle: 0, + }); + + residualHit = worstObstacleOnPath(path, exempt, obstacles); + } + + const capped = residualHit !== null; + + // Pull the polyline taut: drop any waypoint whose removal doesn't re-introduce + // a clip. The greedy multi-pass can leave alternating-side waypoints that + // zig-zag the centreline; this keeps only the load-bearing detours. + let removed = true; + while (removed && path.length > 2) { + removed = false; + for (let idx = 1; idx < path.length - 1; idx++) { + const shortcut = [path[idx - 1]!, path[idx + 1]!]; + if (!worstObstacleOnPath(shortcut, exempt, obstacles)) { + path.splice(idx, 1); + removed = true; + break; + } + } + } + + if (path.length === 2) { + return { + curves: computeRawCurves(source, target, [], config), + capped, + }; + } + + // Build smooth C1 cubics through the waypoints. The through-tangent at an + // interior point runs along (next - prev) (Catmull-Rom). Crucial detail: + // `cubicBetweenWaypoints` places a segment's end handle at p2 = p3 + + // tension*dir(angle), so the end angle must point backward along the tangent, + // otherwise p2 lands beyond p3 (further from p0 than p3) and the cubic + // overshoots and loops back on itself at every waypoint. The start handle + // points forward; the path endpoints keep their port-normal angles. + const tangentAt = (idx: number): number => { + if (idx === 0) { + return source.angle; + } + if (idx === path.length - 1) { + return target.angle; + } + const prev = path[idx - 1]!; + const next = path[idx + 1]!; + return Math.atan2(next.y - prev.y, next.x - prev.x); + }; + + const curves: CubicCurve[] = []; + for (let idx = 0; idx < path.length - 1; idx++) { + const a = path[idx]!; + const b = path[idx + 1]!; + const fromAngle = tangentAt(idx); + const toAngle = + idx + 1 === path.length - 1 ? target.angle : tangentAt(idx + 1) + Math.PI; + const segmentLength = Math.hypot(b.x - a.x, b.y - a.y); + const tension = config.portTension * segmentLength; + curves.push( + cubicBetweenWaypoints( + { x: a.x, y: a.y, angle: fromAngle }, + { x: b.x, y: b.y, angle: toAngle }, + tension, + false, + ), + ); + } + return { curves, capped }; +} + +/** + * Build Bezier segments for the BezierSDFLayer, plus label/arrow marks for each directed drawn + * lane. Nested pairs share their merged container highway's lanes instead of scattering numbers + * per base pair. + * + * Segments are written into the caller-owned `out` sink (flat typed arrays) + * rather than allocating per-segment objects. The caller is responsible for + * calling `out.reset()` before invoking and `out.snapshot()` afterwards. + */ +export function buildBezierSegments( + frame: EdgeFrame, + portPairs: ReadonlyMap< + string, + { readonly source: Port; readonly target: Port } + >, + ctx: EdgeGeometryContext, + config: VizConfig, + out: BezierSegmentSink, + labelsOut: RenderEdgeLabel[], + arrowsOut: RenderEdgeArrow[], +): void { + const byPair = new Map(); + for (const edge of frame.visualEdges) { + if (edge.kind !== "aggregate") { + continue; + } + let list = byPair.get(edge.pairKey); + if (!list) { + list = []; + byPair.set(edge.pairKey, list); + } + list.push(edge); + } + + const classified: ClassifiedPair[] = []; + for (const [pairKey, edges] of byPair) { + const sourceId = edges[0]!.source.id; + const targetId = edges[0]!.target.id; + const hierarchy = analyzeHierarchy( + sourceId, + targetId, + ctx.clusterTree, + ctx.cutIndex.containerIds, + ); + classified.push({ pairKey, edges, sourceId, targetId, hierarchy }); + } + + let cappedRouteCount = 0; + + for (const pair of classified) { + if ( + pair.hierarchy.sourceContainers.length > 0 || + pair.hierarchy.targetContainers.length > 0 + ) { + continue; + } + const ports = portsFor(portPairs, pair.sourceId, pair.targetId); + if (!ports) { + continue; + } + const route = routeAround( + ports.a, + ports.b, + pair.sourceId, + pair.targetId, + ctx.obstacles, + config, + ); + + if (route.capped) { + cappedRouteCount += 1; + } + + const directSrc = ctx.clusterTree.get(pair.sourceId)?.circle; + const directTgt = ctx.clusterTree.get(pair.targetId)?.circle; + emitCurveLanes( + out, + route.curves, + pair.edges, + labelsOut, + arrowsOut, + directSrc ? clipInside(directSrc) : undefined, + directTgt ? clipInside(directTgt) : undefined, + ); + } + + const highwayGroups = new Map(); + let droppedPairCount = 0; + let droppedFeederChildCount = 0; + + for (const pair of classified) { + const { sourceContainers, targetContainers } = pair.hierarchy; + if (sourceContainers.length === 0 && targetContainers.length === 0) { + continue; + } + + const outermostSource = + sourceContainers.length > 0 + ? sourceContainers[sourceContainers.length - 1]! + : undefined; + const outermostTarget = + targetContainers.length > 0 + ? targetContainers[targetContainers.length - 1]! + : undefined; + + const highwaySourceId = outermostSource?.containerId ?? pair.sourceId; + const highwayTargetId = outermostTarget?.containerId ?? pair.targetId; + const groupKey = `${highwaySourceId}\x1f${highwayTargetId}`; + + let group = highwayGroups.get(groupKey); + if (!group) { + const sourceCircle = + outermostSource?.circle ?? ctx.clusterTree.get(pair.sourceId)?.circle; + const targetCircle = + outermostTarget?.circle ?? ctx.clusterTree.get(pair.targetId)?.circle; + // Both lookups resolve while the committed edge frame and the live + // tree are in sync: pair ids are tree ids captured at the last + // structure commit, mutations that remove tree ids run only inside a + // commit that re-derives the frame before the next emit, and between + // commits the tree only gains nodes. A miss therefore means frame and + // tree have desynced (e.g. stale aggregator pair state) and the + // endpoint has no meaningful position; emitting a curve to a made-up + // origin circle would draw a highway to (0,0). Drop the pair and + // tally it: the drop recurs every frame while the desync lasts, and + // the debug warn below is its only surface. + if (!sourceCircle || !targetCircle) { + droppedPairCount += 1; + continue; + } + + group = { + highwaySourceId, + highwaySourceCircle: sourceCircle, + highwayTargetId, + highwayTargetCircle: targetCircle, + sourceChildren: [], + targetChildren: [], + }; + highwayGroups.set(groupKey, group); + } + + // Same frame/tree desync class as the group-circle lookup above (pair ids + // are tree ids captured at the last structure commit; a miss means an + // in-flight desync, tallied there). Here a miss drops only this child's + // feeder lanes from the merged highway, not the whole group: the group + // itself already resolved via the outermost container ids, which can + // differ from this inner child id. + if (outermostSource) { + const childCluster = ctx.clusterTree.get(pair.sourceId); + if (childCluster) { + group.sourceChildren.push({ + childId: pair.sourceId, + childCircle: childCluster.circle, + edges: pair.edges, + }); + } else { + droppedFeederChildCount += 1; + } + } + if (outermostTarget) { + const childCluster = ctx.clusterTree.get(pair.targetId); + if (childCluster) { + group.targetChildren.push({ + childId: pair.targetId, + childCircle: childCluster.circle, + edges: pair.edges, + }); + } else { + droppedFeederChildCount += 1; + } + } + } + + let portFallbackCount = 0; + + for (const group of highwayGroups.values()) { + // Each pair contributes its edges once. When both endpoints are + // nested, the pair appears in both sourceChildren and targetChildren + // with the same edges, so merging both sides doubles every lane. + const usingSource = group.sourceChildren.length > 0; + const mergeChildren = usingSource + ? group.sourceChildren + : group.targetChildren; + const merged = mergeLanes(mergeChildren, usingSource ? "source" : "target"); + if (merged.length === 0) { + continue; + } + + // A merged highway collapses several aggregate edges (per type+direction + // across children) into one ribbon, so no single lane id applies. Carry the + // group's representative aggregate lane id on every highway + feeder + // segment, so a clicked merged highway still resolves to a real lane's link + // set. Lanes carry it via a tagged copy; feeders take it as an argument. + const groupLaneId = mergeChildren[0]?.edges[0]?.laneId ?? BEZIER_NO_LINK; + const mergedLanes = merged.map((lane) => ({ + ...lane, + laneId: groupLaneId, + })); + + // Route through the stable container-level ports so the highway attaches at + // the same point whether the container is open or closed. The per-emit port + // pass derives a pair for every aggregated pair's highway endpoints from the + // same live tree and committed cut that built these groups, so while frame + // and tree are in sync this lookup always resolves. A miss means an endpoint + // id failed to resolve during the port pass (the frame/tree desync class + // `droppedPairCount` tallies above) and recurs every tick until a structure + // commit re-derives frame and ports together. The fallback keeps the highway + // rendered with near-port geometry: the raw boundary waypoint sits on the + // same padded ring at the port's ideal centre-to-centre angle, differing + // from a real port only by the crowding adjustment port placement applies on + // busy rims. It is used every tick while the desync lasts (never flapping + // frame-to-frame against a real port), and tallied because the plausible + // fallback otherwise hides the desync entirely. + const hp = portsFor( + portPairs, + group.highwaySourceId, + group.highwayTargetId, + ); + if (!hp) { + portFallbackCount += 1; + } + const sourceWp: Waypoint = + hp?.a ?? + containerBoundaryWaypoint( + group.highwaySourceCircle, + group.highwayTargetCircle.x, + group.highwayTargetCircle.y, + config.portPaddingWorld, + ); + const targetWp: Waypoint = + hp?.b ?? + containerBoundaryWaypoint( + group.highwayTargetCircle, + group.highwaySourceCircle.x, + group.highwaySourceCircle.y, + config.portPaddingWorld, + ); + + const route = routeAround( + sourceWp, + targetWp, + group.highwaySourceId, + group.highwayTargetId, + ctx.obstacles, + config, + ); + + if (route.capped) { + cappedRouteCount += 1; + } + + emitCurveLanes( + out, + route.curves, + mergedLanes, + labelsOut, + arrowsOut, + clipInside(group.highwaySourceCircle), + clipInside(group.highwayTargetCircle), + ); + + emitRecursiveBezierFeeders( + out, + arrowsOut, + group.sourceChildren, + sourceWp, + group.highwaySourceId, + "source", + ctx.clusterTree, + ctx.cutIndex.containerIds, + config, + groupLaneId, + ); + emitRecursiveBezierFeeders( + out, + arrowsOut, + group.targetChildren, + targetWp, + group.highwayTargetId, + "target", + ctx.clusterTree, + ctx.cutIndex.containerIds, + config, + groupLaneId, + ); + } + + // A capped route is drawn anyway and looks plausible (the edge reads as + // passing behind the bubble), so cap exhaustion is invisible in the frame + // itself; this is its only surface. Debug-gated because routing reruns + // every positions tick and transient caps during settle animation would + // spam a production console. + if (config.debug && cappedRouteCount > 0) { + // eslint-disable-next-line no-console + console.warn( + `[graph-worker][edge-routing] ${cappedRouteCount} highway route(s) hit ` + + `ROUTE_MAX_PASSES; residual bubble clips possible this frame`, + ); + } + + // A frame/tree desync is invisible in the rendered frame itself (the + // affected pairs' links simply vanish) and recurs every positions tick + // while it lasts, so this warn is its only surface. Debug-gated because + // it would repeat once per tick in a production console. + if (config.debug && droppedPairCount > 0) { + // eslint-disable-next-line no-console + console.warn( + `[graph-worker][edge-geometry] ${droppedPairCount} nested pair(s) ` + + `dropped: endpoint cluster missing from tree (frame/tree desync)`, + ); + } + + // Same desync class as droppedPairCount above, scoped to a single child + // instead of the whole highway: the group still renders (via the outermost + // container ids, which resolved), but this child's own edges are missing + // from its feeder lanes until a structure commit heals the desync. + if (config.debug && droppedFeederChildCount > 0) { + // eslint-disable-next-line no-console + console.warn( + `[graph-worker][edge-geometry] ${droppedFeederChildCount} feeder ` + + `child(ren) dropped: child cluster missing from tree (frame/tree desync)`, + ); + } + + // A missing port pair is invisible in the rendered frame itself (the + // boundary-waypoint fallback draws a plausible highway), so this warn is + // its only surface. Debug-gated because it recurs once per positions tick + // while the desync lasts. + if (config.debug && portFallbackCount > 0) { + // eslint-disable-next-line no-console + console.warn( + `[graph-worker][edge-geometry] ${portFallbackCount} highway group(s) ` + + `missing a container-level port pair; boundary-waypoint fallback ` + + `used (frame/tree desync)`, + ); + } + + // Entity-level edges are omitted here: their endpoints live in the position + // SharedArrayBuffer that updates every force tick; recomputing cubics here + // would be O(entities * degree) per tick. Straight segments are composed + // where that buffer is already read for dots (see RenderEntityLayer), so + // dots and their edges share one update channel and cannot tear. +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/entity-fanout-exit.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/entity-fanout-exit.test.ts new file mode 100644 index 00000000000..3815e159619 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/entity-fanout-exit.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { containerBoundaryWaypoint } from "./edge-geometry"; + +/** + * Regression: at nesting depth >= 2, the entity fan-out exit must aim at the + * same first boundary waypoint the recursive feeder uses + * (containerBoundaryWaypoint toward the outermost port), not directly at the + * outermost port. + */ +const outermostPort = { x: 100, y: 0 }; // hp.a, on the outermost container's rim +const intermediate = { x: -30, y: -40, radius: 30 }; // container between leaf + outer +const leaf = { x: -45, y: -50, radius: 5 }; // off-centre inside the intermediate + +function angleFromLeaf(target: { x: number; y: number }): number { + return Math.atan2(target.y - leaf.y, target.x - leaf.x); +} + +describe("entity fan-out exit (depth ≥ 2)", () => { + // What the feeder actually does (edge-geometry.ts emitRecursiveBezierFeeders): + // leave toward the enclosing container's boundary, aimed at the outermost port. + const feederFirstWaypoint = containerBoundaryWaypoint( + intermediate, + outermostPort.x, + outermostPort.y, + 0, + ); + const feederExitAngle = angleFromLeaf(feederFirstWaypoint); + + it("the fixed exit aims exactly where the feeder leaves the bucket", () => { + // Expected: exit angle matches feeder first-waypoint angle. + const fixedExitAngle = angleFromLeaf(feederFirstWaypoint); + expect(Math.abs(fixedExitAngle - feederExitAngle)).toBeLessThan(1e-9); + }); + + it("aiming straight at the outermost port drifts off the feeder at depth >= 2", () => { + const oldExitAngle = angleFromLeaf(outermostPort); + const driftDegrees = + (Math.abs(oldExitAngle - feederExitAngle) * 180) / Math.PI; + // eslint-disable-next-line no-console + console.log( + `[fanout-exit] old-vs-feeder drift = ${driftDegrees.toFixed(2)}°`, + ); + expect(driftDegrees).toBeGreaterThan(2); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/feeder-continuity.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/feeder-continuity.test.ts new file mode 100644 index 00000000000..95a4920ffac --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/feeder-continuity.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; + +import { containerBoundaryWaypoint } from "./edge-geometry"; + +/** + * Regression: consecutive feeder hops on the same container rim must share an + * endpoint. With two or more intermediate containers, aiming the outgoing hop + * at the next crossing instead of the outermost port breaks continuity. + */ +const outermostPort = { x: 200, y: 0 }; + +function angleAt( + circle: { x: number; y: number }, + pt: { x: number; y: number }, +): number { + return Math.atan2(pt.y - circle.y, pt.x - circle.x); +} + +describe("feeder hop continuity", () => { + it("ONE intermediate container: incoming end == outgoing start (continuous)", () => { + // participant inside `inner`, `inner` directly inside the outermost. + const inner = { x: 20, y: 30, radius: 20 }; + + // Incoming hop (participant → inner): ends at inner's boundary toward outer. + const incomingEnd = containerBoundaryWaypoint( + inner, + outermostPort.x, + outermostPort.y, + 0, + ); + // Outgoing hop (inner → outermost): since inner's parent IS the outermost, + // its target is the outermost port directly, so its start aims there too. + const outgoingStart = containerBoundaryWaypoint( + inner, + outermostPort.x, + outermostPort.y, + 0, + ); + const driftDeg = + (Math.abs(angleAt(inner, incomingEnd) - angleAt(inner, outgoingStart)) * + 180) / + Math.PI; + expect(driftDeg).toBeLessThan(1e-9); + }); + + it("TWO intermediate containers: incoming end != outgoing start (the drift)", () => { + const outer = { x: 60, y: 10, radius: 40 }; // intermediate closer to outermost + const inner = { x: 20, y: 30, radius: 20 }; // intermediate around the participant + + // Incoming hop (participant → inner): ends at inner's boundary toward the + // OUTERMOST port. + const incomingEnd = containerBoundaryWaypoint( + inner, + outermostPort.x, + outermostPort.y, + 0, + ); + // Outgoing hop (inner → outer): target is outer's boundary toward outermost. + // Incorrect reference: outgoing start re-projected toward the next crossing + // instead of the outermost port. + const outerBoundary = containerBoundaryWaypoint( + outer, + outermostPort.x, + outermostPort.y, + 0, + ); + const outgoingStart = containerBoundaryWaypoint( + inner, + outerBoundary.x, + outerBoundary.y, + 0, + ); + + const driftDeg = + (Math.abs(angleAt(inner, incomingEnd) - angleAt(inner, outgoingStart)) * + 180) / + Math.PI; + // eslint-disable-next-line no-console + console.log( + `[feeder] two-intermediate hop drift = ${driftDeg.toFixed(2)}°`, + ); + expect(driftDeg).toBeGreaterThan(2); + + // Correct: both hops use the boundary point toward the outermost port, so + // the junction is shared. + const outgoingStartFixed = containerBoundaryWaypoint( + inner, + outermostPort.x, + outermostPort.y, + 0, + ); + const fixedDriftDeg = + (Math.abs( + angleAt(inner, incomingEnd) - angleAt(inner, outgoingStartFixed), + ) * + 180) / + Math.PI; + expect(fixedDriftDeg).toBeLessThan(1e-9); + }); + + it("three intermediates: corrected junctions stay continuous at every level", () => { + // Chain inner→outer: participant → c3 → c2 → c1 → outermost. The feeder hops + // outward, so each container's "next hop" is the next-OUTER container (and the + // outermost intermediate hops straight to the port). + const containers = [ + { x: 35, y: 55, radius: 18 }, // c3 (around the participant, innermost) + { x: 60, y: 40, radius: 35 }, // c2 + { x: 120, y: 20, radius: 60 }, // c1 (closest to outermost) + ]; + + // Each container's crossing is defined ONCE, toward the outermost port. + const crossings = containers.map((container) => + containerBoundaryWaypoint(container, outermostPort.x, outermostPort.y, 0), + ); + + let maxOldDrift = 0; + for (let level = 0; level < containers.length; level++) { + const container = containers[level]!; + const crossing = crossings[level]!; + // Both arriving and leaving hops aim at the outermost port, so the + // junction stays continuous at every depth. + const fixedStart = containerBoundaryWaypoint( + container, + outermostPort.x, + outermostPort.y, + 0, + ); + const fixedDrift = + (Math.abs( + angleAt(container, crossing) - angleAt(container, fixedStart), + ) * + 180) / + Math.PI; + expect(fixedDrift).toBeLessThan(1e-9); + + // Incorrect: outgoing hop aimed at the next crossing; drift varies by + // geometry but appears on any chain with depth >= 2. + if (level < containers.length - 1) { + const nextCrossing = crossings[level + 1]!; + const oldStart = containerBoundaryWaypoint( + container, + nextCrossing.x, + nextCrossing.y, + 0, + ); + const oldDrift = + (Math.abs( + angleAt(container, crossing) - angleAt(container, oldStart), + ) * + 180) / + Math.PI; + // eslint-disable-next-line no-console + console.log( + `[feeder] level ${level} old drift = ${oldDrift.toFixed(2)}°`, + ); + maxOldDrift = Math.max(maxOldDrift, oldDrift); + } + } + expect(maxOldDrift).toBeGreaterThan(2); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/world-positions.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/world-positions.test.ts new file mode 100644 index 00000000000..1028df73697 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/world-positions.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { ClusterId } from "../../ids"; +import { ClusterNode } from "../hierarchy/cluster-tree"; +import { syncWorldPositions } from "./world-positions"; + +import type { ForceNode, LayoutSimulation } from "../layout/force-simulation"; + +/** + * A minimal settled LayoutSimulation whose node local offsets we control, so we + * test the world composition without any solver noise. + */ +function fakeLayout(localOffsets: { id: string; x: number; y: number }[]): { + layout: LayoutSimulation; + nodes: ForceNode[]; +} { + const nodes: ForceNode[] = localOffsets.map((offset) => ({ + id: offset.id, + radius: 1, + x: offset.x, + y: offset.y, + })); + const layout: LayoutSimulation = { + status: "settled", + isSettled: true, + nodes, + buffer: new ArrayBuffer(0), + nodeIds: nodes.map((node) => node.id), + alpha: 0, + tick: () => false, + pause: () => {}, + resume: () => {}, + }; + return { layout, nodes }; +} + +function makeNode(id: string): ClusterNode { + const node = new ClusterNode(ClusterId(id), "community", { + source: "groups", + keys: [], + }); + node.circle.radius = 10; + return node; +} + +describe("syncWorldPositions, nested world composition", () => { + it("composes leaf world = root + container-local + leaf-local at depth 2", () => { + const root = makeNode("cluster:root"); + const container = makeNode("A"); // top-level container + const leaf = makeNode("A:leaf"); // depth-2 leaf inside the container + root.addChild(container); + container.addChild(leaf); + + // The root's layout (keyed by the root) places the container at local + // (100, 0); the container's layout (keyed by the container) places the leaf + // at local (5, 3). Layouts are keyed by the parent cluster whose children + // they position. + const rootLayout = fakeLayout([{ id: "A", x: 100, y: 0 }]); + const containerLayout = fakeLayout([{ id: "A:leaf", x: 5, y: 3 }]); + const layouts = new Map([ + ["cluster:root", rootLayout.layout], + ["A", containerLayout.layout], + ]); + const isCluster = (id: ClusterId): boolean => layouts.has(id); + const layoutFor = (id: ClusterId): LayoutSimulation | undefined => + layouts.get(id); + + syncWorldPositions(root, layoutFor, isCluster); + + expect(container.circle.x).toBe(100); + expect(leaf.circle.x).toBe(105); // 0 (root) + 100 (A) + 5 (leaf) + expect(leaf.circle.y).toBe(3); + }); + + it("carries a deep leaf when the macro moves the container, through a settled intermediate", () => { + const root = makeNode("cluster:root"); + const container = makeNode("A"); + const leaf = makeNode("A:leaf"); + root.addChild(container); + container.addChild(leaf); + + const rootLayout = fakeLayout([{ id: "A", x: 100, y: 0 }]); + const containerLayout = fakeLayout([{ id: "A:leaf", x: 5, y: 3 }]); + const layouts = new Map([ + ["cluster:root", rootLayout.layout], + ["A", containerLayout.layout], + ]); + const isCluster = (id: ClusterId): boolean => layouts.has(id); + const layoutFor = (id: ClusterId): LayoutSimulation | undefined => + layouts.get(id); + + syncWorldPositions(root, layoutFor, isCluster); + expect(leaf.circle.x).toBe(105); + + // The macro re-settles and moves the container by +50 in x. Only the ROOT + // layout changes; the container's own (settled) layout is untouched. + rootLayout.nodes[0]!.x = 150; + + syncWorldPositions(root, layoutFor, isCluster); + + // The deep leaf follows the container translation; it must not keep the + // pre-move world x. + expect(container.circle.x).toBe(150); + expect(leaf.circle.x).toBe(155); // followed: 150 + 5 + expect(leaf.circle.y).toBe(3); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/world-positions.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/world-positions.ts new file mode 100644 index 00000000000..f716fd78b20 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/geometry/world-positions.ts @@ -0,0 +1,48 @@ +/** + * Recompose world positions for the opened cluster subtree. + * + * `child.world = parent.world + child.localOffset`. Must run even through + * settled layouts: the macro layout may have moved an intermediate container, + * and a settled child layout never re-publishes its world circles. + * + * Top-down order: a parent's world circle is updated before we descend. + * Cost is bounded by the opened subtree, not the whole tree. + */ +import type { ClusterId } from "../../ids"; +import type { ClusterNode } from "../hierarchy/cluster-tree"; +import type { LayoutSimulation } from "../layout/force-simulation"; + +export function syncWorldPositions( + root: ClusterNode, + layoutFor: (id: ClusterId) => LayoutSimulation | undefined, + isClusterLayout: (id: ClusterId) => boolean, +): void { + const visit = (cluster: ClusterNode): void => { + const layout = layoutFor(cluster.id); + // Apply only when this cluster has a matching clusters layout (same child + // count); entity layouts, stale layouts, and closed clusters leave + // existing world circles unchanged for this subtree. + if ( + layout && + isClusterLayout(cluster.id) && + cluster.children.length === layout.nodes.length + ) { + const childById = new Map( + cluster.children.map((child) => [child.id, child]), + ); + for (const node of layout.nodes) { + // ForceSimulation node ids are ClusterId strings for cluster layouts. + const child = childById.get(node.id as ClusterId); + if (child) { + child.circle.x = cluster.circle.x + (node.x ?? 0); + child.circle.y = cluster.circle.y + (node.y ?? 0); + } + } + } + for (const child of cluster.children) { + visit(child); + } + }; + + visit(root); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/cluster-feature-source.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/cluster-feature-source.ts new file mode 100644 index 00000000000..ec23e960b31 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/cluster-feature-source.ts @@ -0,0 +1,136 @@ +/** + * Builds a {@link FeatureSource} over worker stores for + * {@link nameClustersByDistinctiveFeatures}. + * + * Feature keys are namespaced strings (`p`/`lt`/`n` + NUL-separated fields); + * this module decodes them in {@link FeatureSource.describe}. + */ +import { primaryTypeOfSet } from "../entity-style"; + +import type { EntityIndex, TypeId } from "../../ids"; +import type { EntityStore } from "../entity-graph/store/entity"; +import type { LinkStore } from "../entity-graph/store/link"; +import type { PropertyStore } from "../entity-graph/store/property"; +import type { TypeSetStore } from "../entity-graph/store/type-set"; +import type { TypeRegistry } from "../store/type-registry"; +import type { + FeatureDescriptor, + FeatureSource, + NumericDimension, + NumericReading, +} from "./distinctive-cluster-label"; + +export interface ClusterFeatureDependencies { + readonly properties: PropertyStore; + readonly links: LinkStore; + readonly entities: EntityStore; + readonly typeSets: TypeSetStore; + readonly types: TypeRegistry; +} + +/** Sorts link parts after property/range parts (which sort by their human title). */ +const LINK_SORT_PREFIX = "\uFFFF"; + +/** + * Returns a {@link FeatureSource} backed by the worker's property, link, + * entity, type-set, and type stores. + * + * Keys are opaque to the namer; {@link FeatureSource.describe} and + * {@link FeatureSource.describeNumeric} decode them for display. Link + * features require a resolvable target type; members without one are + * skipped. + */ +export function createClusterFeatureSource( + dependencies: ClusterFeatureDependencies, +): FeatureSource { + const { properties, links, entities, typeSets, types } = dependencies; + + /** The primary (most specific) type of the entity an endpoint points at, if known. */ + const targetTypeIdx = (otherIdx: EntityIndex): TypeId | undefined => { + const groupIdx = entities.getTypeSet(otherIdx); + if (groupIdx === -1) { + return undefined; + } + const group = typeSets.getById(groupIdx); + if (!group) { + return undefined; + } + return primaryTypeOfSet(group.directTypeIds, types); + }; + + return { + *keysOf(member: EntityIndex): Iterable { + const features = properties.featuresOf(member); + if (features) { + for (const featureIdx of features) { + yield `p\u0000${featureIdx}`; + } + } + for (const link of links.linksFor(member)) { + const typeIdx = targetTypeIdx(link.otherId); + if (typeIdx === undefined) { + continue; + } + yield `lt\u0000${link.direction}\u0000${typeIdx}`; + } + }, + + *numericsOf(member: EntityIndex): Iterable { + const keys = properties.numericKeysOf(member); + const values = properties.numericValuesOf(member); + if (!keys || !values) { + return; + } + // keys and values are parallel arrays of equal length from the same member. + for (let index = 0; index < keys.length; index++) { + yield { dimension: `n\u0000${keys[index]!}`, value: values[index]! }; + } + }, + + describe(key: string): FeatureDescriptor | undefined { + const parts = key.split("\u0000"); + if (parts[0] === "p") { + const info = properties.describe(Number(parts[1])); + if (!info) { + return undefined; + } + return { + group: `prop\u0000${info.baseUrl}`, + text: `${info.title} = ${info.display}`, + sortKey: info.title, + }; + } + if (parts[0] === "lt") { + const direction = parts[1]; + // parts[2] is the type index encoded when the key was built from a + // resolved target type. + const info = types.get(Number(parts[2]) as TypeId); + if (!info) { + return undefined; + } + const arrow = direction === "out" ? "→" : "←"; + return { + group: key, + text: `${arrow} ${info.title}`, + sortKey: `${LINK_SORT_PREFIX}${direction}\u0000${info.title}`, + }; + } + return undefined; + }, + + describeNumeric(dimension: string): NumericDimension | undefined { + const keyIdx = Number(dimension.split("\u0000")[1]); + const baseUrl = properties.numericBaseUrl(keyIdx); + if (baseUrl === undefined) { + return undefined; + } + const title = properties.title(baseUrl); + return { + group: `prop\u0000${baseUrl}`, + title, + kind: properties.numericKind(keyIdx), + sortKey: title, + }; + }, + }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/cluster-radii.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/cluster-radii.test.ts new file mode 100644 index 00000000000..44e5d80faea --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/cluster-radii.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { enclosingRadius } from "./cluster-tree"; + +// Top-level minimum radius (`TOP_LEVEL_MIN_RADIUS` = 15) for a count-6 leaf-sized bubble. +const TOP_LEVEL_FLOOR = 15; + +describe("enclosingRadius", () => { + it("is 0 for no children", () => { + expect(enclosingRadius([], 2)).toBe(0); + }); + + it("is the child radius for a single child", () => { + expect(enclosingRadius([10], 2)).toBe(10); + }); + + it("sums the radii (+gap) for two children placed side by side", () => { + expect(enclosingRadius([8, 8], 2)).toBe(18); + expect(enclosingRadius([11, 8], 2)).toBe(21); + }); + + it("ignores child order (uses the two largest)", () => { + expect(enclosingRadius([8, 11], 2)).toBe(enclosingRadius([11, 8], 2)); + }); + + it("regression: two r=8 children need MORE than a count-6 leaf radius (15)", () => { + // The Company family was sized 15 by count but had to hold two r=8 children + // → overlap. The enclosing radius must exceed 15 so the container grows. + expect(enclosingRadius([8, 8], 2)).toBeGreaterThan(TOP_LEVEL_FLOOR); + }); + + it("places 3+ children on a ring (radius = ring + largest child)", () => { + const expected = (5 + 5 + 2) / (2 * Math.sin(Math.PI / 3)) + 5; + expect(enclosingRadius([5, 5, 5], 2)).toBeCloseTo(expected, 6); + }); + + it("grows as more equal children are added", () => { + expect(enclosingRadius([6, 6, 6, 6], 2)).toBeGreaterThan( + enclosingRadius([6, 6], 2), + ); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/cluster-sizing-config.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/cluster-sizing-config.ts new file mode 100644 index 00000000000..4edf31816ca --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/cluster-sizing-config.ts @@ -0,0 +1,43 @@ +/** + * Sizing of cluster bubbles and entity dots: leaf radii from member counts + * ({@link "./cluster-tree"}), enclosing-parent fits, and the entity-dot radius + * used when a leaf opens ({@link "../entity-graph/hierarchical/layouts"}, + * {@link "../core/frames/structure-frame"}). + * + * Kept separate from {@link "./cluster-tree"} so the main thread (root + * `config.ts`, the dev harness) can import the defaults without a cycle: + * cluster-tree itself consumes the composed `VizConfig`. + */ +export interface ClusterSizingConfig { + /** + * Leaf radius = sqrt(count) × this. + * + * @defaultValue 5. Higher values enlarge all bubbles proportionally; lower + * values increase overlap risk in force layout. + */ + readonly radiusPerSqrtCount: number; + /** Minimum leaf radius regardless of count, so tiny clusters stay clickable. @defaultValue 8. */ + readonly leafMinRadius: number; + /** Larger floor so singleton-type top-level bubbles stay clickable. @defaultValue 15. */ + readonly topLevelMinRadius: number; + /** Minimum gap enforced between sibling circles when sizing enclosing parents. @defaultValue 2. */ + readonly encloseGap: number; + /** Extra margin on top of the enclosing fit so force layout can settle children without clipping. @defaultValue 3. */ + readonly enclosePadding: number; + /** + * Entity-dot radius as a fraction of the parent bubble radius. + * + * @defaultValue 0.02. Lower values keep dense leaves readable; higher values + * make individual dots dominate the bubble. + */ + readonly entityRadiusFraction: number; +} + +export const defaultClusterSizingConfig: ClusterSizingConfig = { + radiusPerSqrtCount: 5, + leafMinRadius: 8, + topLevelMinRadius: 15, + encloseGap: 2, + enclosePadding: 3, + entityRadiusFraction: 0.02, +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/cluster-tree.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/cluster-tree.ts new file mode 100644 index 00000000000..0da028ff547 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/cluster-tree.ts @@ -0,0 +1,1324 @@ +/* eslint-disable no-param-reassign */ +import { MutableCircle } from "../../geometry"; +import { ClusterId } from "../../ids"; +import { hslToRgb } from "../../math/color"; +import { murmur3StringUnit } from "../../math/hash"; +import { graphColors } from "../../visual-style"; +import { Column } from "../collections/column"; +import { defaultClusterSizingConfig } from "./cluster-sizing-config"; +import { subclusterByLinks } from "./community"; + +import type { VizConfig } from "../../config"; +import type { Color } from "../../frames"; +import type { ClusterKind, EntityIndex, TypeId, TypeSetKey } from "../../ids"; +import type { LinkStore } from "../entity-graph/store/link"; +import type { + TypeSetGroup, + TypeSetStore, +} from "../entity-graph/store/type-set"; +import type { TypeRegistry } from "../store/type-registry"; +import type { ClusterSizingConfig } from "./cluster-sizing-config"; + +function stableHashToAngle(id: string): number { + return murmur3StringUnit(id) * 2 * Math.PI; +} + +/** + * Display label for a cluster, plus the classification metadata behind it. + * + * `primaryType` is the type that decided the label, or `null` for generic + * rollup labels ("All entities", "More", "Mixed entities"). `coverage` is + * the fraction of the cluster's mass covered by `primaryType`; `isMixed` + * marks labels below the "clean" coverage threshold, shown to the user + * with a "Mostly " prefix. + */ +export class ClusterLabel { + readonly text: string; + readonly primaryType: TypeId | null; + readonly coverage: number; + readonly isMixed: boolean; + + constructor( + text?: string, + primaryType?: TypeId | null, + coverage?: number, + isMixed?: boolean, + ) { + this.text = text ?? ""; + this.primaryType = primaryType ?? null; + this.coverage = coverage ?? 0; + this.isMixed = isMixed ?? true; + } +} + +/** + * Per-cluster accumulator of type membership counts, tracked as `direct` + * (only a group's own type ids) and `closure` (direct types plus their + * ancestors) mass. + * + * Labeling reads these maps to find the type that best explains a + * cluster; `absorb` merges another cluster's mass in when subtrees are + * rolled up into a family node. + */ +export class ClusterMass { + readonly direct = new Map(); + readonly closure = new Map(); + + addGroup(group: TypeSetGroup): void { + for (const typeIdx of group.directTypeIds) { + this.direct.set(typeIdx, (this.direct.get(typeIdx) ?? 0) + group.count); + } + for (const typeIdx of group.closure.members()) { + this.closure.set(typeIdx, (this.closure.get(typeIdx) ?? 0) + group.count); + } + } + + removeGroup(group: TypeSetGroup, entityCount: number): void { + for (const typeIdx of group.directTypeIds) { + const prev = this.direct.get(typeIdx) ?? 0; + this.direct.set(typeIdx, Math.max(0, prev - entityCount)); + } + for (const typeIdx of group.closure.members()) { + const prev = this.closure.get(typeIdx) ?? 0; + this.closure.set(typeIdx, Math.max(0, prev - entityCount)); + } + } + + incrementForGroup(group: TypeSetGroup, delta: number): void { + for (const typeIdx of group.directTypeIds) { + this.direct.set(typeIdx, (this.direct.get(typeIdx) ?? 0) + delta); + } + for (const typeIdx of group.closure.members()) { + this.closure.set(typeIdx, (this.closure.get(typeIdx) ?? 0) + delta); + } + } + + absorb(other: ClusterMass): void { + for (const [typeIdx, mass] of other.direct) { + this.direct.set(typeIdx, (this.direct.get(typeIdx) ?? 0) + mass); + } + for (const [typeIdx, mass] of other.closure) { + this.closure.set(typeIdx, (this.closure.get(typeIdx) ?? 0) + mass); + } + } +} + +interface GroupMembership { + readonly source: "groups"; + readonly keys: TypeSetKey[]; +} + +interface DirectMembership { + readonly source: "direct"; + readonly members: Column; +} + +/** + * How a cluster's entity membership is tracked. `"groups"` clusters + * (type-set rollups, families) reference their member type-set keys and + * recompute mass from the live {@link TypeSetStore}; `"direct"` clusters + * (embedding and community children) hold a materialized column of entity + * indices instead. `ClusterNode.addGroupMass`, `removeGroupMass`, and + * `incrementGroupMass` only take effect on `"groups"` nodes. + */ +export type ClusterMembership = GroupMembership | DirectMembership; + +/** + * A node in the cluster hierarchy: an id, a `kind` discriminant, its own + * type mass, a live `count`, an ordered list of `children`, and a + * `circle` used for layout. + * + * `parent` is stored as a `WeakRef` so a node removed from the tree's + * registry can be garbage-collected even while a stale sibling or child + * reference still points at it; use `addChild`/`removeChild` rather than + * mutating `children` directly so `parent` stays consistent with the tree + * shape. `count` on `"direct"`-membership nodes is set directly by the + * caller that materializes the node, not through the mass-mutation + * methods above. + */ +export class ClusterNode { + readonly id: ClusterId; + readonly kind: ClusterKind; + readonly mass: ClusterMass; + readonly children: ClusterNode[]; + readonly membership: ClusterMembership; + count: number; + label: ClusterLabel; + readonly circle: MutableCircle; + + #parent: WeakRef | null = null; + + constructor(id: ClusterId, kind: ClusterKind, membership: ClusterMembership) { + this.id = id; + this.kind = kind; + this.mass = new ClusterMass(); + this.children = []; + this.membership = membership; + this.count = 0; + this.label = new ClusterLabel(); + this.circle = new MutableCircle(); + } + + get parent(): ClusterNode | null { + return this.#parent?.deref() ?? null; + } + + set parent(node: ClusterNode | null) { + this.#parent = node ? new WeakRef(node) : null; + } + + addChild(child: ClusterNode): void { + this.children.push(child); + child.parent = this; + } + + removeChild(child: ClusterNode): void { + const idx = this.children.indexOf(child); + if (idx !== -1) { + this.children.splice(idx, 1); + } + child.parent = null; + } + + clearChildren(): void { + for (const child of this.children) { + child.parent = null; + } + this.children.length = 0; + } + + addGroupMass(group: TypeSetGroup): void { + if (this.membership.source !== "groups") { + return; + } + if (!this.membership.keys.includes(group.key)) { + this.membership.keys.push(group.key); + } + this.count += group.count; + this.mass.addGroup(group); + } + + removeGroupMass(group: TypeSetGroup, entityCount: number): void { + if (this.membership.source !== "groups") { + return; + } + const keyIdx = this.membership.keys.indexOf(group.key); + if (keyIdx !== -1) { + this.membership.keys.splice(keyIdx, 1); + } + this.count -= entityCount; + this.mass.removeGroup(group, entityCount); + } + + incrementGroupMass(group: TypeSetGroup, delta: number): void { + this.count += delta; + this.mass.incrementForGroup(group, delta); + } +} + +/** + * One type-set's count change for {@link ClusterTree.updateIncrementally}. + * `previousCount` is the count before this delta was applied, used to + * detect a small group crossing the standalone-promotion threshold. + */ +export interface IngestDelta { + readonly groupKey: TypeSetKey; + readonly delta: number; + readonly isNewGroup: boolean; + readonly previousCount: number; +} + +function stableSortNodes(nodes: ClusterNode[]): ClusterNode[] { + return [...nodes].sort( + (lhs, rhs) => rhs.count - lhs.count || lhs.id.localeCompare(rhs.id), + ); +} + +/** + * Radius of a circle that can hold children of the given radii without overlap, + * placed on a ring. A ring is always a valid non-overlapping arrangement, so a + * confined force layout can always pack the children within this radius. Tight + * for <= 2 children (the common family case, two circles side by side); a single + * ring for more (conservative; the force layout then packs them tighter). + */ +export function enclosingRadius(radii: readonly number[], gap: number): number { + const count = radii.length; + if (count === 0) { + return 0; + } + if (count === 1) { + // count === 1 guarantees radii[0] exists. + return radii[0]!; + } + const sorted = [...radii].sort((left, right) => right - left); + // The two largest children could end up adjacent, size for that worst case. + // count >= 2 here guarantees sorted[0] and sorted[1] exist after the + // descending sort. + const widestPair = sorted[0]! + sorted[1]! + gap; + if (count === 2) { + return widestPair; + } + const ringRadius = widestPair / (2 * Math.sin(Math.PI / count)); + return ringRadius + sorted[0]!; +} + +function documentFrequency( + clusters: readonly ClusterNode[], + massKey: "direct" | "closure", +): Map { + const df = new Map(); + + for (const cluster of clusters) { + for (const typeIdx of cluster.mass[massKey].keys()) { + df.set(typeIdx, (df.get(typeIdx) ?? 0) + 1); + } + } + + return df; +} + +function bestCandidate( + cluster: ClusterNode, + mass: Map, + df: Map, + minCoverage: number, + totalClusters: number, + types: TypeRegistry, +): { typeIdx: TypeId; coverage: number; score: number } | undefined { + let best: { typeIdx: TypeId; coverage: number; score: number } | undefined; + + for (const [typeIdx, count] of mass) { + const coverage = count / cluster.count; + if (coverage < minCoverage) { + continue; + } + + const info = types.get(typeIdx); + const docFreq = df.get(typeIdx) ?? 1; + const idf = Math.log((totalClusters + 1) / (docFreq + 1)); + const depth = info?.depth ?? 0; + const score = coverage * (idf + 0.05 * depth); + + if ( + !best || + score > best.score || + (score === best.score && depth > (types.get(best.typeIdx)?.depth ?? 0)) + ) { + best = { typeIdx, coverage, score }; + } + } + + return best; +} + +function distinctiveLabel( + cluster: ClusterNode, + directDf: Map, + closureDf: Map, + totalClusters: number, + types: TypeRegistry, +): ClusterLabel { + // "other" clusters are heterogeneous; accept lower type coverage. + const minCoverage = cluster.kind === "other" ? 0.35 : 0.5; + + const candidate = + bestCandidate( + cluster, + cluster.mass.direct, + directDf, + minCoverage, + totalClusters, + types, + ) ?? + bestCandidate( + cluster, + cluster.mass.closure, + closureDf, + // Closure mass is inherited from ancestor types, a weaker signal + // than direct membership; require majority coverage regardless of + // cluster kind. + 0.5, + totalClusters, + types, + ); + + if (candidate) { + const info = types.get(candidate.typeIdx); + const title = info?.title ?? "Unknown"; + // Prefix "Mostly" and set isMixed when coverage is below 0.65. + const prefix = candidate.coverage < 0.65 ? "Mostly " : ""; + return new ClusterLabel( + `${prefix}${title}`, + candidate.typeIdx, + candidate.coverage, + candidate.coverage < 0.65, + ); + } + + return new ClusterLabel("Mixed entities"); +} + +function findMergeTarget( + small: TypeSetGroup, + anchorIndex: Map, + config: VizConfig, +): ClusterId { + const candidates = new Map(); + for (const typeIdx of small.closure.members()) { + for (const anchor of anchorIndex.get(typeIdx) ?? []) { + candidates.set(anchor.key, anchor); + } + } + + let best: + | { + group: TypeSetGroup; + rawJaccard: number; + directSuperset: boolean; + adjustedScore: number; + } + | undefined; + + for (const anchor of candidates.values()) { + const rawJaccard = small.closure.jaccard(anchor.closure); + const directSuperset = small.directTypeIds.isSubsetOf(anchor.directTypeIds); + // Prefer anchors whose direct types are a superset of the small + // group's, even when Jaccard is tied. + const adjustedScore = rawJaccard + (directSuperset ? 0.1 : 0); + + if ( + !best || + adjustedScore > best.adjustedScore || + (adjustedScore === best.adjustedScore && anchor.key < best.group.key) + ) { + best = { group: anchor, rawJaccard, directSuperset, adjustedScore }; + } + } + + if ( + best && + (best.rawJaccard >= config.mergeJaccardMin || + (best.directSuperset && best.rawJaccard >= config.mergeSubsetJaccardMin)) + ) { + return best.group.standaloneClusterId; + } + + const primaryType = small.directTypeIds.items[0] ?? 0; + return ClusterId(`cluster:other:${primaryType}`); +} + +function buildAnchorIndex(typeSets: TypeSetStore): Map { + const index = new Map(); + + for (const group of typeSets) { + if (!group.isStandalone || group.count === 0) { + continue; + } + + for (const typeIdx of group.closure.members()) { + let list = index.get(typeIdx); + if (!list) { + list = []; + index.set(typeIdx, list); + } + list.push(group); + } + } + + return index; +} + +const CLUSTER_GOLDEN_ANGLE_DEG = 137.508; +const SUBCLUSTER_HUE_SPAN_DEG = 18; + +function clusterDepth(cluster: ClusterNode): number { + let depth = 0; + let parent = cluster.parent; + while (parent?.kind !== "root") { + depth += 1; + parent = parent?.parent ?? null; + } + return depth; +} + +function inheritedPrimaryType(cluster: ClusterNode): { + typeIdx: TypeId | null; + inherited: boolean; +} { + if (cluster.label.primaryType !== null) { + return { typeIdx: cluster.label.primaryType, inherited: false }; + } + let parent = cluster.parent; + while (parent && parent.kind !== "root") { + if (parent.label.primaryType !== null) { + return { typeIdx: parent.label.primaryType, inherited: true }; + } + parent = parent.parent; + } + return { typeIdx: null, inherited: false }; +} + +function siblingTint(cluster: ClusterNode): number { + const siblings = cluster.parent?.children ?? []; + const count = siblings.length; + if (count <= 1) { + return 0; + } + const index = Math.max( + 0, + siblings.findIndex((sibling) => sibling.id === cluster.id), + ); + return (index / (count - 1) - 0.5) * SUBCLUSTER_HUE_SPAN_DEG; +} + +/** + * Returns the RGBA fill color for a cluster's bubble. + * + * Hue derives from the primary type's color slot, spread by the golden + * angle (`CLUSTER_GOLDEN_ANGLE_DEG` = 137.508°) so sibling root types + * stay visually distinct. Clusters that inherit their label from an + * ancestor (no distinctive type of their own) additionally shift hue via + * `siblingTint`, spreading up to `SUBCLUSTER_HUE_SPAN_DEG` (18°) across + * siblings so an inherited family reads as related bubbles. Mixed or + * low-coverage clusters (`isMixed`, or coverage below 0.55) desaturate + * toward gray; depth and inheritance both reduce alpha and adjust + * lightness so nested and inherited bubbles recede behind their more + * distinctive ancestors. + */ +export function colorForCluster( + cluster: ClusterNode, + types: TypeRegistry, +): Color { + const depth = clusterDepth(cluster); + const { typeIdx: primaryType, inherited } = inheritedPrimaryType(cluster); + if (primaryType === null) { + const alpha = depth > 0 ? 95 : 145; + return [126, 142, 160, alpha]; + } + + const info = types.get(primaryType); + const rootIdx = info?.rootIds[0] ?? primaryType; + const slot = types.colorSlot(rootIdx); + if (slot === undefined) { + const alpha = depth > 0 ? 95 : 145; + return [ + graphColors.fallbackEntity[0], + graphColors.fallbackEntity[1], + graphColors.fallbackEntity[2], + alpha, + ]; + } + + const rawHue = + slot * CLUSTER_GOLDEN_ANGLE_DEG + (inherited ? siblingTint(cluster) : 0); + const hue = ((rawHue % 360) + 360) % 360; + const mixed = cluster.label.isMixed || cluster.label.coverage < 0.55; + const [red, green, blue] = hslToRgb( + hue, + inherited ? 0.42 : mixed ? 0.36 : 0.6, + inherited ? 0.6 + Math.min(depth, 3) * 0.045 : depth > 0 ? 0.68 : 0.54, + ); + return [ + red, + green, + blue, + inherited ? 150 : depth > 0 ? (mixed ? 110 : 150) : mixed ? 165 : 215, + ]; +} + +/** + * Owns the cluster hierarchy: the node registry, lazy subdivision + * state, and embedding membership. All tree mutations go through + * this class so invariants (disjoint children, consistent counts, + * valid parent refs) are maintained in one place. + */ +export class ClusterTree { + readonly #nodes = new Map(); + readonly #root: ClusterNode; + readonly #subdivisionRequested = new Set(); + readonly #sizing: ClusterSizingConfig; + + constructor(sizing: ClusterSizingConfig = defaultClusterSizingConfig) { + this.#sizing = sizing; + this.#root = new ClusterNode(ClusterId("cluster:root"), "root", { + source: "groups", + keys: [], + }); + this.#register(this.#root); + } + + get root(): ClusterNode { + return this.#root; + } + + get(id: ClusterId): ClusterNode | undefined { + return this.#nodes.get(id); + } + + has(id: ClusterId): boolean { + return this.#nodes.has(id); + } + + get size(): number { + return this.#nodes.size; + } + + get isEmpty(): boolean { + return this.#nodes.size <= 1; + } + + values(): IterableIterator { + return this.#nodes.values(); + } + + atomicSum(): number { + let sum = 0; + for (const node of this.#nodes.values()) { + if (node.kind === "type-set" || node.kind === "other") { + sum += node.count; + } + } + return sum; + } + + /** + * Returns an indented debug string of the full tree (label, count, + * kind, radius, child count, id). For diagnostics only; not stable + * across versions. + */ + debugDump(): string { + const lines: string[] = []; + const visit = (node: ClusterNode, depth: number): void => { + lines.push( + `${" ".repeat(depth)}"${node.label.text}" (${node.count}) ` + + `[${node.kind}] r=${Math.round(node.circle.radius)} ` + + `nchildren=${node.children.length} id=${node.id}`, + ); + for (const child of node.children) { + visit(child, depth + 1); + } + }; + visit(this.#root, 0); + return lines.join("\n"); + } + + /** + * Full rebuild pipeline: reclassifies type-sets, clears the tree, + * materializes clusters, recomputes labels, rebuilds the display + * hierarchy, and lays out top-level radii. + * + * Replaces all prior nodes; use {@link ClusterTree.updateIncrementally} + * after the first batch. + */ + rebuild( + typeSets: TypeSetStore, + types: TypeRegistry, + config: VizConfig, + ): void { + for (const group of typeSets) { + group.recomputeClosure(types); + } + + this.#classifyAndMerge(typeSets, config); + this.#clearAll(); + this.#materializeClusters(typeSets); + this.#computeLabels(types); + this.#buildDisplayHierarchy(types, config); + this.#layoutTopLevel(); + } + + /** + * Incremental update. Handles count growth, new groups, threshold + * promotion, and re-targeting of small groups. + * + * Nodes left at count 0 after applying deltas are unregistered; dirty + * nodes are relabeled via {@link distinctiveLabel}; counts propagate up + * to the root ancestors of every touched node. When a delta triggers a + * structural change (promotion, re-targeting, a new group, or a node + * emptying out), family rollup nodes are cleared and the display + * hierarchy is rebuilt from scratch before layout runs. + */ + updateIncrementally( + deltas: readonly IngestDelta[], + typeSets: TypeSetStore, + types: TypeRegistry, + config: VizConfig, + ): void { + const dirtyIds = new Set(); + let needsHierarchyRebuild = false; + + for (const { groupKey } of deltas) { + typeSets.get(groupKey)?.recomputeClosure(types); + } + + const anchorIndex = buildAnchorIndex(typeSets); + + for (const { groupKey, delta, isNewGroup, previousCount } of deltas) { + const group = typeSets.get(groupKey); + if (!group) { + continue; + } + + const crossedThreshold = + !group.isStandalone && + previousCount < config.minStandaloneTypeSet && + group.count >= config.minStandaloneTypeSet; + + if (crossedThreshold) { + const oldNode = this.#nodes.get(group.assignedClusterId); + if (oldNode) { + oldNode.removeGroupMass(group, previousCount); + dirtyIds.add(oldNode.id); + } + + group.isStandalone = true; + group.assignedClusterId = group.standaloneClusterId; + + const newNode = this.#ensureNode(group.standaloneClusterId, "type-set"); + newNode.addGroupMass(group); + + if (oldNode) { + const angle = stableHashToAngle(newNode.id); + newNode.circle.x = + oldNode.circle.x + Math.cos(angle) * oldNode.circle.radius * 0.25; + newNode.circle.y = + oldNode.circle.y + Math.sin(angle) * oldNode.circle.radius * 0.25; + } + + dirtyIds.add(newNode.id); + needsHierarchyRebuild = true; + + const candidates = this.#smallGroupsSharingTypes(group, typeSets); + for (const small of candidates) { + const newTarget = findMergeTarget(small, anchorIndex, config); + if (newTarget !== small.assignedClusterId) { + const prevNode = this.#nodes.get(small.assignedClusterId); + if (prevNode) { + prevNode.removeGroupMass(small, small.count); + dirtyIds.add(prevNode.id); + } + + small.assignedClusterId = newTarget; + const targetNode = this.#ensureNode( + newTarget, + newTarget.startsWith("cluster:other:") ? "other" : "type-set", + ); + targetNode.addGroupMass(small); + dirtyIds.add(newTarget); + needsHierarchyRebuild = true; + } + } + } else if (isNewGroup) { + if (group.count >= config.minStandaloneTypeSet) { + group.isStandalone = true; + group.assignedClusterId = group.standaloneClusterId; + } else { + group.isStandalone = false; + group.assignedClusterId = findMergeTarget(group, anchorIndex, config); + } + + const clusterId = group.assignedClusterId; + const kind: ClusterKind = clusterId.startsWith("cluster:other:") + ? "other" + : "type-set"; + const node = this.#ensureNode(clusterId, kind); + node.addGroupMass(group); + dirtyIds.add(clusterId); + needsHierarchyRebuild = true; + } else { + const node = this.#nodes.get(group.assignedClusterId); + if (node) { + node.incrementGroupMass(group, delta); + dirtyIds.add(node.id); + } + } + } + + this.#propagateCountsToRoot(dirtyIds); + + for (const [, node] of this.#nodes) { + if (node.kind !== "root" && node.count === 0) { + this.#unregister(node); + needsHierarchyRebuild = true; + } + } + + this.#relabelDirty(dirtyIds, types); + + if (needsHierarchyRebuild) { + this.#clearFamilyNodes(); + this.#buildDisplayHierarchy(types, config); + } + + this.#stableLayout(); + } + + /** + * Synchronously subdivides a leaf cluster via community detection. + * + * Returns `false` without mutating the tree when `node.count` is at or + * below `config.entityRevealMax`, the node already has children, or + * community detection yields fewer than two groups. On success, + * registers the new children and lays them out inside `node`. + */ + ensureSubclusters( + node: ClusterNode, + typeSets: TypeSetStore, + links: LinkStore, + config: VizConfig, + ): boolean { + if (node.count <= config.entityRevealMax) { + return false; + } + if (node.children.length > 0) { + return false; + } + const entityIdxs = this.#collectEntityIdxs(node, typeSets); + const childNodes = subclusterByLinks(node, entityIdxs, links, config); + if (childNodes.length < 2) { + return false; + } + + for (const child of childNodes) { + node.addChild(child); + this.#register(child); + } + + this.#layoutChildrenInParent(node); + if (config.debug) { + // eslint-disable-next-line no-console + console.debug( + `[graph-worker][cluster-tree] subdivided ${node.id} (${node.count}) ` + + `into ${childNodes.length} groups:`, + childNodes.map((ch) => `${ch.label.text} (${ch.count})`).join(", "), + ); + } + return true; + } + + needsEmbeddingSubdivision(node: ClusterNode, config: VizConfig): boolean { + if (node.count <= config.entityRevealMax) { + return false; + } + // Entity-bucket children are placeholders; still needs real subdivision. + const hasRealChildren = + node.children.length >= 2 && + node.children.some((child) => child.kind !== "entity-bucket"); + if (hasRealChildren) { + return false; + } + return !this.#subdivisionRequested.has(node.id); + } + + markSubdivisionRequested(id: ClusterId): void { + this.#subdivisionRequested.add(id); + } + + applyEmbeddingResult( + id: ClusterId, + childAssignments: readonly { + readonly childId: ClusterId; + readonly count: number; + readonly memberIdxs: Int32Array; + }[], + ): void { + const node = this.#nodes.get(id); + if (!node) { + return; + } + + // When embedding subdivision returns no assignments, leave existing + // entity-bucket children in place. The subdivision request stays + // consumed (see markSubdivisionRequested), so the worker will not + // retry until the cluster is rebuilt or the tree is reset; callers + // must treat empty results as a terminal fallback to coarse buckets. + if (childAssignments.length === 0) { + return; + } + + node.clearChildren(); + + for (const { childId, count, memberIdxs } of childAssignments) { + const members = new Column( + Int32Array, + memberIdxs.length, + ); + // memberIdxs originate from entity index columns; branding matches + // EntityIndex. + for (const idx of memberIdxs) { + members.push(idx as EntityIndex); + } + const child = new ClusterNode(childId, "embedding", { + source: "direct", + members, + }); + child.count = count; + child.label = new ClusterLabel( + `Similar group ${node.children.length + 1}`, + ); + + node.addChild(child); + this.#register(child); + } + + this.#layoutChildrenInParent(node); + } + + /** + * Sets a cluster's display label. Embedding and community nodes keep + * this text across `#computeLabels` / `#relabelDirty` passes. + */ + setLabelText(id: ClusterId, text: string): void { + const node = this.#nodes.get(id); + if (node) { + node.label = new ClusterLabel(text); + } + } + + #collectEntityIdxs( + node: ClusterNode, + typeSets: TypeSetStore, + ): Column { + if (node.membership.source === "direct") { + return node.membership.members; + } + + let total = 0; + for (const key of node.membership.keys) { + const group = typeSets.get(key); + if (group) { + total += group.entities.length; + } + } + + const result = new Column(Int32Array, total); + for (const key of node.membership.keys) { + const group = typeSets.get(key); + if (group) { + for (const idx of group.entities) { + result.push(idx); + } + } + } + return result; + } + + #register(node: ClusterNode): void { + this.#nodes.set(node.id, node); + } + + #unregister(node: ClusterNode): void { + node.parent?.removeChild(node); + this.#nodes.delete(node.id); + } + + #clearAll(): void { + this.#root.clearChildren(); + this.#nodes.clear(); + this.#subdivisionRequested.clear(); + this.#register(this.#root); + } + + #clearFamilyNodes(): void { + for (const [, node] of this.#nodes) { + if (node.kind === "family") { + node.clearChildren(); + this.#unregister(node); + } + } + } + + #ensureNode(id: ClusterId, kind: ClusterKind): ClusterNode { + let node = this.#nodes.get(id); + if (!node) { + node = new ClusterNode(id, kind, { source: "groups", keys: [] }); + this.#register(node); + } + return node; + } + + #classifyAndMerge(typeSets: TypeSetStore, config: VizConfig): void { + const anchors: TypeSetGroup[] = []; + const small: TypeSetGroup[] = []; + + for (const group of typeSets) { + if (group.count === 0) { + continue; + } + if (group.count >= config.minStandaloneTypeSet) { + anchors.push(group); + } else { + small.push(group); + } + } + + if (anchors.length === 0 && small.length > 0) { + const promoted = [...small] + .sort( + (lhs, rhs) => rhs.count - lhs.count || lhs.key.localeCompare(rhs.key), + ) + .slice(0, Math.min(8, small.length)); + for (const group of promoted) { + anchors.push(group); + } + } + + const anchorIndex = new Map(); + for (const anchor of anchors) { + for (const typeIdx of anchor.closure.members()) { + let list = anchorIndex.get(typeIdx); + if (!list) { + list = []; + anchorIndex.set(typeIdx, list); + } + list.push(anchor); + } + } + + for (const anchor of anchors) { + anchor.assignedClusterId = anchor.standaloneClusterId; + anchor.isStandalone = true; + } + + for (const group of small) { + if (anchors.includes(group)) { + group.assignedClusterId = group.standaloneClusterId; + group.isStandalone = true; + continue; + } + group.assignedClusterId = findMergeTarget(group, anchorIndex, config); + group.isStandalone = false; + } + } + + #materializeClusters(typeSets: TypeSetStore): void { + const groupsByCluster = new Map(); + for (const group of typeSets) { + if (group.count === 0) { + continue; + } + let list = groupsByCluster.get(group.assignedClusterId); + if (!list) { + list = []; + groupsByCluster.set(group.assignedClusterId, list); + } + list.push(group); + } + + for (const [clusterId, groups] of groupsByCluster) { + const kind: ClusterKind = clusterId.startsWith("cluster:other:") + ? "other" + : "type-set"; + const node = new ClusterNode(clusterId, kind, { + source: "groups", + keys: [], + }); + + for (const group of groups) { + node.addGroupMass(group); + } + + this.#root.addChild(node); + this.#root.count += node.count; + this.#register(node); + } + } + + #computeLabels(types: TypeRegistry): void { + const atomic = [...this.#nodes.values()].filter( + (node) => node.kind === "type-set" || node.kind === "other", + ); + + const directDf = documentFrequency(atomic, "direct"); + const closureDf = documentFrequency(atomic, "closure"); + + for (const node of atomic) { + node.label = distinctiveLabel( + node, + directDf, + closureDf, + atomic.length, + types, + ); + } + } + + #relabelDirty(dirtyIds: Set, types: TypeRegistry): void { + const atomic = [...this.#nodes.values()].filter( + (node) => node.kind === "type-set" || node.kind === "other", + ); + + const directDf = documentFrequency(atomic, "direct"); + const closureDf = documentFrequency(atomic, "closure"); + + for (const id of dirtyIds) { + const node = this.#nodes.get(id); + if (!node || (node.kind !== "type-set" && node.kind !== "other")) { + continue; + } + node.label = distinctiveLabel( + node, + directDf, + closureDf, + atomic.length, + types, + ); + } + } + + #makeRollupNode( + id: ClusterId, + kind: ClusterKind, + sourceNodes: readonly ClusterNode[], + label?: ClusterLabel, + ): ClusterNode { + const node = new ClusterNode(id, kind, { source: "groups", keys: [] }); + for (const source of sourceNodes) { + node.count += source.count; + node.mass.absorb(source.mass); + } + if (label) { + node.label = label; + } + return node; + } + + #bucketByPrimaryRoot( + atomicNodes: readonly ClusterNode[], + types: TypeRegistry, + ): Map { + const buckets = new Map(); + + for (const node of atomicNodes) { + const primaryType = node.label.primaryType; + const rootIdx = + primaryType !== null ? types.get(primaryType)?.rootIds[0] : undefined; + + let list = buckets.get(rootIdx); + if (!list) { + list = []; + buckets.set(rootIdx, list); + } + list.push(node); + } + + return buckets; + } + + #buildBoundedRollupSubtree( + parent: ClusterNode, + children: ClusterNode[], + maxChildren: number, + ): void { + if (children.length <= maxChildren) { + for (const child of stableSortNodes(children)) { + parent.addChild(child); + } + return; + } + + const sorted = stableSortNodes(children); + const direct = sorted.slice(0, maxChildren - 1); + const overflow = sorted.slice(maxChildren - 1); + + for (const child of direct) { + parent.addChild(child); + } + + if (overflow.length > 0) { + const other = this.#makeRollupNode( + ClusterId(`cluster:family:overflow:${parent.id}`), + "family", + overflow, + new ClusterLabel("More"), + ); + parent.addChild(other); + this.#register(other); + + if (overflow.length > maxChildren) { + this.#buildBoundedRollupSubtree(other, overflow, maxChildren); + } else { + for (const child of overflow) { + other.addChild(child); + } + } + } + } + + #buildDisplayHierarchy(types: TypeRegistry, config: VizConfig): void { + this.#root.clearChildren(); + + const atomicNodes = [...this.#nodes.values()].filter( + (node) => node.kind === "type-set" || node.kind === "other", + ); + + const buckets = this.#bucketByPrimaryRoot(atomicNodes, types); + const topLevel: ClusterNode[] = []; + + for (const [rootTypeIdx, bucketNodes] of buckets) { + if (bucketNodes.length === 1) { + // bucketNodes.length === 1 in this branch. + topLevel.push(bucketNodes[0]!); + continue; + } + + const rootName = + rootTypeIdx !== undefined ? (types.get(rootTypeIdx)?.title ?? "") : ""; + const rootTitle = rootName.length > 0 ? rootName : "Other"; + const family = this.#makeRollupNode( + ClusterId(`cluster:family:${rootTypeIdx ?? "unknown"}`), + "family", + bucketNodes, + new ClusterLabel(rootTitle, rootTypeIdx ?? null, 1, false), + ); + this.#register(family); + topLevel.push(family); + + this.#buildBoundedRollupSubtree( + family, + bucketNodes, + config.maxChildrenPerParent, + ); + } + + if (topLevel.length <= config.maxChildrenPerParent) { + for (const child of stableSortNodes(topLevel)) { + this.#root.addChild(child); + } + } else { + this.#buildBoundedRollupSubtree( + this.#root, + topLevel, + config.maxChildrenPerParent, + ); + } + + this.#root.count = this.#root.children.reduce( + (sum, child) => sum + child.count, + 0, + ); + this.#root.label = new ClusterLabel("All entities", null, 1, true); + } + + /** + * Bottom-up circle-packing radii. Family rollups grow to enclose their + * children so small siblings never overlap inside a count-sized container. + * Subdivided type-sets are sized top-down in `#layoutChildrenInParent` + * and stay count-based here so drilling in doesn't reflow the top level. + */ + #assignRadii(): void { + for (const child of this.#root.children) { + this.#sizeNodeRadius(child); + } + } + + #sizeNodeRadius(node: ClusterNode): void { + const sizing = this.#sizing; + const countRadius = Math.max( + sizing.leafMinRadius, + Math.sqrt(node.count) * sizing.radiusPerSqrtCount, + ); + + if (node.kind === "family" && node.children.length > 0) { + for (const child of node.children) { + this.#sizeNodeRadius(child); + } + const childRadii = node.children.map((child) => child.circle.radius); + const fit = + enclosingRadius(childRadii, sizing.encloseGap) + sizing.enclosePadding; + node.circle.radius = Math.max(countRadius, fit); + } else { + node.circle.radius = countRadius; + } + } + + #layoutTopLevel(): void { + const children = this.#root.children; + if (children.length === 0) { + return; + } + + this.#assignRadii(); + + for (let idx = 0; idx < children.length; idx++) { + // idx iterates 0..children.length-1. + const child = children[idx]!; + child.circle.radius = Math.max( + this.#sizing.topLevelMinRadius, + child.circle.radius, + ); + } + } + + #stableLayout(): void { + const children = this.#root.children; + if (children.length === 0) { + return; + } + + this.#assignRadii(); + + for (const child of children) { + child.circle.radius = Math.max( + this.#sizing.topLevelMinRadius, + child.circle.radius, + ); + } + } + + #layoutChildrenInParent(parent: ClusterNode): void { + const children = parent.children; + if (children.length === 0) { + return; + } + + const totalCount = children.reduce((sum, child) => sum + child.count, 0); + + for (const child of children) { + // Scale child radius by sqrt(count share) of parent, floored at the + // minimum leaf radius; 0.6 leaves margin for force-layout gaps + // inside the parent. + child.circle.radius = Math.max( + this.#sizing.leafMinRadius, + parent.circle.radius * Math.sqrt(child.count / totalCount) * 0.6, + ); + } + } + + #propagateCountsToRoot(dirtyIds: Set): void { + const visited = new Set(); + + for (const id of dirtyIds) { + let current = this.#nodes.get(id); + while (current?.parent && !visited.has(current.parent.id)) { + visited.add(current.parent.id); + const parentNode = current.parent; + parentNode.count = parentNode.children.reduce( + (sum, child) => sum + child.count, + 0, + ); + current = parentNode; + } + } + } + + #smallGroupsSharingTypes( + promoted: TypeSetGroup, + typeSets: TypeSetStore, + ): TypeSetGroup[] { + const result: TypeSetGroup[] = []; + for (const group of typeSets) { + if ( + group.isStandalone || + group.count === 0 || + group.key === promoted.key + ) { + continue; + } + if (group.closure.jaccard(promoted.closure) > 0) { + result.push(group); + } + } + return result; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/community.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/community.bench.ts new file mode 100644 index 00000000000..0e5130e365c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/community.bench.ts @@ -0,0 +1,92 @@ +/** + * Community sub-clustering hot path (runs when a large type-set cluster is + * opened in hierarchical mode). Splits the pipeline into its three stages so the + * cost of the CSR build (array-of-arrays + `{neighbor, weight}` object per edge), + * the BFS components pass, and the label-propagation inner loop (a fresh `Map` + * per node per iteration, up to 20 iterations) can each be attributed. + * + * Run: `cd apps/hash-frontend && ../../node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer/worker/hierarchy/community.bench.ts` + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { buildCommunityInputs } from "../bench-fixtures"; +import { buildInducedCsr, connectedComponents } from "../csr-graph"; +import { boundedLabelPropagation } from "./community"; + +import type { GraphShape } from "../bench-fixtures"; + +interface Case { + readonly label: string; + readonly shape: GraphShape; +} + +const CASES: readonly Case[] = [ + { + label: "small (2k nodes / 6k links)", + shape: { + nodeCount: 2_000, + linkCount: 6_000, + typeCount: 8, + hubCount: 30, + rootFraction: 1, + seed: 11, + }, + }, + { + label: "medium (8k nodes / 24k links)", + shape: { + nodeCount: 8_000, + linkCount: 24_000, + typeCount: 8, + hubCount: 60, + rootFraction: 1, + seed: 12, + }, + }, + { + label: "large (20k nodes / 60k links)", + shape: { + nodeCount: 20_000, + linkCount: 60_000, + typeCount: 8, + hubCount: 120, + rootFraction: 1, + seed: 13, + }, + }, +]; + +for (const { label, shape } of CASES) { + // Inputs are built once and only read by the pipeline, so they can be shared + // across iterations. buildInducedCsr allocates fresh output every call. + const { entityIdxs, links } = buildCommunityInputs(shape); + const csr = buildInducedCsr(entityIdxs, links); + const components = connectedComponents(csr); + const largestComponent = components.reduce( + (best, component) => (component.length > best.length ? component : best), + components[0] ?? [], + ); + + describe(`community detection: ${label}`, () => { + bench("buildInducedCsr", () => { + buildInducedCsr(entityIdxs, links); + }); + + bench("connectedComponents", () => { + connectedComponents(csr); + }); + + bench("boundedLabelPropagation (largest component)", () => { + boundedLabelPropagation(csr, largestComponent); + }); + + bench("full: CSR + components + label propagation", () => { + const freshCsr = buildInducedCsr(entityIdxs, links); + for (const component of connectedComponents(freshCsr)) { + boundedLabelPropagation(freshCsr, component); + } + }); + }); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/community.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/community.ts new file mode 100644 index 00000000000..503fbdb0a34 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/community.ts @@ -0,0 +1,462 @@ +/** + * Community detection for sub-clustering large type-set clusters. + * + * Runs lazily when a cluster is about to open and is too large to show + * individual entities. Connected components are extracted first; large + * components are further split by bounded label propagation. + */ + +import { ClusterId } from "../../ids"; +import { murmur3String } from "../../math/hash"; +import { deterministicShuffle } from "../../math/random"; +import { Column } from "../collections/column"; +import { + type CsrGraph, + buildInducedCsr, + connectedComponents, +} from "../csr-graph"; +import { ClusterLabel, ClusterNode } from "./cluster-tree"; + +import type { VizConfig } from "../../config"; +import type { EntityIndex } from "../../ids"; +import type { LinkStore } from "../entity-graph/store/link"; + +/** + * Synchronous label-propagation community detection on a single connected + * component of `graph`. + * + * Returns per-node community labels as an `Int32Array` indexed by the + * same local node ids as `graph` (entries outside `component` are left + * at their zero-initialized default). Stops early once fewer than 0.5% + * of the component's nodes change label in a pass, or after 20 + * iterations. A size-penalty exponent (`alpha` = 0.35) discourages + * joining already-large communities, and a small stability bias (0.01) + * favors keeping a node's current label on ties, together limiting + * runaway mega-communities. + */ +export function boundedLabelPropagation( + graph: CsrGraph, + component: number[], +): Int32Array { + const nodeCount = graph.nodeIds.length; + const labels = new Int32Array(nodeCount); + const sizes = new Int32Array(nodeCount); + + for (const localIdx of component) { + labels[localIdx] = localIdx; + sizes[localIdx] = 1; + } + + // Extra passes rarely change labels after convergence; raising this + // slows large components linearly. + const maxIterations = 20; + // Penalizes joining large communities; lower values merge more + // aggressively. + const alpha = 0.35; + // Breaks ties toward the current label to reduce oscillation. + const stabilityBias = 0.01; + + for (let iteration = 0; iteration < maxIterations; iteration++) { + let changed = 0; + const order = deterministicShuffle(component, iteration); + + // node is a local CSR index in this component; labels/sizes were + // initialized for every component member above, and graph.offsets + // bounds valid neighbor/weight indices for any node in the graph. + for (const node of order) { + const current = labels[node]!; + const scores = new Map(); + + for ( + let edge = graph.offsets[node]!; + edge < graph.offsets[node + 1]!; + edge++ + ) { + const neighbor = graph.neighbors[edge]!; + const label = labels[neighbor]!; + const weight = graph.weights[edge]!; + scores.set(label, (scores.get(label) ?? 0) + weight); + } + + scores.set(current, (scores.get(current) ?? 0) + stabilityBias); + + let bestLabel = current; + let bestScore = -Infinity; + + for (const [label, rawScore] of scores) { + const sizePenalty = Math.max(1, sizes[label]!) ** alpha; + const score = rawScore / sizePenalty; + if (score > bestScore || (score === bestScore && label < bestLabel)) { + bestScore = score; + bestLabel = label; + } + } + + if (bestLabel !== current) { + sizes[current]!--; + sizes[bestLabel]!++; + labels[node] = bestLabel; + changed++; + } + } + + // Stop when fewer than 0.5% of component nodes relabel in one pass + // (empirical convergence threshold for link graphs). + if (changed / component.length < 0.005) { + break; + } + } + + return labels; +} + +function labelsToCommunities( + labels: Int32Array, + component: number[], +): number[][] { + const communities = new Map(); + + for (const localIdx of component) { + const label = labels[localIdx]!; + let list = communities.get(label); + if (!list) { + list = []; + communities.set(label, list); + } + list.push(localIdx); + } + + return [...communities.values()]; +} + +function normalizeCommunitySizes( + communities: number[][], + graph: CsrGraph, + config: VizConfig, +): EntityIndex[][] { + const result: EntityIndex[][] = []; + const tiny: EntityIndex[] = []; + + for (const community of communities) { + const entityIdxs = community.map((local) => graph.nodeIds.get(local)); + + if (entityIdxs.length < config.communityMinSize) { + for (const idx of entityIdxs) { + tiny.push(idx); + } + } else if (entityIdxs.length > config.communityMaxSize) { + for ( + let start = 0; + start < entityIdxs.length; + start += config.communityMaxSize + ) { + result.push(entityIdxs.slice(start, start + config.communityMaxSize)); + } + } else { + result.push(entityIdxs); + } + } + + if (tiny.length > 0) { + result.push(tiny); + } + + return result; +} + +function topDegreeEntity( + members: EntityIndex[], + links: LinkStore, +): EntityIndex | undefined { + let bestIdx: EntityIndex | undefined; + let bestDegree = 0; + + for (const entityIdx of members) { + const degree = links.degreeOf(entityIdx); + + if (degree > bestDegree) { + bestDegree = degree; + bestIdx = entityIdx; + } + } + + return bestIdx; +} + +function collectLinkFeatures( + members: EntityIndex[], + links: LinkStore, +): Map { + const features = new Map(); + const memberSet = new Set(members); + + for (const entityIdx of members) { + const endpoints = links.linksFor(entityIdx); + for (const endpoint of endpoints) { + const isInternal = memberSet.has(endpoint.otherId); + const prefix = isInternal ? "int" : "ext"; + const key = `${prefix}:${endpoint.direction}:${endpoint.typeSetId}`; + features.set(key, (features.get(key) ?? 0) + 1); + } + } + + return features; +} + +function featureKeyToLabel(key: string): string { + const parts = key.split(":"); + const scope = parts[0] === "int" ? "Internal" : "External"; + const direction = parts[1] === "out" ? "outgoing" : "incoming"; + return `${scope} ${direction} links`; +} + +/** Label communities using overrepresented link features (TF-IDF across siblings). */ +function labelAllCommunities( + communityMembers: EntityIndex[][], + children: ClusterNode[], + links: LinkStore, +): void { + if (communityMembers.length === 0) { + return; + } + + const allFeatures: Map[] = communityMembers.map((members) => + collectLinkFeatures(members, links), + ); + + const df = new Map(); + for (const features of allFeatures) { + for (const key of features.keys()) { + df.set(key, (df.get(key) ?? 0) + 1); + } + } + + const totalCommunities = communityMembers.length; + + for (let idx = 0; idx < children.length; idx++) { + const features = allFeatures[idx]!; + const memberCount = communityMembers[idx]!.length; + const child = children[idx]!; + + let bestKey: string | undefined; + let bestScore = -Infinity; + let bestCoverage = 0; + + for (const [featureKey, count] of features) { + const coverage = count / memberCount; + // Require the link feature in at least a quarter of members before + // naming a community by it. + if (coverage < 0.25) { + continue; + } + + const docFreq = df.get(featureKey) ?? 1; + const idf = Math.log((totalCommunities + 1) / (docFreq + 1)); + const score = coverage * idf; + + if (score > bestScore) { + bestScore = score; + bestKey = featureKey; + bestCoverage = coverage; + } + } + + if (bestKey) { + child.label = new ClusterLabel( + featureKeyToLabel(bestKey), + null, + bestCoverage, + bestCoverage < 0.5, + ); + } else { + const hub = topDegreeEntity(communityMembers[idx]!, links); + child.label = hub + ? new ClusterLabel(`Around entity ${hub}`) + : new ClusterLabel(`Community ${idx + 1}`); + } + } +} + +function linkSignatureKey( + entityIdx: EntityIndex, + links: LinkStore, + maxBuckets: number, +): string { + const degree = links.degreeOf(entityIdx); + if (degree === 0) { + return "isolated"; + } + + const features = new Set(); + for (const endpoint of links.linksFor(entityIdx)) { + features.add(`${endpoint.direction}:${endpoint.typeSetId}`); + } + + const sorted = [...features].sort(); + const key = sorted.join("|"); + + // eslint-disable-next-line no-bitwise + return `sig:${(murmur3String(key) >>> 0) % maxBuckets}`; +} + +function columnFromIndices( + indices: ArrayLike, +): Column { + const col = new Column(Int32Array, indices.length); + for (let idx = 0; idx < indices.length; idx++) { + col.push(indices[idx]!); + } + return col; +} + +function coarseLinkSignatureBuckets( + cluster: ClusterNode, + entityIdxs: Column, + links: LinkStore, + config: VizConfig, +): ClusterNode[] { + const buckets = new Map(); + + for (const entityIdx of entityIdxs) { + const key = linkSignatureKey(entityIdx, links, config.maxChildrenPerParent); + + let bucket = buckets.get(key); + if (!bucket) { + bucket = []; + buckets.set(key, bucket); + } + bucket.push(entityIdx); + } + + const normalized = [...buckets.values()]; + + const children: ClusterNode[] = normalized.map((memberIdxs, idx) => { + const members = columnFromIndices(memberIdxs); + const child = new ClusterNode( + ClusterId(`${cluster.id}:bucket:${idx}`), + "community", + { source: "direct", members }, + ); + child.count = memberIdxs.length; + child.label = new ClusterLabel(`Group ${idx + 1}`); + return child; + }); + + labelAllCommunities(normalized, children, links); + + return children; +} + +/** + * Last-resort partitioning when community detection can't produce + * meaningful groups (e.g. zero internal edges). Splits entities into + * roughly equal entity-bucket chunks sized for embedding k-means. + * + * Buckets are placeholders until embedding subdivision succeeds; if + * embeddings never arrive, the buckets remain the visible partition. + */ +function deterministicPartition( + cluster: ClusterNode, + entityIdxs: Column, + config: VizConfig, +): ClusterNode[] { + const targetSize = Math.floor( + config.entityRevealMax * config.embeddingTargetLeafFillRatio, + ); + const kk = Math.max( + 2, + Math.min(config.embeddingMaxK, Math.ceil(entityIdxs.length / targetSize)), + ); + + const children: ClusterNode[] = []; + for (let idx = 0; idx < kk; idx++) { + const start = Math.floor((idx * entityIdxs.length) / kk); + const end = Math.floor(((idx + 1) * entityIdxs.length) / kk); + const members = entityIdxs.slice(start, end); + const child = new ClusterNode( + ClusterId(`${cluster.id}:bucket:${idx}`), + "entity-bucket", + { source: "direct", members }, + ); + child.count = end - start; + child.label = new ClusterLabel(`Group ${idx + 1}`); + children.push(child); + } + return children; +} + +/** + * Sub-cluster a single cluster into child nodes. + * + * Returns an empty array if the cluster is small enough to show entities + * directly, otherwise >= 2 children. When `entityIdxs.length` exceeds + * `config.communityWorkerNodeCap`, skips full CSR community detection + * and buckets members by coarse link signature instead. Otherwise runs + * community detection and falls back to {@link deterministicPartition} + * when there are no internal edges or fewer than two normalized + * communities. + */ +export function subclusterByLinks( + cluster: ClusterNode, + entityIdxs: Column, + links: LinkStore, + config: VizConfig, +): ClusterNode[] { + if (entityIdxs.length <= config.entityRevealMax) { + return []; + } + + // Above the worker node cap, skip O(E) CSR community detection; bucket + // by link signature instead (faster, coarser groups). + if (entityIdxs.length > config.communityWorkerNodeCap) { + return coarseLinkSignatureBuckets(cluster, entityIdxs, links, config); + } + + const csr = buildInducedCsr(entityIdxs, links); + + // Isolated or externally-only subgraph: label propagation has no + // signal, so partition deterministically. + if (csr.neighbors.length === 0) { + return deterministicPartition(cluster, entityIdxs, config); + } + + const components = connectedComponents(csr); + const rawCommunities: number[][] = []; + + for (const component of components) { + if (component.length <= config.communityMaxSize) { + rawCommunities.push(component); + continue; + } + + const labels = boundedLabelPropagation(csr, component); + const split = labelsToCommunities(labels, component); + for (const community of split) { + rawCommunities.push(community); + } + } + + const normalized = normalizeCommunitySizes(rawCommunities, csr, config); + + // A single mega-community after normalization still hides structure; + // fall back to equal chunks for drill-in. + if (normalized.length < 2) { + return deterministicPartition(cluster, entityIdxs, config); + } + + const children: ClusterNode[] = normalized.map((memberIdxs, idx) => { + const members = columnFromIndices(memberIdxs); + const child = new ClusterNode( + ClusterId(`${cluster.id}:community:${idx}`), + "community", + { source: "direct", members }, + ); + child.count = memberIdxs.length; + child.label = new ClusterLabel(`Community ${idx + 1}`); + return child; + }); + + labelAllCommunities(normalized, children, links); + + return children; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/distinctive-cluster-label.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/distinctive-cluster-label.test.ts new file mode 100644 index 00000000000..8fe9c7914c7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/distinctive-cluster-label.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; + +import { ClusterId, EntityIndex } from "../../ids"; +import { nameClustersByDistinctiveFeatures } from "./distinctive-cluster-label"; + +import type { + ClusterMembers, + FeatureDescriptor, + FeatureSource, + NumericDimension, + NumericReading, +} from "./distinctive-cluster-label"; + +interface MemberSpec { + readonly keys: readonly string[]; + readonly numerics: readonly NumericReading[]; +} + +/** In-memory {@link FeatureSource} with explicit feature registration for unit tests. */ +class FeatureModel { + readonly #members: MemberSpec[] = []; + readonly #descriptors = new Map(); + readonly #numericDimensions = new Map(); + + /** An exact `Title = "value"` feature key (auto-registers its descriptor). */ + exact(baseUrl: string, title: string, value: string): string { + const key = `p\u0000${baseUrl}\u0000${value}`; + this.#descriptors.set(key, { + group: `prop\u0000${baseUrl}`, + text: `${title} = "${value}"`, + sortKey: title, + }); + return key; + } + + /** An outgoing link/target-type feature key (auto-registers its descriptor). */ + link(targetTitle: string): string { + const key = `lt\u0000out\u0000${targetTitle}`; + this.#descriptors.set(key, { + group: key, + text: `→ ${targetTitle}`, + sortKey: `\uFFFF${targetTitle}`, + }); + return key; + } + + /** A numeric axis dimension key (auto-registers its dimension descriptor). */ + numeric( + baseUrl: string, + title: string, + kind: "number" | "date" = "number", + ): string { + const dimension = `n\u0000${baseUrl}`; + this.#numericDimensions.set(dimension, { + group: `prop\u0000${baseUrl}`, + title, + kind, + sortKey: title, + }); + return dimension; + } + + addMember(spec: MemberSpec): EntityIndex { + this.#members.push(spec); + return EntityIndex(this.#members.length - 1); + } + + source(): FeatureSource { + const members = this.#members; + const descriptors = this.#descriptors; + const numericDimensions = this.#numericDimensions; + return { + keysOf: (member) => members[member]?.keys ?? [], + numericsOf: (member) => members[member]?.numerics ?? [], + describe: (key) => descriptors.get(key), + describeNumeric: (dimension) => numericDimensions.get(dimension), + }; + } +} + +function cluster(id: string, members: readonly EntityIndex[]): ClusterMembers { + return { childId: ClusterId(id), memberIdxs: Int32Array.from(members) }; +} + +describe("nameClustersByDistinctiveFeatures", () => { + it("names clusters by the exact value each shares but its sibling does not", () => { + const model = new FeatureModel(); + const foo = model.exact("dest", "Destination", "foo"); + const bar = model.exact("dest", "Destination", "bar"); + + const a = [0, 1, 2].map(() => + model.addMember({ keys: [foo], numerics: [] }), + ); + const b = [0, 1, 2].map(() => + model.addMember({ keys: [bar], numerics: [] }), + ); + + const labels = nameClustersByDistinctiveFeatures( + [cluster("A", a), cluster("B", b)], + model.source(), + ); + + expect(labels.get(ClusterId("A"))).toBe('Destination = "foo"'); + expect(labels.get(ClusterId("B"))).toBe('Destination = "bar"'); + }); + + it("leaves a cluster unnamed when its only feature is shared by every sibling", () => { + const model = new FeatureModel(); + const shared = model.exact("dest", "Destination", "foo"); + + const a = [0, 1, 2].map(() => + model.addMember({ keys: [shared], numerics: [] }), + ); + const b = [0, 1, 2].map(() => + model.addMember({ keys: [shared], numerics: [] }), + ); + + const labels = nameClustersByDistinctiveFeatures( + [cluster("A", a), cluster("B", b)], + model.source(), + ); + + expect(labels.size).toBe(0); + }); + + it("separates magnitude groups by numeric range when no exact value is common", () => { + const model = new FeatureModel(); + const quantity = model.numeric("qty", "Quantity"); + + const low = [10, 11, 12, 13, 14, 15, 16, 17].map((value) => + model.addMember({ keys: [], numerics: [{ dimension: quantity, value }] }), + ); + const high = [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007].map((value) => + model.addMember({ keys: [], numerics: [{ dimension: quantity, value }] }), + ); + + const labels = nameClustersByDistinctiveFeatures( + [cluster("Low", low), cluster("High", high)], + model.source(), + ); + + expect(labels.get(ClusterId("Low"))).toBe("Quantity 10–17"); + expect(labels.get(ClusterId("High"))).toMatch( + /^Quantity 1[,]?000–1[,]?007$/u, + ); + }); + + it("names clusters by what they link to (target type)", () => { + const model = new FeatureModel(); + const material = model.link("Material"); + const plant = model.link("Plant"); + + const a = [0, 1, 2].map(() => + model.addMember({ keys: [material], numerics: [] }), + ); + const b = [0, 1, 2].map(() => + model.addMember({ keys: [plant], numerics: [] }), + ); + + const labels = nameClustersByDistinctiveFeatures( + [cluster("A", a), cluster("B", b)], + model.source(), + ); + + expect(labels.get(ClusterId("A"))).toBe("→ Material"); + expect(labels.get(ClusterId("B"))).toBe("→ Plant"); + }); + + it("compounds a second feature, in deterministic order, to break a collision", () => { + const model = new FeatureModel(); + const us = model.exact("region", "Region", "US"); + const eu = model.exact("region", "Region", "EU"); + const asia = model.exact("region", "Region", "ASIA"); + const africa = model.exact("region", "Region", "AFRICA"); + const gold = model.exact("tier", "Tier", "gold"); + const silver = model.exact("tier", "Tier", "silver"); + + // A and B uniquely share Region = US (rare across the 5 siblings, so it is each one's top-scoring + // feature -> they collide); Tier is more widely shared, so it can only break the tie once + // compounded on top of Region. + const members = (keys: readonly string[]): EntityIndex[] => + [0, 1, 2].map(() => model.addMember({ keys, numerics: [] })); + const groupA = members([us, gold]); + const groupB = members([us, silver]); + const groupC = members([eu, gold, silver]); + const groupD = members([asia, gold]); + const groupE = members([africa, silver]); + + const labels = nameClustersByDistinctiveFeatures( + [ + cluster("A", groupA), + cluster("B", groupB), + cluster("C", groupC), + cluster("D", groupD), + cluster("E", groupE), + ], + model.source(), + ); + + // Region sorts before Tier, so the compound label is ordered deterministically + multi-line. + expect(labels.get(ClusterId("A"))).toBe('Region = "US"\nTier = "gold"'); + expect(labels.get(ClusterId("B"))).toBe('Region = "US"\nTier = "silver"'); + expect(labels.get(ClusterId("C"))).toBe('Region = "EU"'); + expect(labels.get(ClusterId("D"))).toBe('Region = "ASIA"'); + expect(labels.get(ClusterId("E"))).toBe('Region = "AFRICA"'); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/distinctive-cluster-label.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/distinctive-cluster-label.ts new file mode 100644 index 00000000000..50655dfb132 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/distinctive-cluster-label.ts @@ -0,0 +1,605 @@ +/** + * Name a set of sibling clusters by their distinctive shared features. + * + * Given clusters that are already grouped (by embeddings or a grouping fallback), + * this finds a meaningful label for each group from the features its members share + * but its siblings don't. Features come from three sources: + * + * - exact `(property = value)` pairs (e.g. `Destination = "foo"`), + * - numeric/date ranges (e.g. `Quantity 100-500`), bucketed per-subdivision from + * the live distribution of the siblings, and + * - link target types (e.g. `→ Material`). + * + * Scoring is coverage * IDF: a feature must be common within the cluster (coverage + * >= {@link MIN_COVERAGE}) and rare across siblings (high IDF). Features are grouped + * by a dedup key so a compound label never repeats a dimension. + * + * Collision breaking has two stages: first, extend colliding labels with the next + * most distinctive feature; then, when characteristic features are exhausted, separate + * on the feature where the groups most differ. Genuinely indistinguishable groups + * share a name. + * + * Cost is bounded: clusters above {@link MAX_SAMPLE_MEMBERS} are named from a + * deterministic, evenly-spread sample. Labels render in deterministic sort order. + */ +import type { ClusterId, EntityIndex } from "../../ids"; + +export interface ClusterMembers { + readonly childId: ClusterId; + readonly memberIdxs: Int32Array; +} + +export interface FeatureDescriptor { + /** Dedup group: at most one part per group appears in a compound label. */ + readonly group: string; + /** Rendered label line, e.g. `Destination = "foo"`, `Quantity 100-500`, `→ Material`. */ + readonly text: string; + /** Stable sort key so a multi-part label never reshuffles its lines. */ + readonly sortKey: string; +} + +export interface NumericReading { + /** Stable per-property axis key; range buckets are computed per dimension. */ + readonly dimension: string; + /** A plain number, or a date as epoch milliseconds. */ + readonly value: number; +} + +export interface NumericDimension { + /** Dedup group, shared with the property's exact features so a property yields one part. */ + readonly group: string; + readonly title: string; + readonly kind: "number" | "date"; + readonly sortKey: string; +} + +/** + * Supplies per-member features for naming. The namer treats every key as + * opaque except numeric readings, whose range bucketing it owns. + */ +export interface FeatureSource { + /** Opaque feature keys for a member; decode only via {@link FeatureSource.describe}. */ + keysOf(member: EntityIndex): Iterable; + /** Raw axis readings for a member; the namer may bucket these into range features. */ + numericsOf(member: EntityIndex): Iterable; + /** Describe a key returned by {@link keysOf}, or undefined to skip it. */ + describe(key: string): FeatureDescriptor | undefined; + /** Describe a numeric dimension, or undefined to skip it. */ + describeNumeric(dimension: string): NumericDimension | undefined; +} + +/** + * Minimum within-cluster coverage (default 0.6). Lower values admit + * noisier labels; higher values leave more clusters unnamed. + */ +const MIN_COVERAGE = 0.6; +/** Most parts joined into one compound label (collision breaking). */ +const MAX_LABEL_PARTS = 3; +/** + * A discriminative tie-break feature need only be reasonably common (the groups + * already share their dominant features, so the separator lives below MIN_COVERAGE). + */ +const DISCRIMINATOR_MIN_COVERAGE = 0.34; +/** Minimum coverage gap between this cluster and the colliding peers. */ +const DISCRIMINATOR_MIN_GAP = 0.2; +/** Discriminative passes to attempt once characteristic compounding is exhausted. */ +const MAX_DISCRIMINATOR_PASSES = 2; + +/** + * Cap the members scanned per cluster; bigger clusters name from an even + * sample of this many. Default 5000. Raising improves accuracy on skewed + * distributions at O(sample) cost per cluster. + */ +const MAX_SAMPLE_MEMBERS = 5000; +/** + * A numeric axis needs at least this many distinct values across the subdivision + * to be range-bucketed; below that, exact value features name it instead. + */ +const MIN_NUMERIC_DISTINCT = 8; + +interface NumericRange { + readonly describe: NumericDimension; + /** Strictly-increasing interior edges; bucket b spans [edges[b-1], edges[b]). */ + readonly edges: number[]; + /** Observed [min, max] per bucket (parallel to `edges.length + 1`). */ + readonly bounds: { min: number; max: number }[]; +} + +interface Candidate extends FeatureDescriptor { + readonly coverage: number; + readonly score: number; +} + +/** Deterministic, evenly-spread sample of at most `max` members. */ +function subsample(members: Int32Array, max: number): Int32Array { + if (members.length <= max) { + return members; + } + const sampled = new Int32Array(max); + for (let index = 0; index < max; index++) { + sampled[index] = members[Math.floor((index * members.length) / max)]!; + } + return sampled; +} + +/** Median of a non-empty ascending-sorted array. */ +function median(sorted: number[]): number { + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 + ? sorted[mid]! + : (sorted[mid - 1]! + sorted[mid]!) / 2; +} + +/** Bucket index (0..edges.length) of a value against interior edges. */ +function bucketOf(value: number, edges: number[]): number { + let bucket = 0; + while (bucket < edges.length && value >= edges[bucket]!) { + bucket++; + } + return bucket; +} + +/** Format a number compactly: integers plain, fractions to a few significant digits. */ +function formatNumber(value: number): string { + if (Number.isInteger(value)) { + return value.toLocaleString("en-US"); + } + const magnitude = Math.abs(value); + const fractionDigits = magnitude >= 100 ? 0 : magnitude >= 1 ? 2 : 4; + return value.toLocaleString("en-US", { + maximumFractionDigits: fractionDigits, + }); +} + +/** Format a range bound according to its axis kind (a number, or a `YYYY-MM-DD` date). */ +function formatBound(value: number, kind: "number" | "date"): string { + if (kind === "date") { + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? formatNumber(value) + : date.toISOString().slice(0, 10); + } + return formatNumber(value); +} + +/** The descriptor for a value falling in one bucket of a numeric range. */ +function rangeDescriptor( + range: NumericRange, + bucket: number, +): FeatureDescriptor { + const { bounds, describe } = range; + const { min, max } = bounds[bucket] ?? { min: NaN, max: NaN }; + let text: string; + if (!Number.isFinite(min) || !Number.isFinite(max)) { + text = describe.title; + } else if (min === max) { + text = `${describe.title} ${formatBound(min, describe.kind)}`; + } else { + text = `${describe.title} ${formatBound(min, describe.kind)}–${formatBound( + max, + describe.kind, + )}`; + } + return { + group: describe.group, + text, + sortKey: `${describe.sortKey}\u0000${bucket}`, + }; +} + +/** + * Per-axis range buckets for the whole subdivision. Bucket edges sit at the + * midpoints between the clusters' median values: a group split off by magnitude + * occupies a contiguous band, so a midpoint keeps that whole band in one bucket. + * Equal-frequency quantiles would slice the band in two and leave coverage below + * {@link MIN_COVERAGE}. + */ +function buildNumericRanges( + samples: readonly Int32Array[], + source: FeatureSource, +): Map { + // Collect per-cluster value lists so median-based bucket edges separate + // magnitude bands. + const valuesByAxis = new Map(); + for (let cluster = 0; cluster < samples.length; cluster++) { + // subsample stores EntityIndex values in an Int32Array. + for (const member of samples[cluster]!) { + for (const reading of source.numericsOf(member as EntityIndex)) { + let perCluster = valuesByAxis.get(reading.dimension); + if (!perCluster) { + perCluster = samples.map(() => []); + valuesByAxis.set(reading.dimension, perCluster); + } + perCluster[cluster]!.push(reading.value); + } + } + } + + const ranges = new Map(); + for (const [dimension, perCluster] of valuesByAxis) { + const all = perCluster.flat().sort((left, right) => left - right); + let distinct = all.length > 0 ? 1 : 0; + for (let index = 1; index < all.length; index++) { + if (all[index] !== all[index - 1]) { + distinct++; + } + } + if (distinct < MIN_NUMERIC_DISTINCT) { + continue; + } + + // One representative median per cluster the axis is characteristic of (covers a majority). + const medians: number[] = []; + for (let cluster = 0; cluster < perCluster.length; cluster++) { + const values = perCluster[cluster]!; + if (values.length / samples[cluster]!.length < MIN_COVERAGE) { + continue; + } + medians.push(median([...values].sort((left, right) => left - right))); + } + const distinctMedians = [...new Set(medians)].sort( + (left, right) => left - right, + ); + if (distinctMedians.length < 2) { + continue; + } + + const describe = source.describeNumeric(dimension); + if (!describe) { + continue; + } + + const edges: number[] = []; + for (let index = 1; index < distinctMedians.length; index++) { + edges.push((distinctMedians[index - 1]! + distinctMedians[index]!) / 2); + } + + const bounds = Array.from({ length: edges.length + 1 }, () => ({ + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + })); + for (const value of all) { + const bound = bounds[bucketOf(value, edges)]!; + bound.min = Math.min(bound.min, value); + bound.max = Math.max(bound.max, value); + } + + ranges.set(dimension, { describe, edges, bounds }); + } + return ranges; +} + +/** + * Coverage of every feature in one cluster (fraction of sampled members carrying it). + * Descriptors are registered as features are first seen. Each member counts a feature + * at most once. + */ +function clusterCoverage( + sample: Int32Array, + source: FeatureSource, + ranges: Map, + descriptors: Map, +): Map { + const counts = new Map(); + const size = sample.length; + if (size === 0) { + return counts; + } + + // sample stores EntityIndex values in an Int32Array. + for (const member of sample) { + const seen = new Set(); + for (const key of source.keysOf(member as EntityIndex)) { + if (seen.has(key)) { + continue; + } + let descriptor = descriptors.get(key); + if (!descriptor) { + descriptor = source.describe(key); + if (!descriptor) { + continue; + } + descriptors.set(key, descriptor); + } + seen.add(key); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + for (const reading of source.numericsOf(member as EntityIndex)) { + const range = ranges.get(reading.dimension); + if (!range) { + continue; + } + const bucket = bucketOf(reading.value, range.edges); + const key = `range\u0000${reading.dimension}\u0000${bucket}`; + if (seen.has(key)) { + continue; + } + if (!descriptors.has(key)) { + descriptors.set(key, rangeDescriptor(range, bucket)); + } + seen.add(key); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + + for (const [key, count] of counts) { + counts.set(key, count / size); + } + return counts; +} + +/** + * Rank a cluster's distinctive candidates, keeping the best-scoring feature per + * dedup group so a compound label never repeats a dimension. + * + * Within a group, ties break by higher coverage, then by sort key, so the + * chosen candidate is deterministic across runs. + */ +function rankCandidates( + coverage: Map, + documentFrequency: Map, + clusterCount: number, + descriptors: Map, +): Candidate[] { + const bestPerGroup = new Map(); + + for (const [key, fraction] of coverage) { + if (fraction < MIN_COVERAGE) { + continue; + } + const descriptor = descriptors.get(key); + if (!descriptor) { + continue; + } + const docFreq = documentFrequency.get(key) ?? 1; + const idf = Math.log((clusterCount + 1) / (docFreq + 1)); + const score = fraction * idf; + if (score <= 0) { + // Shared by every cluster (idf 0); carries no distinguishing signal. + continue; + } + + const candidate: Candidate = { + group: descriptor.group, + text: descriptor.text, + sortKey: descriptor.sortKey, + coverage: fraction, + score, + }; + const existing = bestPerGroup.get(descriptor.group); + if ( + !existing || + score > existing.score || + (score === existing.score && + (fraction > existing.coverage || + (fraction === existing.coverage && + descriptor.sortKey.localeCompare(existing.sortKey) < 0))) + ) { + bestPerGroup.set(descriptor.group, candidate); + } + } + + return [...bestPerGroup.values()].sort( + (left, right) => + right.score - left.score || + right.coverage - left.coverage || + left.sortKey.localeCompare(right.sortKey) || + left.text.localeCompare(right.text), + ); +} + +/** + * The feature most over-represented in `here` versus the colliding `peers`, + * excluding groups already used. Returns undefined when nothing separates + * them above {@link DISCRIMINATOR_MIN_GAP}. + */ +function bestDiscriminator( + here: Map, + peers: readonly number[], + coverages: readonly Map[], + used: ReadonlySet, + descriptors: Map, +): FeatureDescriptor | undefined { + const qualifying: { + part: FeatureDescriptor; + gap: number; + coverage: number; + }[] = []; + + for (const [key, coverage] of here) { + if (coverage < DISCRIMINATOR_MIN_COVERAGE) { + continue; + } + const descriptor = descriptors.get(key); + if (!descriptor || used.has(descriptor.group)) { + continue; + } + let peerMax = 0; + for (const peer of peers) { + const peerCoverage = coverages[peer]!.get(key) ?? 0; + if (peerCoverage > peerMax) { + peerMax = peerCoverage; + } + } + const gap = coverage - peerMax; + if (gap < DISCRIMINATOR_MIN_GAP) { + continue; + } + qualifying.push({ part: descriptor, gap, coverage }); + } + + qualifying.sort( + (left, right) => + right.gap - left.gap || + right.coverage - left.coverage || + left.part.sortKey.localeCompare(right.part.sortKey) || + left.part.text.localeCompare(right.part.text), + ); + return qualifying[0]?.part; +} + +/** + * Render parts to their display string: deterministic sort order, one per line. + * The stable ordering guarantees collision detection is exact. + */ +function renderLabel(parts: readonly FeatureDescriptor[]): string { + return [...parts] + .sort( + (left, right) => + left.sortKey.localeCompare(right.sortKey) || + left.text.localeCompare(right.text), + ) + .map((part) => part.text) + .join("\n"); +} + +/** + * Turn ranked candidates into labels, extending colliding clusters until they + * separate or the label-part budget is spent. + * + * Two phases run in order: first, colliding clusters are extended with + * their next most characteristic candidate; once that is exhausted, + * remaining collisions are separated by the feature where they most + * differ (see {@link bestDiscriminator}). + */ +function resolveLabels( + clusters: readonly ClusterMembers[], + candidates: readonly Candidate[][], + coverages: readonly Map[], + descriptors: Map, +): Map { + const chosen: FeatureDescriptor[][] = candidates.map((list) => + list.length > 0 ? [list[0]!] : [], + ); + const used: Set[] = chosen.map( + (parts) => new Set(parts.map((part) => part.group)), + ); + + const collisions = (): number[][] => { + const byLabel = new Map(); + for (let index = 0; index < clusters.length; index++) { + if (chosen[index]!.length === 0) { + continue; + } + const key = renderLabel(chosen[index]!); + const bucket = byLabel.get(key); + if (bucket) { + bucket.push(index); + } else { + byLabel.set(key, [index]); + } + } + return [...byLabel.values()].filter((bucket) => bucket.length > 1); + }; + + // Phase 1: try to separate collisions by adding each cluster's next + // most characteristic feature before falling back to discriminators. + for (let pass = 1; pass < MAX_LABEL_PARTS; pass++) { + const groups = collisions(); + if (groups.length === 0) { + break; + } + let extended = false; + for (const group of groups) { + for (const index of group) { + if (chosen[index]!.length >= MAX_LABEL_PARTS) { + continue; + } + const next = candidates[index]!.find( + (candidate) => !used[index]!.has(candidate.group), + ); + if (next) { + chosen[index]!.push(next); + used[index]!.add(next.group); + extended = true; + } + } + } + if (!extended) { + break; + } + } + + // Phase 2: clusters that still collide share every characteristic feature. + // Separate them on the feature where they most differ. + for (let pass = 0; pass < MAX_DISCRIMINATOR_PASSES; pass++) { + const groups = collisions(); + if (groups.length === 0) { + break; + } + let separated = false; + for (const group of groups) { + for (const index of group) { + if (chosen[index]!.length >= MAX_LABEL_PARTS) { + continue; + } + const peers = group.filter((other) => other !== index); + const part = bestDiscriminator( + coverages[index]!, + peers, + coverages, + used[index]!, + descriptors, + ); + if (part) { + chosen[index]!.push(part); + used[index]!.add(part.group); + separated = true; + } + } + } + if (!separated) { + break; + } + } + + const labels = new Map(); + for (let index = 0; index < clusters.length; index++) { + if (chosen[index]!.length === 0) { + continue; + } + const label = renderLabel(chosen[index]!); + if (label.length > 0) { + labels.set(clusters[index]!.childId, label); + } + } + return labels; +} + +/** + * Compute a distinctive label for each cluster. Clusters without a confident + * distinctive signature are omitted from the result and keep their placeholder. + */ +export function nameClustersByDistinctiveFeatures( + clusters: readonly ClusterMembers[], + source: FeatureSource, +): Map { + const clusterCount = clusters.length; + + const samples = clusters.map((cluster) => + subsample(cluster.memberIdxs, MAX_SAMPLE_MEMBERS), + ); + + const ranges = buildNumericRanges(samples, source); + + const descriptors = new Map(); + const coverages = samples.map((sample) => + clusterCoverage(sample, source, ranges, descriptors), + ); + + // IDF denominator: features common across siblings score lower. + const documentFrequency = new Map(); + for (const coverage of coverages) { + for (const [key, fraction] of coverage) { + if (fraction >= MIN_COVERAGE) { + documentFrequency.set(key, (documentFrequency.get(key) ?? 0) + 1); + } + } + } + + const candidates = coverages.map((coverage) => + rankCandidates(coverage, documentFrequency, clusterCount, descriptors), + ); + + return resolveLabels(clusters, candidates, coverages, descriptors); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/lod.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/lod.ts new file mode 100644 index 00000000000..d4ea7e26c7a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/hierarchy/lod.ts @@ -0,0 +1,245 @@ +/** + * Level-of-detail (LOD) decisions driven by viewport state. + * + * The visible cut is a set of cluster tree nodes to render. + * Each node in the cut has a mode: render as bubble, show children, + * or show individual entities. Hysteresis prevents flickering + * at threshold boundaries. + */ +import { Bbox, screenRadius } from "../../geometry"; + +import type { VizConfig } from "../../config"; +import type { ClusterId, LodMode } from "../../ids"; +import type { ClusterNode, ClusterTree } from "./cluster-tree"; + +export interface ViewportState { + readonly zoom: number; + readonly centerX: number; + readonly centerY: number; + readonly width: number; + readonly height: number; +} + +export interface LodItem { + readonly clusterId: ClusterId; + readonly mode: LodMode; +} + +function screenRadiusPx(cluster: ClusterNode, zoom: number): number { + return screenRadius(cluster.circle, zoom); +} + +function setsEqual(lhs: Set, rhs: Set): boolean { + if (lhs.size !== rhs.size) { + return false; + } + + for (const item of lhs) { + if (!rhs.has(item)) { + return false; + } + } + + return true; +} + +/** + * Tracks the previous LOD decisions for hysteresis. + * Open/close thresholds differ so a cluster that just opened + * requires a smaller screen radius to close than it took to open. + */ +export class LodState { + #visibleIds = new Set(); + #showingChildren = new Set(); + #showingEntities = new Set(); + + wasShowingChildren(clusterId: ClusterId): boolean { + return this.#showingChildren.has(clusterId); + } + + wasShowingEntities(clusterId: ClusterId): boolean { + return this.#showingEntities.has(clusterId); + } + + #partition(items: readonly LodItem[]): { + readonly visible: Set; + readonly children: Set; + readonly entities: Set; + } { + const visible = new Set(); + const children = new Set(); + const entities = new Set(); + + for (const item of items) { + visible.add(item.clusterId); + if (item.mode === "children") { + children.add(item.clusterId); + } else if (item.mode === "entities" || item.mode === "entities-pending") { + entities.add(item.clusterId); + } + } + + return { visible, children, entities }; + } + + /** Would applying this cut change the committed open-state? */ + wouldChange(items: readonly LodItem[]): boolean { + const { visible, children, entities } = this.#partition(items); + return ( + !setsEqual(visible, this.#visibleIds) || + !setsEqual(children, this.#showingChildren) || + !setsEqual(entities, this.#showingEntities) + ); + } + + /** + * Commit a new visible cut. Returns true if the cut changed. + * + * This is the sole mutation of hysteresis open-state; layout side + * effects must run in the same frame when `true` is returned so open + * clusters and their force simulations stay aligned. + */ + applyVisibleCut(items: readonly LodItem[]): boolean { + const { visible, children, entities } = this.#partition(items); + + const changed = + !setsEqual(visible, this.#visibleIds) || + !setsEqual(children, this.#showingChildren) || + !setsEqual(entities, this.#showingEntities); + + this.#visibleIds = visible; + this.#showingChildren = children; + this.#showingEntities = entities; + + return changed; + } +} + +/** + * Compute the visible cut: which clusters to render and in what mode. + * + * Walks the cluster tree top-down, using screen-space radius to decide + * whether to open each cluster. Largest screen radius is processed first; + * render budgets cap the total cluster and entity count. + */ +export function computeVisibleCut( + tree: ClusterTree, + rootId: ClusterId, + viewport: ViewportState, + lodState: LodState, + config: VizConfig, + trySubdivide?: (node: ClusterNode) => boolean, + /** Clusters forced open regardless of zoom, viewport, or budget. + * Ancestors open to children; the pinned leaf opens to entities. */ + pinnedOpen?: ReadonlySet, +): LodItem[] { + const root = tree.get(rootId); + if (!root) { + return []; + } + + const result: LodItem[] = []; + const viewBbox = Bbox.fromViewport( + viewport.centerX, + viewport.centerY, + viewport.width, + viewport.height, + viewport.zoom, + ); + + // Simple array sorted by screen radius (largest first). + // Cluster counts are bounded by maxRenderedClusters, so O(n²) splice is fine. + const queue: ClusterNode[] = []; + + for (const child of root.children) { + queue.push(child); + } + + queue.sort( + (lhs, rhs) => + screenRadiusPx(rhs, viewport.zoom) - screenRadiusPx(lhs, viewport.zoom), + ); + + let renderedClusters = 0; + let renderedEntities = 0; + + while (queue.length > 0) { + // while (queue.length > 0) guarantees a node exists. + const node = queue.shift()!; + + // Every cluster stays in the cut regardless of viewport position. Frustum + // culling is Deck.gl's job; removing a panned-off cluster from the cut made + // it vanish from the obstacle list, re-routing edges on every pan. + // + // Whether a cluster *opens* is viewport-gated (centerInView below, with + // hysteresis so it doesn't snap shut when panned partially off-screen). + + const rPx = screenRadiusPx(node, viewport.zoom); + let hasChildren = node.children.length > 0; + const viewMin = Math.min(viewport.width, viewport.height); + + const centerInView = viewBbox.containsPoint(node.circle.x, node.circle.y); + + // Hysteresis: open/close thresholds differ, as fraction of viewport min dimension. + const wasOpen = lodState.wasShowingChildren(node.id); + const openChildren = wasOpen + ? rPx >= config.closeChildrenFraction * viewMin + : centerInView && rPx >= config.openChildrenFraction * viewMin; + + const wasShowingEntities = lodState.wasShowingEntities(node.id); + const openEntities = wasShowingEntities + ? rPx >= config.closeEntitiesFraction * viewMin + : centerInView && rPx >= config.openEntitiesFraction * viewMin; + + // Pinned clusters (the selected node's leaf + ancestors) open regardless of + // zoom, viewport, or budget. They stay open until deselected. + const pinned = pinnedOpen?.has(node.id) ?? false; + + if ( + !hasChildren && + node.count <= config.entityRevealMax && + (pinned || + (openEntities && + renderedEntities + node.count <= config.maxRenderedEntities)) + ) { + result.push({ clusterId: node.id, mode: "entities" }); + renderedEntities += node.count; + continue; + } + + // Lazy subdivision: community detection runs only when the cluster + // would otherwise open with no children. + if (!hasChildren && (openChildren || pinned) && trySubdivide?.(node)) { + hasChildren = node.children.length > 0; + } + + if ( + hasChildren && + (pinned || + (openChildren && + renderedClusters + node.children.length <= + config.maxRenderedClusters)) + ) { + result.push({ clusterId: node.id, mode: "children" }); + + for (const child of node.children) { + const childRPx = screenRadiusPx(child, viewport.zoom); + // insertIdx <= queue.length after the ascending scan. + let insertIdx = 0; + while ( + insertIdx < queue.length && + screenRadiusPx(queue[insertIdx]!, viewport.zoom) > childRPx + ) { + insertIdx++; + } + queue.splice(insertIdx, 0, child); + } + continue; + } + + result.push({ clusterId: node.id, mode: "cluster" }); + renderedClusters++; + } + + return result; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/cluster-layout-config.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/cluster-layout-config.ts new file mode 100644 index 00000000000..708c819f130 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/cluster-layout-config.ts @@ -0,0 +1,37 @@ +/** + * Tuning for the WebCola cluster (bubble/macro) layout + * ({@link "./cluster-layout"}). + * + * Kept separate from the engine so the main thread (root `config.ts`, the dev + * harness) can import the defaults without pulling WebCola into the page + * bundle. + */ +export interface ClusterForceConfig { + /** Non-overlap padding at the root level, as a fraction of the mean radius. @defaultValue 0.35. */ + readonly rootPaddingMultiplier: number; + /** Non-overlap padding inside sub-clusters, as a fraction of the mean radius. @defaultValue 0.05. */ + readonly subPaddingMultiplier: number; + /** Root-level ideal link length as a multiple of the pair's combined radii. @defaultValue 1.7. */ + readonly rootSeparationMultiplier: number; + /** Sub-cluster ideal link length as a multiple of the pair's combined radii. @defaultValue 1.2. */ + readonly subSeparationMultiplier: number; + /** Safety cap on majorisation steps so a layout always settles. @defaultValue 2000. */ + readonly maxSteps: number; + /** WebCola alpha that kicks the descent into running after initialisation. @defaultValue 0.1. */ + readonly startAlpha: number; + /** Overlap-relaxation passes when fitting a confined sub-cluster to its circle. @defaultValue 16. */ + readonly confinePasses: number; + /** Child-to-port-anchor link length, as a fraction of the parent radius. @defaultValue 0.6. */ + readonly anchorLinkFraction: number; +} + +export const defaultClusterForceConfig: ClusterForceConfig = { + rootPaddingMultiplier: 0.35, + subPaddingMultiplier: 0.05, + rootSeparationMultiplier: 1.7, + subSeparationMultiplier: 1.2, + maxSteps: 2_000, + startAlpha: 0.1, + confinePasses: 16, + anchorLinkFraction: 0.6, +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/cluster-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/cluster-layout.ts new file mode 100644 index 00000000000..594c4d87cce --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/cluster-layout.ts @@ -0,0 +1,411 @@ +/** + * Cluster (macro) layout, powered by WebCola. + * + * WebCola solves the layout we actually want directly: stress majorisation + * embeds the link structure (connected clusters end up near each other, the + * "edges have meaning" principle that motivated a structural layout over circle + * packing), while gradient-projection non-overlap constraints guarantee the + * bubbles never intersect. There is no force seed and no anchor: the positions + * are the optimum of that objective, not a polish of some local minimum. A + * crossing-reduction pass ({@link "./untangle"}) then runs over the settled + * result to remove edge crossings WebCola's stress model does not target. + * + * The engine is driven manually (one majorisation step per `tick`, within a + * time budget) so settling streams to the GPU live and never blocks the + * worker. Positions are written to a SharedArrayBuffer, re-centred on the origin + * each step (the parent translates them to world coords). + * + * Spacing is size-aware: link lengths and non-overlap padding scale with the + * endpoints' radii, because top-level bubbles vary enormously in size (a 5k-node + * type next to a 20-node one) and a single ideal length would pack the big ones + * to touching. The root layout spreads generously; subcluster layouts stay snug + * so children fill their parent circle. + * + * Confinement: WebCola lays out unconstrained and we re-centre; the parent + * circle is sized by the cluster tree's packing, close to WebCola's non-overlap + * extent. Sub-cluster confinement is enforced post-WebCola via `#fitWithin`; + * rim ports as fixed WebCola boundary nodes remain future work (WebCola's + * `fixed` node locking is the integration point). + */ +import { Layout } from "webcola"; + +import { PositionBuffer } from "../buffers/position-buffer"; +import { defaultClusterForceConfig } from "./cluster-layout-config"; + +import type { Position } from "../../geometry"; +import type { ClusterForceConfig } from "./cluster-layout-config"; +import type { + ForceEdge, + ForceLayoutStatus, + ForceNode, + LayoutSimulation, + PortAnchor, +} from "./force-simulation"; +import type { InputNode, Link } from "webcola"; + +/** Drives WebCola one majorisation step at a time under a time budget. */ +class SteppableLayout extends Layout { + /** One stress-majorisation step. Returns true once converged. */ + runStep(): boolean { + return this.tick(); + } +} + +class ClusterLayout implements LayoutSimulation { + readonly #nodes: ForceNode[]; + /** Children (0..n-1) plus any fixed port anchors appended by setPortAnchors. */ + #colaNodes: InputNode[]; + /** The children-only cola nodes (rebuilt from on each setPortAnchors). */ + readonly #childColaNodes: InputNode[]; + /** The inter-sibling links (anchor links are appended on setPortAnchors). */ + readonly #childLinks: Link[]; + readonly #cola: SteppableLayout; + readonly #buffer: PositionBuffer; + readonly #confinementRadius: number | undefined; + readonly #tuning: ClusterForceConfig; + /** Scratch for the re-centred (and, if confined, fitted) local positions. */ + readonly #posX: Float64Array; + readonly #posY: Float64Array; + #status: ForceLayoutStatus; + #steps = 0; + + constructor( + nodes: ForceNode[], + edges: ForceEdge[], + confinementRadius: number | undefined, + tuning: ClusterForceConfig, + ) { + this.#nodes = nodes; + this.#buffer = new PositionBuffer(nodes.length); + this.#confinementRadius = confinementRadius; + this.#tuning = tuning; + this.#posX = new Float64Array(nodes.length); + this.#posY = new Float64Array(nodes.length); + const isRoot = confinementRadius === undefined; + + let meanRadius = 0; + for (const node of nodes) { + meanRadius += node.radius; + } + meanRadius = nodes.length > 0 ? meanRadius / nodes.length : 1; + + const pad = + (isRoot ? tuning.rootPaddingMultiplier : tuning.subPaddingMultiplier) * + meanRadius; + const sepMul = isRoot + ? tuning.rootSeparationMultiplier + : tuning.subSeparationMultiplier; + + const idToIndex = new Map(); + for (const [index, node] of nodes.entries()) { + idToIndex.set(node.id, index); + } + + // Pad each bubble's collision box so WebCola's non-overlap constraint + // matches our size-aware spacing. + this.#colaNodes = nodes.map((node) => ({ + x: node.x ?? 0, + y: node.y ?? 0, + width: (node.radius + pad) * 2, + height: (node.radius + pad) * 2, + })); + this.#childColaNodes = this.#colaNodes; + + // Resolve edges to index links with size-aware ideal lengths. We never + // mutate the input edges (d3's forceLink rewrote source/target, a footgun). + const links: Link[] = []; + for (const edge of edges) { + const sourceId = + typeof edge.source === "string" ? edge.source : edge.source.id; + const targetId = + typeof edge.target === "string" ? edge.target : edge.target.id; + const sourceIndex = idToIndex.get(sourceId); + const targetIndex = idToIndex.get(targetId); + if ( + sourceIndex !== undefined && + targetIndex !== undefined && + sourceIndex !== targetIndex + ) { + const ideal = + (nodes[sourceIndex]!.radius + nodes[targetIndex]!.radius) * sepMul; + links.push({ source: sourceIndex, target: targetIndex, length: ideal }); + } + } + this.#childLinks = links; + + this.#cola = new SteppableLayout(); + this.#cola + .nodes(this.#colaNodes) + .links(links) + .avoidOverlaps(true) + .handleDisconnected(true); + // Build the descent + distance matrix without iterating, then kick alpha so + // our manual `tick`s drive the majorisation. + this.#cola.start(0, 0, 0, 0, false, false); + this.#cola.alpha(tuning.startAlpha); + + this.#status = "running"; + this.#writePositions(); + } + + get status(): ForceLayoutStatus { + return this.#status; + } + + get isSettled(): boolean { + return this.#status === "settled"; + } + + get nodes(): readonly ForceNode[] { + return this.#nodes; + } + + get buffer(): SharedArrayBuffer | ArrayBuffer { + return this.#buffer.raw; + } + + get nodeIds(): string[] { + return this.#nodes.map((node) => node.id); + } + + get alpha(): number { + return this.#cola.alpha(); + } + + tick(budgetMs: number): boolean { + if (this.#status === "settled" || this.#status === "paused") { + return false; + } + this.#status = "running"; + const startTime = performance.now(); + let changed = false; + let converged = false; + + while (performance.now() - startTime < budgetMs && !converged) { + converged = this.#cola.runStep(); + this.#steps += 1; + changed = true; + if (this.#steps >= this.#tuning.maxSteps) { + converged = true; + } + } + + if (changed) { + this.#writePositions(); + } + if (converged) { + this.#status = "settled"; + } + return changed; + } + + pause(): void { + if (this.#status === "running") { + this.#status = "paused"; + } + } + + resume(): void { + if (this.#status === "paused") { + this.#status = "running"; + this.#cola.alpha(this.#tuning.startAlpha); + } + } + + /** + * Re-run the layout with fixed external-port anchors (see {@link PortAnchor}). + * Each anchor is a pinned WebCola node on the rim; the children linked to it + * are pulled toward their external connection, so they sort to the correct + * side and feeders leave the container without crossing. Anchors are appended + * after the children, so the SharedArrayBuffer output (children only, 0..n-1) + * is unaffected. + */ + setPortAnchors(anchors: readonly PortAnchor[]): void { + if (anchors.length === 0) { + return; + } + const childCount = this.#childColaNodes.length; + const anchorNodes: InputNode[] = anchors.map((anchor) => ({ + x: anchor.x, + y: anchor.y, + fixed: 1, + width: 1, + height: 1, + })); + this.#colaNodes = [...this.#childColaNodes, ...anchorNodes]; + + const links: Link[] = [...this.#childLinks]; + const anchorLength = + (this.#confinementRadius ?? 1) * this.#tuning.anchorLinkFraction; + for (const [anchorOffset, anchor] of anchors.entries()) { + const anchorIndex = childCount + anchorOffset; + for (const child of anchor.children) { + links.push({ + source: child.index, + target: anchorIndex, + length: anchorLength, + weight: child.weight, + }); + } + } + + this.#cola + .nodes(this.#colaNodes) + .links(links) + // Disable disconnected-component packing for the re-run: it can relocate + // our fixed rim anchors. The anchors + sibling links provide connectivity. + .handleDisconnected(false) + .start(0, 0, 0, 0, false, false); + this.#cola.alpha(this.#tuning.startAlpha); + this.#status = "running"; + this.#steps = 0; + this.#writePositions(); + } + + /** + * Move the fixed port anchors in place: no re-run, no emit. WebCola re-reads + * a fixed node's locked `px`/`py` at the start of every step, so on a + * still-running layout the updated anchors re-aim the children, which keep + * sorting toward them as the layout settles. + * + * On a settled layout the writes are inert: `tick` returns before stepping, + * so nothing re-reads the locks, and no production path restarts a settled + * cluster layout short of rebuilding it (the port-constraint pass applies + * anchors to running layouts only). Settle times are not coupled: a small + * sub-cluster settles well before a large macro, and the macro's settle-tick + * polish can jump bubble positions afterwards, so a tracked neighbour can + * end up in a direction the settled children never sorted toward. That + * staleness is accepted deliberately: feeders are rebuilt from live circles + * every frame, so edges still meet the true rim port and the worst case is + * cosmetic (children grouped toward an outdated side, feeders running + * further across the bubble interior); the settle-tick untangle polish + * re-arranges small sub-clusters with no port term anyway, so port sorting + * is a soft bias of the running phase, not a maintained invariant; and + * restarting a settled layout to chase drift would visibly re-shuffle an + * arrangement the user has already been shown. + */ + updateAnchorPositions(positions: readonly Position[]): void { + const childCount = this.#childColaNodes.length; + for (let idx = 0; idx < positions.length; idx++) { + // childCount + idx is within #colaNodes only when setPortAnchors + // appended anchors; cast reaches WebCola's undocumented px/py lock fields. + const anchor = this.#colaNodes[childCount + idx] as + | (InputNode & { px?: number; py?: number }) + | undefined; + if (!anchor) { + continue; + } + const target = positions[idx]!; + anchor.x = target.x; + anchor.px = target.x; + anchor.y = target.y; + anchor.py = target.y; + } + } + + /** Sync positions from WebCola, re-centred on the origin, into the shared buffer. */ + #writePositions(): void { + const count = this.#nodes.length; + let centroidX = 0; + let centroidY = 0; + for (let idx = 0; idx < count; idx++) { + centroidX += this.#colaNodes[idx]!.x ?? 0; + centroidY += this.#colaNodes[idx]!.y ?? 0; + } + centroidX = count > 0 ? centroidX / count : 0; + centroidY = count > 0 ? centroidY / count : 0; + + const xs = this.#posX; + const ys = this.#posY; + for (let idx = 0; idx < count; idx++) { + xs[idx] = (this.#colaNodes[idx]!.x ?? 0) - centroidX; + ys[idx] = (this.#colaNodes[idx]!.y ?? 0) - centroidY; + } + + // Sub-cluster layouts are confined to the parent circle. WebCola lays out + // unconstrained: its stress spread is wider than the tree's circle-packing, + // so without this the children stick out of their parent bubble. Clamp into + // the circle and relax overlaps; a contained, ~non-overlapping fit exists + // (the tree packed them), and the relaxation recovers it. + if (this.#confinementRadius !== undefined) { + this.#fitWithin(this.#confinementRadius); + } + + const positions = this.#buffer.positions; + for (let idx = 0; idx < count; idx++) { + // Keep ForceNode x/y in sync with the buffer so downstream geometry + // passes read settled local coords. + this.#nodes[idx]!.x = xs[idx]!; + this.#nodes[idx]!.y = ys[idx]!; + positions[idx * 2] = xs[idx]!; + positions[idx * 2 + 1] = ys[idx]!; + } + this.#buffer.commit(); + } + + /** Clamp positions inside the confinement circle and relax overlaps, in place. */ + #fitWithin(radius: number): void { + const count = this.#nodes.length; + const xs = this.#posX; + const ys = this.#posY; + const clamp = (idx: number): void => { + const limit = Math.max(0, radius - this.#nodes[idx]!.radius); + const dist = Math.hypot(xs[idx]!, ys[idx]!); + if (dist > limit && dist > 0) { + const scale = limit / dist; + xs[idx] = xs[idx]! * scale; + ys[idx] = ys[idx]! * scale; + } + }; + + for (let idx = 0; idx < count; idx++) { + clamp(idx); + } + for (let pass = 0; pass < this.#tuning.confinePasses; pass++) { + let moved = false; + for (let first = 0; first < count; first++) { + for (let second = first + 1; second < count; second++) { + let dx = xs[second]! - xs[first]!; + let dy = ys[second]! - ys[first]!; + let dist = Math.hypot(dx, dy); + const minDist = + this.#nodes[first]!.radius + this.#nodes[second]!.radius; + if (dist < minDist) { + if (dist < 1e-6) { + dx = 1; + dy = 0; + dist = 1; + } + const push = (minDist - dist) / 2; + const ux = (dx / dist) * push; + const uy = (dy / dist) * push; + xs[first] = xs[first]! - ux; + ys[first] = ys[first]! - uy; + xs[second] = xs[second]! + ux; + ys[second] = ys[second]! + uy; + moved = true; + } + } + } + for (let idx = 0; idx < count; idx++) { + clamp(idx); + } + if (!moved) { + break; + } + } + } +} + +/** + * Creates a WebCola-backed cluster layout that streams re-centred positions + * into a SharedArrayBuffer each tick. + */ +export function createClusterLayout( + nodes: ForceNode[], + edges: ForceEdge[], + confinementRadius?: number, + tuning: ClusterForceConfig = defaultClusterForceConfig, +): LayoutSimulation { + return new ClusterLayout(nodes, edges, confinementRadius, tuning); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/entity-layout-config.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/entity-layout-config.ts new file mode 100644 index 00000000000..deddce3b8b5 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/entity-layout-config.ts @@ -0,0 +1,55 @@ +/** + * Tuning for the d3-force entity (dot) layout inside opened leaf clusters + * ({@link "./entity-layout"}), including the shared settle threshold consumed + * by {@link "./force-simulation"}. + * + * Kept separate from the engine so the main thread (root `config.ts`, the dev + * harness) can import the defaults without pulling d3-force into the page + * bundle. + */ +export interface EntityForceConfig { + /** Gentle pull toward the bubble centre; collision does the real spacing. @defaultValue 0.05. */ + readonly centerStrength: number; + /** Pull toward the external port target; blends with (does not erase) centre. @defaultValue 0.2. */ + readonly portAttractionStrength: number; + /** d3 forceManyBody strength (negative repels). @defaultValue -1. */ + readonly chargeStrength: number; + /** Distance (world units) beyond which the charge force is ignored. @defaultValue 50. */ + readonly chargeDistanceMax: number; + /** Extra collision radius (world units) around each dot. @defaultValue 1. */ + readonly collidePadding: number; + /** d3 forceCollide iterations per tick. @defaultValue 4. */ + readonly collideIterations: number; + /** Link rest length as a multiple of the endpoints' combined radii. @defaultValue 2. */ + readonly linkDistanceMultiplier: number; + /** Additive link rest-length padding (world units). @defaultValue 10. */ + readonly linkDistancePadding: number; + /** Link strength per unit of link weight, capped at 1. @defaultValue 0.3. */ + readonly linkStrengthFactor: number; + /** d3 alphaDecay: how fast the simulation cools. @defaultValue 0.015. */ + readonly alphaDecay: number; + /** d3 velocityDecay: per-tick velocity damping. @defaultValue 0.35. */ + readonly velocityDecay: number; + /** + * Freeze a d3 layout once alpha drops to here. + * + * @defaultValue 0.001, d3's natural settle floor; 0.01 freezes before + * collision forces finish separating the last overlaps. + */ + readonly settleAlpha: number; +} + +export const defaultEntityForceConfig: EntityForceConfig = { + centerStrength: 0.05, + portAttractionStrength: 0.2, + chargeStrength: -1, + chargeDistanceMax: 50, + collidePadding: 1, + collideIterations: 4, + linkDistanceMultiplier: 2, + linkDistancePadding: 10, + linkStrengthFactor: 0.3, + alphaDecay: 0.015, + velocityDecay: 0.35, + settleAlpha: 0.001, +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/entity-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/entity-layout.ts new file mode 100644 index 00000000000..3ae11018c14 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/entity-layout.ts @@ -0,0 +1,129 @@ +/** + * Force layout for individual entities inside an opened leaf cluster. + * + * Entities should fill their bubble compactly: weak charge, collision spacing, + * a gentle spring toward the center, and hard confinement to the bubble. Link + * springs pull connected entities together. This is deliberately not the + * cluster layout: bubbles need to spread and route edges; dots need to pack. + * + * On top of that, a port-attraction force pulls each entity toward the live rim + * point where its external edge attaches (its port target), instead of a + * pre-baked exit fan: the dots cluster near their real exits, so the fan-out + * lines stay short and legible. The target array is shared with (and updated + * live by) the worker, so as ports re-slot the dots follow, with no baked, + * going-stale positions. + * + * The port pull does not fully win: the gentle center spring stays on for every + * dot, so a targeted dot settles a bit inside the rim (a blend of port + centre) + * rather than jammed against it, which keeps dots legible and lets a dot with + * several external ports sit sensibly between them instead of being torn to one. + */ +import { + forceCollide, + forceLink, + forceManyBody, + forceSimulation, + forceX, + forceY, +} from "d3-force"; + +import { defaultEntityForceConfig } from "./entity-layout-config"; +import { ForceSimulation } from "./force-simulation"; + +import type { EntityForceConfig } from "./entity-layout-config"; +import type { ForceEdge, ForceNode } from "./force-simulation"; +import type { Force } from "d3-force"; + +/** + * Pull each entity toward its port target `(targets[2i], targets[2i+1])`, in the + * leaf's local frame. A NaN target means "no external connection", so that + * entity is left to the center/charge forces. `targets` is owned by the worker and + * mutated in place as ports re-slot; this force reads it live each tick. + */ +function forcePortAttraction( + targets: Float32Array, + strength: number, +): Force { + let nodes: ForceNode[] = []; + + const force: Force = (alpha) => { + for (let idx = 0; idx < nodes.length; idx++) { + const tx = targets[idx * 2]!; + if (Number.isNaN(tx)) { + continue; + } + const ty = targets[idx * 2 + 1]!; + const node = nodes[idx]!; + node.vx = (node.vx ?? 0) + (tx - (node.x ?? 0)) * strength * alpha; + node.vy = (node.vy ?? 0) + (ty - (node.y ?? 0)) * strength * alpha; + } + }; + + force.initialize = (newNodes: ForceNode[]) => { + nodes = newNodes; + }; + + return force; +} + +/** + * Builds a confined d3-force simulation for leaf-cluster entities with + * optional live port-target attraction. + */ +export function createEntityLayout( + nodes: ForceNode[], + edges: ForceEdge[], + confinementRadius: number, + portTargets?: Float32Array, + tuning: EntityForceConfig = defaultEntityForceConfig, +): ForceSimulation { + const simulation = forceSimulation(nodes) + .force( + "charge", + forceManyBody() + .strength(tuning.chargeStrength) + .distanceMax(tuning.chargeDistanceMax), + ) + .force( + "collide", + forceCollide( + (node) => node.radius + tuning.collidePadding, + ).iterations(tuning.collideIterations), + ) + .force( + "link", + forceLink(edges) + .id((node) => node.id) + // forceLink resolves string endpoints to ForceNode objects before + // distance/strength run; cast is safe after simulation init. + .distance( + (edge) => + ((edge.source as ForceNode).radius + + (edge.target as ForceNode).radius) * + tuning.linkDistanceMultiplier + + tuning.linkDistancePadding, + ) + .strength((edge) => + Math.min(1, tuning.linkStrengthFactor * edge.weight), + ), + ) + .force("centerX", forceX(0).strength(tuning.centerStrength)) + .force("centerY", forceY(0).strength(tuning.centerStrength)) + .alphaDecay(tuning.alphaDecay) + .velocityDecay(tuning.velocityDecay) + .stop(); + + if (portTargets) { + simulation.force( + "port", + forcePortAttraction(portTargets, tuning.portAttractionStrength), + ); + } + + return new ForceSimulation( + nodes, + simulation, + confinementRadius, + tuning.settleAlpha, + ); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/flat-layout-config.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/flat-layout-config.ts new file mode 100644 index 00000000000..764b571172b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/flat-layout-config.ts @@ -0,0 +1,72 @@ +/** + * Tuning for the small-N flat tier's WebCola stress layout + * ({@link "./flat-layout"}: stress -> pack -> overlap phases). + * + * Kept separate from the engine so the main thread (root `config.ts`, the dev + * harness) can import the defaults without pulling WebCola into the page + * bundle. + */ +export interface FlatForceConfig { + /** + * Base ideal link length (world units); jaccardLinkLengths scales it by structure. + * + * @defaultValue 40. + */ + readonly idealLinkLength: number; + /** + * How strongly neighbourhood overlap warps ideal link lengths (WebCola's + * jaccardLinkLengths weight). + * + * @defaultValue 1. + */ + readonly jaccardWeight: number; + /** + * Non-overlap padding around each dot, in world units. + * + * @defaultValue 3. + */ + readonly nodePadding: number; + /** + * Stress-convergence ratio threshold for the unconstrained phase (cola's own + * default ratio test). + * + * @defaultValue 0.01. + */ + readonly convergenceThreshold: number; + /** + * Safety cap on unconstrained stress iterations (guarantees phase termination). + * + * @defaultValue 200. + */ + readonly stressMaxIterations: number; + /** + * Guaranteed base of overlap (VPSC) iterations before the convergence test + * may stop the phase. + * + * @defaultValue 40. + */ + readonly overlapMinIterations: number; + /** + * Safety cap on overlap (VPSC) iterations. + * + * @defaultValue 400. + */ + readonly overlapMaxIterations: number; + /** + * Fallback node size (world units) for disconnected-component packing. + * + * @defaultValue 16. + */ + readonly packNodeSize: number; +} + +export const defaultFlatForceConfig: FlatForceConfig = { + idealLinkLength: 40, + jaccardWeight: 1, + nodePadding: 3, + convergenceThreshold: 0.01, + stressMaxIterations: 200, + overlapMinIterations: 40, + overlapMaxIterations: 400, + packNodeSize: 16, +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/flat-layout.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/flat-layout.test.ts new file mode 100644 index 00000000000..c2057019a28 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/flat-layout.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; + +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { createFlatLayout } from "./flat-layout"; + +import type { + ForceEdge, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; + +function settle(layout: LayoutSimulation): void { + for (let step = 0; step < 1000 && !layout.isSettled; step++) { + layout.tick(50); + } +} + +function positionsOf(layout: LayoutSimulation): [number, number][] { + return layout.nodes.map((node) => [node.x ?? 0, node.y ?? 0]); +} + +function makeNodes(count: number): ForceNode[] { + const nodes: ForceNode[] = []; + for (let idx = 0; idx < count; idx++) { + // Distinct deterministic seeds (not all stacked on the origin). + nodes.push({ id: String(idx), x: idx * 7, y: (idx % 3) * 5, radius: 4 }); + } + return nodes; +} + +function layoutOf(nodes: ForceNode[], edges: ForceEdge[]): LayoutSimulation { + return createFlatLayout(nodes, edges, new FlatGraphBuffer(nodes.length)); +} + +function distanceBetween( + positions: readonly [number, number][], + lhs: number, + rhs: number, +): number { + return Math.hypot( + positions[lhs]![0] - positions[rhs]![0], + positions[lhs]![1] - positions[rhs]![1], + ); +} + +describe("createFlatLayout", () => { + it("settles, stays finite, and re-centres on the origin", () => { + const nodes = makeNodes(8); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + expect(layout.isSettled).toBe(true); + const positions = positionsOf(layout); + let sumX = 0; + let sumY = 0; + for (const [posX, posY] of positions) { + expect(Number.isFinite(posX)).toBe(true); + expect(Number.isFinite(posY)).toBe(true); + sumX += posX; + sumY += posY; + } + expect(Math.abs(sumX / positions.length)).toBeLessThan(1); + expect(Math.abs(sumY / positions.length)).toBeLessThan(1); + }); + + it("does not pile nodes on top of each other", () => { + const nodes = makeNodes(10); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + const positions = positionsOf(layout); + for (let lhs = 0; lhs < positions.length; lhs++) { + for (let rhs = lhs + 1; rhs < positions.length; rhs++) { + // Radius 4 each: centres must be clearly apart (non-overlap box is 2*(4+3)=14). + expect(distanceBetween(positions, lhs, rhs)).toBeGreaterThan(4); + } + } + }); + + it("pulls a linked pair closer than the graph's overall spread", () => { + // Path 0-1-2-3-4: adjacent nodes sit near the ideal length; ends far apart. + const nodes = makeNodes(5); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + { source: "3", target: "4", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + const positions = positionsOf(layout); + expect(distanceBetween(positions, 0, 1)).toBeLessThan( + distanceBetween(positions, 0, 4), + ); + expect(distanceBetween(positions, 3, 4)).toBeLessThan( + distanceBetween(positions, 0, 4), + ); + }); + + it("is deterministic for identical seed positions", () => { + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const first = layoutOf(makeNodes(4), [...edges]); + const second = layoutOf(makeNodes(4), [...edges]); + settle(first); + settle(second); + + const firstPositions = positionsOf(first); + const secondPositions = positionsOf(second); + for (let idx = 0; idx < firstPositions.length; idx++) { + expect(firstPositions[idx]![0]).toBeCloseTo(secondPositions[idx]![0], 4); + expect(firstPositions[idx]![1]).toBeCloseTo(secondPositions[idx]![1], 4); + } + }); + + // Perf probe (skipped; ~6.6s). cola's Descent is the small-N (flat-force) + // engine; this confirms it stays robust at the top of its range (1000 nodes in + // one connected component is the worst case for its O(N^2) (real data is many + // small components, far faster). The community-force tier uses the majorization + // engine; a different engine for a different scale, not a fallback when + // flat-force is slow. Un-skip to re-benchmark. + it.skip("settles ~1000 nodes (worst case: one connected component) and times it", () => { + const count = 1000; + const nodes: ForceNode[] = []; + for (let idx = 0; idx < count; idx++) { + nodes.push({ + id: String(idx), + x: (idx % 40) * 20, + y: Math.floor(idx / 40) * 20, + radius: 5, + }); + } + + // One connected component (chain + cross-links): a fully-connected graph is + // the worst case for cola's O(N^2) (dense distance matrix, slow convergence). + // Heavier than the real many-small-components data. + const edges: ForceEdge[] = []; + for (let idx = 1; idx < count; idx++) { + edges.push({ source: String(idx - 1), target: String(idx), weight: 1 }); + if (idx >= 7) { + edges.push({ source: String(idx - 7), target: String(idx), weight: 1 }); + } + } + + const start = performance.now(); + const layout = createFlatLayout(nodes, edges, new FlatGraphBuffer(count)); + let batches = 0; + while (!layout.isSettled && batches < 2000) { + layout.tick(1000); + batches += 1; + } + const elapsed = performance.now() - start; + // eslint-disable-next-line no-console + console.log( + `[bench] ${count} nodes / ${edges.length} edges (connected) -> settle ` + + `${elapsed.toFixed(0)}ms over ${batches} batches`, + ); + expect(layout.isSettled).toBe(true); + }, 120000); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/flat-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/flat-layout.ts new file mode 100644 index 00000000000..bb3348cbebb --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/flat-layout.ts @@ -0,0 +1,400 @@ +/** + * The flat-tier layout: one stress-minimised embedding of the whole entity set + * for `flat-force` and `community-force` (one individual-entity regime: + * identical placement; the modes differ only in whether BubbleSets highlight + * subcommunities). This is not the cluster (macro) layout: there are no + * containers, no ports, every node is an individual entity. + * + * We drive WebCola's `Descent` solver directly rather than via its `Layout` + * class. `Layout.start()` runs its phases (unconstrained stress -> overlap) to + * completion synchronously; that fights our model, where every step must go + * through the event-queue scheduler and stream to the SharedArrayBuffer. So we + * own the sequencing and stage the phases across `tick()` calls: + * + * - Phase A (unconstrained stress majorisation): `descent.project = null`; step + * `rungeKutta()` until stress converges. cola unfolds the graph by pure + * stress, link communities separating spatially (jaccard-weighted ideal link + * lengths). Disconnected pairs have an infinite ideal distance, which cola's + * gradient treats as zero force (harmless), so components drift freely here. + * - Pack: `separateGraphs` + `applyPacking` arrange the (now-settled) connected + * components compactly, the geometric step the stress phase can't do. + * - Phase B (VPSC non-overlap): `descent.project = Projection(...).projectFunctions()` + * with `avoidOverlaps`; step `rungeKutta()` for a fixed iteration budget. Not + * "until stress converges", VPSC is nearly stress-neutral, so a convergence + * test quits before overlap is resolved (this is how `Layout` does it too). + * + * Every step writes the shared buffer and is published, so the whole settling + * streams. cola owns every position throughout, we never mutate its node + * coordinates behind its back (doing so corrupts its descent + VPSC state and + * piles nodes up). + */ +import { + Calculator, + Descent, + Projection, + applyPacking, + jaccardLinkLengths, + separateGraphs, +} from "webcola"; + +import { defaultFlatForceConfig } from "./flat-layout-config"; + +import type { FlatGraphBuffer } from "../buffers/position-buffer"; +import type { FlatForceConfig } from "./flat-layout-config"; +import type { + ForceEdge, + ForceLayoutStatus, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; + +/** + * A node as cola's `Projection` / packing read it: positions (rebuilt into + * `bounds` by the projection each step) + the box used for non-overlap. + */ +interface ColaNode { + index: number; + x: number; + y: number; + width: number; + height: number; +} + +/** Index-based link (its jaccard length is kept in a side map, not on the link). */ +interface ColaLink { + readonly source: number; + readonly target: number; +} + +/** Object-endpoint link for `separateGraphs` (it reads `source.index`). */ +interface ColaObjectLink { + source: ColaNode; + target: ColaNode; +} + +type FlatPhase = "stress" | "pack" | "overlap" | "done"; + +class FlatLayout implements LayoutSimulation { + readonly #nodes: ForceNode[]; + readonly #colaNodes: ColaNode[]; + readonly #objectLinks: ColaObjectLink[]; + readonly #rootGroup: { leaves: ColaNode[]; groups: never[]; padding: number }; + readonly #descent: Descent; + /** + * Square weight matrix: 2 off-edge (repel only), 1 on-edge (pull to ideal). + * Attached only during Phase B overlap projection. + */ + readonly #weights: number[][]; + readonly #buffer: FlatGraphBuffer; + readonly #tuning: FlatForceConfig; + #status: ForceLayoutStatus; + #phase: FlatPhase; + #prevDisp = Number.MAX_VALUE; + #stressSteps = 0; + #overlapSteps = 0; + + constructor( + nodes: ForceNode[], + edges: ForceEdge[], + buffer: FlatGraphBuffer, + tuning: FlatForceConfig, + ) { + this.#nodes = nodes; + this.#buffer = buffer; + this.#tuning = tuning; + const count = nodes.length; + + const idToIndex = new Map(); + for (const [index, node] of nodes.entries()) { + idToIndex.set(node.id, index); + } + + this.#colaNodes = nodes.map((node, index) => ({ + index, + x: node.x ?? 0, + y: node.y ?? 0, + width: (node.radius + tuning.nodePadding) * 2, + height: (node.radius + tuning.nodePadding) * 2, + })); + + const links: ColaLink[] = []; + this.#objectLinks = []; + for (const edge of edges) { + const sourceId = + typeof edge.source === "string" ? edge.source : edge.source.id; + const targetId = + typeof edge.target === "string" ? edge.target : edge.target.id; + const sourceIndex = idToIndex.get(sourceId); + const targetIndex = idToIndex.get(targetId); + if ( + sourceIndex !== undefined && + targetIndex !== undefined && + sourceIndex !== targetIndex + ) { + links.push({ source: sourceIndex, target: targetIndex }); + this.#objectLinks.push({ + source: this.#colaNodes[sourceIndex]!, + target: this.#colaNodes[targetIndex]!, + }); + } + } + + // Neighbourhood-aware ideal lengths: jaccardLinkLengths writes link.length + // (= 1 + w * jaccard); the distance fed to Dijkstra is then idealLength * length. + const lengthByLink = new Map(); + const accessor = { + getSourceIndex: (link: ColaLink) => link.source, + getTargetIndex: (link: ColaLink) => link.target, + setLength: (link: ColaLink, value: number) => { + lengthByLink.set(link, value); + }, + }; + jaccardLinkLengths(links, accessor, tuning.jaccardWeight); + + const distances = new Calculator( + count, + links, + accessor.getSourceIndex, + accessor.getTargetIndex, + (link) => tuning.idealLinkLength * (lengthByLink.get(link) ?? 1), + ).DistanceMatrix(); + const distanceMatrix = Descent.createSquareMatrix( + count, + (row, col) => distances[row]![col]!, + ); + + // Weights: 2 everywhere (non-adjacent, only repel when too close), 1 for + // edges (pulled to ideal). Matches Layout.start; attached for the overlap pass. + this.#weights = Descent.createSquareMatrix(count, () => 2); + for (const link of links) { + this.#weights[link.source]![link.target] = 1; + this.#weights[link.target]![link.source] = 1; + } + + const xs = this.#colaNodes.map((node) => node.x); + const ys = this.#colaNodes.map((node) => node.y); + this.#descent = new Descent([xs, ys], distanceMatrix); + this.#descent.threshold = tuning.convergenceThreshold; + // `descent.project` is null after construction, so Phase A is unconstrained + // (computeNextPosition guards `if (this.project)`). Phase B sets the overlap + // Projection. (cola's .d.ts mistypes `project` as non-nullable, hence no assign.) + + this.#rootGroup = { leaves: this.#colaNodes, groups: [], padding: 1 }; + + this.#status = count > 0 ? "running" : "settled"; + this.#phase = count > 0 ? "stress" : "done"; + this.#writePositions(); + } + + get status(): ForceLayoutStatus { + return this.#status; + } + + get isSettled(): boolean { + return this.#status === "settled"; + } + + get nodes(): readonly ForceNode[] { + return this.#nodes; + } + + get buffer(): SharedArrayBuffer | ArrayBuffer { + return this.#buffer.raw; + } + + get nodeIds(): string[] { + return this.#nodes.map((node) => node.id); + } + + get alpha(): number { + return this.#phase === "done" ? 0 : this.#tuning.convergenceThreshold; + } + + tick(budgetMs: number): boolean { + if (this.#status === "settled" || this.#status === "paused") { + return false; + } + this.#status = "running"; + const startTime = performance.now(); + let stepped = false; + + while (performance.now() - startTime < budgetMs && this.#phase !== "done") { + this.#advance(); + stepped = true; + } + + if (stepped) { + this.#writePositions(); + } + if (this.#phase === "done") { + this.#status = "settled"; + } + return stepped; + } + + pause(): void { + if (this.#status === "running") { + this.#status = "paused"; + } + } + + resume(): void { + if (this.#status === "paused") { + this.#status = "running"; + } + } + + /** One unit of work, advancing the phase machine. cola owns every position. */ + #advance(): void { + switch (this.#phase) { + case "stress": { + const disp = this.#descent.rungeKutta(); + this.#stressSteps += 1; + const converged = + Number.isFinite(disp) && + disp > 0 && + Math.abs(this.#prevDisp / disp - 1) < + this.#tuning.convergenceThreshold; + this.#prevDisp = disp; + if ( + converged || + disp === 0 || + !Number.isFinite(disp) || + // rungeKutta() returns displacement, which ->0 at settle, so the + // ratio test never trips and the cap guarantees termination. + this.#stressSteps >= this.#tuning.stressMaxIterations + ) { + this.#phase = "pack"; + } + break; + } + case "pack": { + this.#packComponents(); + // Switch on VPSC non-overlap. Projection rebuilds each node's bounds from + // the descent's position arrays every step, so we needn't sync node x/y. + const projection = new Projection( + // as never: ColaNode satisfies runtime shape but not WebCola's + // GraphNode d.ts. [] not null: Projection skips xConstraints init + // when falsy, then project() calls xConstraints.concat(...) and + // throws (Layout passes [] here too). + this.#colaNodes as never, + [], + this.#rootGroup as never, + [], + true, + ); + this.#descent.project = projection.projectFunctions(); + this.#descent.G = this.#weights; + this.#overlapSteps = 0; + this.#prevDisp = Number.MAX_VALUE; + this.#phase = "overlap"; + break; + } + case "overlap": { + const disp = this.#descent.rungeKutta(); + this.#overlapSteps += 1; + if (this.#overlapSteps <= this.#tuning.overlapMinIterations) { + // Guaranteed base: VPSC is stress-neutral at first, so a convergence + // test would quit before overlap resolves. Run the base, then trust it. + this.#prevDisp = disp; + break; + } + // Past the base: stop once the descent floors (stable), same ratio test + // as Phase A, or at the safety cap. + const converged = + disp === 0 || + (Number.isFinite(disp) && + disp > 0 && + Math.abs(this.#prevDisp / disp - 1) < + this.#tuning.convergenceThreshold); + this.#prevDisp = disp; + if ( + converged || + !Number.isFinite(disp) || + this.#overlapSteps >= this.#tuning.overlapMaxIterations + ) { + this.#phase = "done"; + } + break; + } + default: + break; + } + } + + /** + * Packs disconnected components compactly after stress convergence: a + * geometric step unconstrained stress cannot perform, since disconnected + * components have no inter-force to pull them together. `applyPacking` works + * on node x/y, so this syncs from the descent, packs, and syncs back. + */ + #packComponents(): void { + const xs = this.#descent.x[0]!; + const ys = this.#descent.x[1]!; + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (let idx = 0; idx < this.#colaNodes.length; idx++) { + const node = this.#colaNodes[idx]!; + node.x = xs[idx]!; + node.y = ys[idx]!; + minX = Math.min(minX, node.x); + maxX = Math.max(maxX, node.x); + minY = Math.min(minY, node.y); + maxY = Math.max(maxY, node.y); + } + const width = Math.max(1, maxX - minX); + const height = Math.max(1, maxY - minY); + + // as never: separateGraphs expects cola's Link d.ts; object + // endpoints carry .index at runtime. + const graphs = separateGraphs(this.#colaNodes, this.#objectLinks as never); + applyPacking(graphs, width, height, this.#tuning.packNodeSize, 1, true); + + for (let idx = 0; idx < this.#colaNodes.length; idx++) { + const node = this.#colaNodes[idx]!; + xs[idx] = node.x; + ys[idx] = node.y; + } + } + + /** Read cola's positions, re-centre on the origin, into the shared buffer + ForceNode view. */ + #writePositions(): void { + const count = this.#nodes.length; + const xs = this.#descent.x[0]!; + const ys = this.#descent.x[1]!; + let centroidX = 0; + let centroidY = 0; + for (let idx = 0; idx < count; idx++) { + centroidX += xs[idx]!; + centroidY += ys[idx]!; + } + centroidX = count > 0 ? centroidX / count : 0; + centroidY = count > 0 ? centroidY / count : 0; + + for (let idx = 0; idx < count; idx++) { + const localX = xs[idx]! - centroidX; + const localY = ys[idx]! - centroidY; + // Keep ForceNode x/y aligned with descent arrays for rebuilds that seed + // from prior positions. + this.#nodes[idx]!.x = localX; + this.#nodes[idx]!.y = localY; + this.#buffer.setPosition(idx, localX, localY); + } + this.#buffer.commit(); + } +} + +/** + * Creates a phased WebCola Descent layout (stress -> pack -> overlap) that + * streams positions through a FlatGraphBuffer. + */ +export function createFlatLayout( + nodes: ForceNode[], + edges: ForceEdge[], + buffer: FlatGraphBuffer, + tuning: FlatForceConfig = defaultFlatForceConfig, +): LayoutSimulation { + return new FlatLayout(nodes, edges, buffer, tuning); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/force-simulation.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/force-simulation.bench.ts new file mode 100644 index 00000000000..46a8ff7dbec --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/force-simulation.bench.ts @@ -0,0 +1,111 @@ +/** + * Full-layout settle cost for the two individual-entity engines. `build + settle` + * is the whole time-to-laid-out a user waits on: matrix/seed construction plus + * every solver iteration to convergence. It shows why flat-force (cola `Descent`, + * O(N^2) per step + an O(N^2) distance matrix at build) is capped at 200 nodes + * and community-force (constrained stress majorization) takes the medium tier. + * + * Settling dominates and is expensive, so these use a fixed, small iteration + * count (`SETTLE_OPTS`) rather than vitest's default time budget; treat the means + * as ballpark (few samples), the cross-size / cross-engine SHAPE is the point. + * + * Run: `cd apps/hash-frontend && ../../node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer/worker/layout/force-simulation.bench.ts` + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { buildForceGraph } from "../bench-fixtures"; +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { createFlatLayout } from "./flat-layout"; +import { createMajorizationLayout } from "./majorization-layout"; + +import type { GraphShape } from "../bench-fixtures"; +import type { ForceNode, LayoutSimulation } from "./force-simulation"; + +/** Exactly `iterations` measured runs (settling is too slow for a time budget). */ +const SETTLE_OPTS = { + time: 0, + iterations: 6, + warmupTime: 0, + warmupIterations: 1, +} as const; + +function shape(nodeCount: number, linkCount: number, seed: number): GraphShape { + return { + nodeCount, + linkCount, + typeCount: 1, + hubCount: Math.max(4, Math.round(nodeCount / 40)), + rootFraction: 1, + seed, + }; +} + +/** Drive the phase machine to convergence (each engine self-caps its iterations). */ +function settle(layout: LayoutSimulation): void { + while (!layout.isSettled) { + if (!layout.tick(60_000)) { + break; + } + } +} + +/** Fresh node objects: the solvers mutate node x/y in place, so a shared array + * would let run N+1 warm-start from run N's settled positions. */ +function cloneNodes(nodes: readonly ForceNode[]): ForceNode[] { + return nodes.map((node) => ({ ...node })); +} + +const FLAT_CASES: readonly GraphShape[] = [ + shape(50, 90, 101), + shape(120, 240, 102), + shape(200, 400, 103), +]; + +for (const graphShape of FLAT_CASES) { + describe(`flat-force build + settle (${graphShape.nodeCount} nodes)`, () => { + const { nodes, edges } = buildForceGraph(graphShape); + bench( + "cola Descent", + () => { + const buffer = new FlatGraphBuffer(nodes.length); + settle(createFlatLayout(cloneNodes(nodes), edges, buffer)); + }, + SETTLE_OPTS, + ); + }); +} + +const COMMUNITY_CASES: readonly GraphShape[] = [ + shape(500, 1_200, 201), + shape(1_500, 4_000, 202), + shape(3_000, 8_000, 203), +]; + +for (const graphShape of COMMUNITY_CASES) { + describe(`community-force (${graphShape.nodeCount} nodes)`, () => { + const { nodes, edges } = buildForceGraph(graphShape); + + // Worker thread blocks on synchronous Louvain + buffer allocation during + // createMajorizationLayout; subsequent solve ticks are budget-sliced and + // stream to the main thread. + bench( + "build only (construct)", + () => { + const buffer = new FlatGraphBuffer(nodes.length); + createMajorizationLayout(cloneNodes(nodes), edges, buffer); + }, + SETTLE_OPTS, + ); + + bench( + "build + settle (majorization)", + () => { + const buffer = new FlatGraphBuffer(nodes.length); + settle(createMajorizationLayout(cloneNodes(nodes), edges, buffer)); + }, + SETTLE_OPTS, + ); + }); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/force-simulation.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/force-simulation.ts new file mode 100644 index 00000000000..5a9bf7618e7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/force-simulation.ts @@ -0,0 +1,297 @@ +import { EntityPositionBuffer } from "../buffers/position-buffer"; + +import type { Position } from "../../geometry"; +/** + * Shared mechanics for a SharedArrayBuffer-backed d3-force simulation: + * position storage, time-budgeted ticking, settle detection, and circular + * confinement. The forces differ per use and are configured by the dedicated + * factory ({@link "./entity-layout"}); this base only owns what they have in + * common. This engine backs entity-dot layouts; cluster/macro layout uses + * WebCola in {@link "./cluster-layout"}. + * + * Positions are local to the parent (centered at 0,0); the caller translates to + * world coords using the parent's position. Worker writes positions then + * Atomics-increments the version in the SharedArrayBuffer; main thread polls + * the version to decide GPU re-upload (no lock; monotonic counter). + */ +import type { Force, Simulation, SimulationNodeDatum } from "d3-force"; + +export { sharedBufferAvailable } from "../buffers/growable-buffer"; + +export interface ForceNode extends SimulationNodeDatum { + readonly id: string; + readonly radius: number; +} + +export interface ForceEdge { + source: string | ForceNode; + target: string | ForceNode; + readonly weight: number; +} + +export type ForceLayoutStatus = "running" | "paused" | "settled"; + +/** + * A fixed external-port anchor for a sub-cluster layout. The anchor is pinned to + * the parent rim in the direction of an external neighbour; the children that + * connect through it are linked to it, so the layout sorts them toward their + * real external connections (WebCola constraint, only the cluster layout + * supports it). + * + * Position relative to the parent layout's local frame. + */ +export interface PortAnchor extends Position { + /** + * Children linked to this port: index into the layout's nodes + the pull + * weight (proportional to the edge count to this port, so heavily-connected + * children are held to their port more firmly than weakly-connected ones). + */ + readonly children: readonly { + readonly index: number; + readonly weight: number; + }[]; +} + +/** + * Common tick/pause/resume/buffer contract shared by WebCola cluster layouts + * and d3-force entity layouts ({@link ForceSimulation}); the worker drives any + * layout engine through this surface alone. + */ +export interface LayoutSimulation { + readonly status: ForceLayoutStatus; + readonly isSettled: boolean; + readonly nodes: readonly ForceNode[]; + readonly buffer: SharedArrayBuffer | ArrayBuffer; + readonly nodeIds: string[]; + readonly alpha: number; + /** Louvain community id per node in buffer order (community-force layout only). */ + readonly communities?: readonly number[]; + + /** + * Advance up to `budgetMs` of simulation work. Returns whether node + * positions were committed (published) during this call; the worker gates + * frame emission on it, so an engine that advances internally without + * moving published positions MUST return false for those ticks (emitting + * anyway rebuilds all edge geometry for an unchanged picture). + * Liveness/termination is `isSettled`, never this return value. + */ + tick(budgetMs: number): boolean; + pause(): void; + resume(): void; + /** + * Incrementally absorb newly-arrived nodes without a restart (community-force + * majorization engine only): append them (pre-seeded near their neighbours), + * rebuild edge topology from `edges` (the full current set), and keep iterating + * from current positions. cola can't (its Descent is a fixed N*N solve), so the + * flat-force tier omits this and rebuilds (warm-seeded) instead. + */ + absorb?(newNodes: ForceNode[], edges: ForceEdge[]): void; + /** Write a node's rgba colour into the buffer (entity-dot leaves carry per-node colour). */ + setNodeColor?( + index: number, + color: readonly [number, number, number, number], + ): void; + /** Publish colour writes -- bumps the version so the main thread re-uploads. */ + commitColors?(): void; + /** + * Force a community (Louvain) refresh if any nodes were absorbed since the last + * one; returns whether it ran. Position-neutral. The worker calls this on a + * trailing debounce (stream goes quiet) so membership reflects the final graph. + */ + refreshCommunities?(): boolean; + /** Re-run with fixed external-port anchors (cluster layout only). */ + setPortAnchors?(anchors: readonly PortAnchor[]): void; + /** + * Move existing port anchors in place (no re-run). A running layout re-aims + * its children toward them; a settled layout ignores the writes. + */ + updateAnchorPositions?(positions: readonly Position[]): void; +} + +/** + * Freeze a layout once its energy drops to here (see + * {@link "./entity-layout-config"} `settleAlpha` for the tuning rationale). + * Fallback when the caller doesn't pass a threshold. + */ +const DEFAULT_SETTLE_ALPHA = 0.001; + +/** + * Custom d3 force that confines nodes inside a circle. Runs as part of the + * simulation tick (adjusts velocities) rather than as a post-processing step, + * so it interacts correctly with the other forces. + */ +export function forceConfine(radius: number): Force { + let nodes: ForceNode[] = []; + + const force: Force = () => { + for (const node of nodes) { + const x = node.x ?? 0; + const y = node.y ?? 0; + const dist = Math.hypot(x, y); + const boundary = Math.max(0, radius * 0.98 - node.radius); + + if (boundary <= 0) { + node.vx = -(node.x ?? 0) * 0.5; + node.vy = -(node.y ?? 0) * 0.5; + } else if (dist > boundary) { + const overshoot = dist - boundary; + const strength = 0.3 + 0.7 * Math.min(1, overshoot / radius); + node.vx = (node.vx ?? 0) - (x / dist) * overshoot * strength; + node.vy = (node.vy ?? 0) - (y / dist) * overshoot * strength; + } + } + }; + + force.initialize = (newNodes: ForceNode[]) => { + nodes = newNodes; + }; + + return force; +} + +/** + * Drives a stopped d3 simulation under a time budget, writing re-centred local + * positions into an {@link EntityPositionBuffer} each tick. + */ +export class ForceSimulation implements LayoutSimulation { + readonly #simulation: Simulation; + readonly #nodes: ForceNode[]; + readonly #confinementRadius: number | undefined; + readonly #positionBuffer: EntityPositionBuffer; + readonly #settleAlpha: number; + #status: ForceLayoutStatus; + + constructor( + nodes: ForceNode[], + simulation: Simulation, + confinementRadius?: number, + settleAlpha: number = DEFAULT_SETTLE_ALPHA, + ) { + this.#nodes = nodes; + this.#confinementRadius = confinementRadius; + this.#settleAlpha = settleAlpha; + this.#status = "running"; + this.#positionBuffer = new EntityPositionBuffer(nodes.length); + this.#writePositions(); + + this.#simulation = simulation; + if (confinementRadius !== undefined) { + this.#simulation.force("confine", forceConfine(confinementRadius)); + } + } + + get status(): ForceLayoutStatus { + return this.#status; + } + + get isSettled(): boolean { + return this.#status === "settled"; + } + + get nodes(): readonly ForceNode[] { + return this.#nodes; + } + + get alpha(): number { + return this.#simulation.alpha(); + } + + /** The raw buffer backing positions. SharedArrayBuffer when available. */ + get buffer(): SharedArrayBuffer | ArrayBuffer { + return this.#positionBuffer.raw; + } + + /** Node IDs in buffer order. Sent once to the main thread on creation. */ + get nodeIds(): string[] { + return this.#nodes.map((node) => node.id); + } + + /** + * Advances the simulation until alpha falls below the settle threshold or + * the `budgetMs` time budget elapses; returns whether any tick ran and + * positions were committed. + */ + tick(budgetMs: number): boolean { + if (this.#status === "settled") { + return false; + } + + this.#status = "running"; + const start = performance.now(); + let ticked = false; + + while ( + performance.now() - start < budgetMs && + this.#simulation.alpha() > this.#settleAlpha + ) { + this.#simulation.tick(); + if (this.#confinementRadius !== undefined) { + this.#clampPositions(); + } + ticked = true; + } + + if (ticked) { + this.#writePositions(); + } + + if (this.#simulation.alpha() <= this.#settleAlpha) { + this.#status = "settled"; + } + + return ticked; + } + + pause(): void { + if (this.#status === "running") { + this.#status = "paused"; + } + } + + resume(): void { + if (this.#status !== "running") { + this.#status = "running"; + this.#simulation.alpha(0.3).restart().stop(); + } + } + + #writePositions(): void { + for (let idx = 0; idx < this.#nodes.length; idx++) { + const node = this.#nodes[idx]!; + this.#positionBuffer.setPosition(idx, node.x ?? 0, node.y ?? 0); + } + this.#positionBuffer.commit(); + } + + /** Write node `index`'s rgba colour into the buffer (the worker knows the colour). */ + setNodeColor( + index: number, + color: readonly [number, number, number, number], + ): void { + this.#positionBuffer.setColor(index, color); + } + + /** Publish colour writes (bumps the version so the main thread re-uploads). */ + commitColors(): void { + this.#positionBuffer.commit(); + } + + #clampPositions(): void { + // #clampPositions is only called from tick when confinementRadius was set + // in the constructor. + const maxR = this.#confinementRadius!; + + for (const node of this.#nodes) { + const x = node.x ?? 0; + const y = node.y ?? 0; + const dist = Math.hypot(x, y); + const boundary = Math.max(0, maxR * 0.98 - node.radius); + + if (dist > boundary && dist > 0) { + const scale = boundary / dist; + node.x = x * scale; + node.y = y * scale; + } + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-absorb.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-absorb.bench.ts new file mode 100644 index 00000000000..78d1728447d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-absorb.bench.ts @@ -0,0 +1,244 @@ +/* eslint-disable no-console -- committed diagnostic harness: prints the + post-absorb responsiveness timeline for streaming-hub regression hunts. */ +/** + * Streaming responsiveness of the majorization engine: how long after a warm + * `absorb()` does the layout visibly REACT? Reproduces the reported "a hub + * that forms after the fact has no pull" feel on the captured 20k fixture: + * + * 1. settle the fixture cold (the pre-stream state), + * 2. absorb one newcomer wired to `HUB_FANOUT` random placed nodes (a hub + * forming after the fact, the worst case: every spoke term changes), + * 3. tick at the app cadence and record when positions first publish, when + * the spokes' mean distance to the hub first drops by 25 % / 50 %, and + * when the layout re-settles. + * + * The absorb→first-pull window is the streaming feel: the engine re-runs + * analysis (pivot BFS, term emission, Laplacian) before any majorization + * iteration can act on the new spokes, and during that window the layout sits + * still no matter how hard the hub "should" pull. + * + * Env knobs: + * MAJORIZATION_ABSORB_FANOUT spokes on the late hub (default 200) + * + * Run (from apps/hash-frontend): + * node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer/worker/layout/majorization-absorb.bench.ts \ + * --disable-console-intercept + */ +import { readFileSync } from "node:fs"; + +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { mulberry32 } from "../../math/random"; +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { createMajorizationLayout } from "./majorization-layout"; + +import type { + ForceEdge, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; + +const HUB_FANOUT = Number(process.env.MAJORIZATION_ABSORB_FANOUT ?? 200); +/** Per-tick solver budget (ms) mirroring the app's frame share. */ +const TICK_BUDGET_MS = 8; +const SETTLE_WALL_CAP_MS = 300_000; + +interface CapturedFixtureJson { + readonly nodes: readonly { + readonly id: string; + readonly x: number; + readonly y: number; + readonly radius: number; + }[]; + readonly edges: readonly { + readonly source: string; + readonly target: string; + readonly weight: number; + }[]; +} + +/** Same cold replay as the scale bench: keep topology/radii, scramble seeds. */ +function loadFixture(): { nodes: ForceNode[]; edges: ForceEdge[] } { + const raw = readFileSync( + new URL("./fixtures/graph-fixture-20000n-22379e.json", import.meta.url), + "utf8", + ); + const fixture = JSON.parse(raw) as CapturedFixtureJson; + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + const random = mulberry32(20_000); + const nodes: ForceNode[] = fixture.nodes.map((node, index) => { + const distance = 20 * Math.sqrt(index + 1); + const angle = index * goldenAngle + random() * Math.PI * 2; + return { + id: node.id, + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + radius: node.radius, + }; + }); + return { nodes, edges: fixture.edges.map((edge) => ({ ...edge })) }; +} + +function tickUntilSettled( + layout: LayoutSimulation, + label: string, +): { wallMs: number; ticks: number } { + const start = performance.now(); + let ticks = 0; + while (!layout.isSettled && performance.now() - start < SETTLE_WALL_CAP_MS) { + layout.tick(TICK_BUDGET_MS); + ticks += 1; + } + const wallMs = performance.now() - start; + console.log( + `${label}: wallMs=${wallMs.toFixed(0)} ticks=${ticks} ` + + `settled=${layout.isSettled}`, + ); + return { wallMs, ticks }; +} + +function run(): void { + const { nodes, edges } = loadFixture(); + + console.log(`\n=== majorization-absorb fanout=${HUB_FANOUT} ===`); + const layout = createMajorizationLayout( + nodes, + edges, + new FlatGraphBuffer(nodes.length), + ); + tickUntilSettled(layout, "cold settle"); + + // Spokes: deterministic sample of placed, connected nodes. + const connected = new Set(); + for (const edge of edges) { + connected.add(edge.source as string); + connected.add(edge.target as string); + } + const random = mulberry32(1234); + const pool = nodes.filter((node) => connected.has(node.id)); + const spokes: ForceNode[] = []; + const taken = new Set(); + while (spokes.length < Math.min(HUB_FANOUT, pool.length)) { + const pick = Math.floor(random() * pool.length); + if (!taken.has(pick)) { + taken.add(pick); + spokes.push(pool[pick]!); + } + } + + // The hub seeds at the spokes' centroid (the flat tier seeds newcomers + // beside placed neighbours; the centroid is that, idealised). + let hubX = 0; + let hubY = 0; + for (const spoke of spokes) { + hubX += spoke.x ?? 0; + hubY += spoke.y ?? 0; + } + hubX /= spokes.length; + hubY /= spokes.length; + const hub: ForceNode = { id: "late-hub", x: hubX, y: hubY, radius: 12 }; + + const spokeDistance = (): number => { + let sum = 0; + for (const spoke of spokes) { + sum += Math.hypot( + (spoke.x ?? 0) - (hub.x ?? 0), + (spoke.y ?? 0) - (hub.y ?? 0), + ); + } + return sum / spokes.length; + }; + + const before = spokeDistance(); + const hubEdges: ForceEdge[] = spokes.map((spoke) => ({ + source: hub.id, + target: spoke.id, + weight: 1, + })); + layout.absorb?.([hub], [...edges, ...hubEdges]); + + // Post-absorb timeline at app cadence. While the hub solve runs, keep + // feeding unrelated dust batches at the dev harness's streaming interval + // (150 ms): the reported regression is pull DURING a stream, not after one + // isolated absorb — every absorb restarts the solver, and when restart + // latency exceeds the arrival interval no iteration ever runs. + const allEdges: ForceEdge[] = [...edges, ...hubEdges]; + const start = performance.now(); + let ticks = 0; + let firstPublishMs = -1; + let pull25Ms = -1; + let pull50Ms = -1; + let nextBatchAt = 150; + let batchesFed = 0; + const timeline: [number, number][] = []; + let nextSampleAt = 0; + while (performance.now() - start < SETTLE_WALL_CAP_MS) { + if (layout.isSettled && (pull50Ms >= 0 || batchesFed >= 40)) { + break; + } + const published = layout.tick(TICK_BUDGET_MS); + ticks += 1; + const now = performance.now() - start; + if (batchesFed < 40 && now >= nextBatchAt) { + const batch: ForceNode[] = []; + for (let index = 0; index < 50; index++) { + batch.push({ + id: `stream-${batchesFed}-${index}`, + x: 4000 + batchesFed * 10, + y: 4000 + index * 10, + radius: 6, + }); + } + layout.absorb?.(batch, allEdges); + batchesFed += 1; + nextBatchAt += 150; + } + if (published && firstPublishMs < 0) { + firstPublishMs = now; + } + if (now >= nextSampleAt) { + timeline.push([now, spokeDistance()]); + nextSampleAt += 500; + } + if (published && (pull25Ms < 0 || pull50Ms < 0)) { + const distance = spokeDistance(); + if (pull25Ms < 0 && distance < before * 0.75) { + pull25Ms = now; + } + if (pull50Ms < 0 && distance < before * 0.5) { + pull50Ms = now; + } + } + } + const wallMs = performance.now() - start; + console.log( + `absorb(+1 hub, +${spokes.length} spokes) under 150ms dust stream ` + + `(${batchesFed} batches x50): spokeDist ${before.toFixed(0)} -> ` + + `${spokeDistance().toFixed(0)}`, + ); + console.log( + ` firstPublishMs=${firstPublishMs.toFixed(0)} ` + + `pull25Ms=${pull25Ms.toFixed(0)} pull50Ms=${pull50Ms.toFixed(0)} ` + + `resettleMs=${wallMs.toFixed(0)} ticks=${ticks} ` + + `settled=${layout.isSettled}`, + ); + console.log( + ` timeline(ms:dist): ${timeline + .map(([atMs, dist]) => `${atMs.toFixed(0)}:${dist.toFixed(0)}`) + .join(" ")}`, + ); +} + +run(); + +describe("majorization absorb (smoke)", () => { + bench( + "noop (measures at module scope)", + () => { + /* The timeline above is the deliverable. */ + }, + { time: 0, iterations: 1, warmupTime: 0, warmupIterations: 0 }, + ); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-baseline.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-baseline.bench.ts new file mode 100644 index 00000000000..367cf0bc5b9 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-baseline.bench.ts @@ -0,0 +1,447 @@ +/* eslint-disable no-console -- committed metrics harness whose whole purpose is to PRINT + the majorization engine's tracked quality/perf baseline table. */ +/** + * Tracked metrics baseline for the community-tier constrained stress-majorization + * engine. Fixtures and metrics are stable so numbers stay comparable across history: + * + * wall ms construct → settled wall time + * worstTick largest single tick (ms), the jank ceiling + * overlaps strictly intersecting disk pairs (zero-overlap oracle) + * edgeStress scale-invariant RMS edge-length deviation (best-fit uniform scale) + * spreadDip terminal contract→expand rebound: how far the RMS spread dips below + * its final value after the widest point, as a fraction of final + * (0 = monotone approach, the motion gate) + * inter/intra mean cross-community edge length ÷ mean same-community edge length + * (Louvain partition computed once per fixture) + * regionOv region-overlap (the primary region-disjointness metric): fraction of + * nodes strictly inside a foreign community's packing disk (a community's + * centroid-centred disk at the shared packing utilisation). Catches the + * "bubble intrudes into another community" artifact directly, which + * inter/intra cannot see (folded branches share no edges). + * hubSpoke mean spoke length of the 150-leaf hub ÷ its one-ring packing radius + * (≈1 = leaves sit right at the tightest feasible halo) + * determ. bitwise position equality across two identical runs + * + * Fixtures are the perf-gate shapes: 1k/3k/5k hub-skewed clouds with a 150-leaf + * near-coincident hub grafted on, plus the ~1k-node TWO-hub "real shape". All are + * driven at the production 1 ms tick cadence. + * + * Run (from apps/hash-frontend): + * node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer/worker/layout/majorization-baseline.bench.ts + */ +import { UndirectedGraph } from "graphology"; +import louvain from "graphology-communities-louvain"; +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { parkMillerRng } from "../../math/random"; +import { + buildForceGraphWithCoincidentHub, + buildRealShapeFixture, +} from "../bench-fixtures"; +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { createMajorizationLayout } from "./majorization-layout"; +import { countOverlaps } from "./overlap-relax"; +import { measureRegionOverlap } from "./region-metrics"; + +import type { GraphShape } from "../bench-fixtures"; +import type { + ForceEdge, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; + +const HUB_LEAVES = 150; +/** Hub halo denominator padding (8 px); must match `OVERLAP_PADDING` in majorization-layout.ts. */ +const HALO_PADDING = 8; + +interface Fixture { + readonly name: string; + readonly nodes: ForceNode[]; + readonly edges: ForceEdge[]; + /** Whether to also do the double-run determinism check (doubles the cost). */ + readonly checkDeterminism: boolean; +} + +function cloudShape(cloudCount: number): GraphShape { + return { + nodeCount: cloudCount, + linkCount: Math.round(cloudCount * 2.6), + typeCount: 1, + hubCount: Math.max(4, Math.round(cloudCount / 40)), + rootFraction: 1, + seed: 4_000 + cloudCount, + }; +} + +function buildFixtures(): Fixture[] { + const real = buildRealShapeFixture(); + const fixtures: Fixture[] = [ + { + name: "real-2hub", + nodes: real.nodes, + edges: real.edges, + checkDeterminism: true, + }, + ]; + for (const cloudCount of [1_000, 3_000, 5_000]) { + const { nodes, edges } = buildForceGraphWithCoincidentHub( + cloudShape(cloudCount), + HUB_LEAVES, + ); + fixtures.push({ + name: `${cloudCount}+hub`, + nodes, + edges, + checkDeterminism: cloudCount === 1_000, + }); + } + return fixtures; +} + +/** Fresh node objects per run: the solver mutates x/y in place. */ +function cloneNodes(nodes: readonly ForceNode[]): ForceNode[] { + return nodes.map((node) => ({ ...node })); +} + +function makeLayout( + nodes: ForceNode[], + edges: ForceEdge[], + buffer: FlatGraphBuffer, +): LayoutSimulation { + return createMajorizationLayout(nodes, edges, buffer); +} + +const edgeEndpointId = (endpoint: ForceEdge["source"]): string => + typeof endpoint === "string" ? endpoint : endpoint.id; + +/** RMS distance of the node positions from their centroid (the layout's "size"). */ +function rmsSpread(layout: LayoutSimulation): number { + const nodes = layout.nodes; + const count = nodes.length; + let cx = 0; + let cy = 0; + for (const node of nodes) { + cx += node.x ?? 0; + cy += node.y ?? 0; + } + cx /= count; + cy /= count; + let sumSq = 0; + for (const node of nodes) { + const dx = (node.x ?? 0) - cx; + const dy = (node.y ?? 0) - cy; + sumSq += dx * dx + dy * dy; + } + return Math.sqrt(sumSq / count); +} + +interface DriveResult { + readonly wallMs: number; + readonly worstTickMs: number; + readonly spreads: readonly number[]; +} + +/** Drive to settled at the production 1 ms cadence, recording per-tick cost + spread. */ +function drive(layout: LayoutSimulation): DriveResult { + let worstTickMs = 0; + const spreads: number[] = []; + const wallStart = performance.now(); + // tick() returns publish-happened, not liveness; isSettled ends the loop. + for (let step = 0; step < 2_000_000 && !layout.isSettled; step++) { + const start = performance.now(); + layout.tick(1); + const tickMs = performance.now() - start; + if (tickMs > worstTickMs) { + worstTickMs = tickMs; + } + spreads.push(rmsSpread(layout)); + } + return { + wallMs: performance.now() - wallStart, + worstTickMs, + spreads, + }; +} + +/** Terminal rebound of a spread trace: dip below the final size after the peak, then climb. */ +function terminalReboundOf(spreads: readonly number[]): number { + let peak = 0; + let peakIndex = 0; + for (const [index, spread] of spreads.entries()) { + if (spread > peak) { + peak = spread; + peakIndex = index; + } + } + let postPeakMin = Number.POSITIVE_INFINITY; + for (let index = peakIndex; index < spreads.length; index++) { + if (spreads[index]! < postPeakMin) { + postPeakMin = spreads[index]!; + } + } + const finalSpread = spreads.length > 0 ? spreads[spreads.length - 1]! : 0; + if (!Number.isFinite(postPeakMin)) { + postPeakMin = finalSpread; + } + return finalSpread > 0 ? (finalSpread - postPeakMin) / finalSpread : 0; +} + +/** One Louvain partition per fixture (seeded → deterministic). */ +function communityPartition( + nodes: readonly ForceNode[], + edges: readonly ForceEdge[], + seed: number, +): Map { + const graph = new UndirectedGraph(); + for (const node of nodes) { + graph.addNode(node.id); + } + for (const edge of edges) { + const source = edgeEndpointId(edge.source); + const target = edgeEndpointId(edge.target); + if (source !== target && !graph.hasEdge(source, target)) { + graph.addEdge(source, target); + } + } + const assignments = louvain(graph, { rng: parkMillerRng(seed) }); + const communities = new Map(); + for (const [nodeId, community] of Object.entries(assignments)) { + communities.set(nodeId, community); + } + return communities; +} + +interface Quality { + readonly overlaps: number; + readonly edgeStress: number; + readonly interIntra: number; + readonly regionOverlap: number; + readonly hubSpokeRatio: number; +} + +function measureQuality( + layout: LayoutSimulation, + edges: readonly ForceEdge[], + communities: ReadonlyMap, +): Quality { + const nodes = layout.nodes; + const count = nodes.length; + const x = new Float32Array(count); + const y = new Float32Array(count); + const radii = new Float32Array(count); + const idToIndex = new Map(); + for (const [index, node] of nodes.entries()) { + idToIndex.set(node.id, index); + x[index] = node.x ?? 0; + y[index] = node.y ?? 0; + radii[index] = node.radius; + } + + // One length per undirected edge, for edge stress and the inter/intra ratio. + const lengths: number[] = []; + let sumSame = 0; + let sameCount = 0; + let sumCross = 0; + let crossCount = 0; + const degree = new Uint32Array(count); + const seen = new Set(); + for (const edge of edges) { + const sourceId = edgeEndpointId(edge.source); + const targetId = edgeEndpointId(edge.target); + const source = idToIndex.get(sourceId); + const target = idToIndex.get(targetId); + if (source === undefined || target === undefined || source === target) { + continue; + } + const lo = Math.min(source, target); + const hi = Math.max(source, target); + const key = `${lo}:${hi}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + // lo/hi are valid indices: edges with missing endpoints were skipped above. + degree[lo]! += 1; + degree[hi]! += 1; + const length = Math.hypot(x[hi]! - x[lo]!, y[hi]! - y[lo]!); + lengths.push(length); + if (communities.get(sourceId) === communities.get(targetId)) { + sumSame += length; + sameCount += 1; + } else { + sumCross += length; + crossCount += 1; + } + } + + // Scale-invariant edge stress: RMS relative deviation from the best-fit uniform + // length (every fixture edge is one hop, so the best-fit "ideal" is the mean). + const meanLen = + lengths.length > 0 + ? lengths.reduce((sum, length) => sum + length, 0) / lengths.length + : 0; + let sumSqDev = 0; + for (const length of lengths) { + const dev = meanLen > 0 ? length / meanLen - 1 : 0; + sumSqDev += dev * dev; + } + + // Hub halo: mean spoke length of the highest-degree node vs its one-ring packing + // radius (the tightest circle its children could sit on side by side). + let hub = 0; + for (let index = 1; index < count; index++) { + if (degree[index]! > degree[hub]!) { + hub = index; + } + } + let spokeSum = 0; + let spokeCount = 0; + let ringNeed = 0; + for (const edge of edges) { + const source = idToIndex.get(edgeEndpointId(edge.source)); + const target = idToIndex.get(edgeEndpointId(edge.target)); + if (source === undefined || target === undefined) { + continue; + } + const other = source === hub ? target : target === hub ? source : undefined; + if (other === undefined || other === hub) { + continue; + } + spokeSum += Math.hypot(x[other]! - x[hub]!, y[other]! - y[hub]!); + spokeCount += 1; + ringNeed += 2 * radii[other]! + HALO_PADDING; + } + const ringRadius = ringNeed / (2 * Math.PI); + + const regionStats = measureRegionOverlap( + nodes.map((node) => ({ + x: node.x ?? 0, + y: node.y ?? 0, + radius: node.radius, + community: communities.get(node.id) ?? -1, + })), + ); + + return { + overlaps: countOverlaps({ x, y, radii, count, padding: 0 }), + edgeStress: lengths.length > 0 ? Math.sqrt(sumSqDev / lengths.length) : 0, + interIntra: + sameCount > 0 && crossCount > 0 + ? sumCross / crossCount / (sumSame / sameCount) + : 0, + regionOverlap: regionStats.diskContainment, + hubSpokeRatio: + spokeCount > 0 && ringRadius > 0 ? spokeSum / spokeCount / ringRadius : 0, + }; +} + +/** Bitwise position equality of two settled runs over identical input. */ +function isDeterministic(nodes: readonly ForceNode[], edges: ForceEdge[]) { + const runPositions = (): Float64Array => { + const runNodes = cloneNodes(nodes); + const layout = makeLayout( + runNodes, + edges, + new FlatGraphBuffer(runNodes.length), + ); + drive(layout); + const positions = new Float64Array(runNodes.length * 2); + for (const [index, node] of layout.nodes.entries()) { + positions[index * 2] = node.x ?? 0; + positions[index * 2 + 1] = node.y ?? 0; + } + return positions; + }; + const first = runPositions(); + const second = runPositions(); + return first.every((value, index) => value === second[index]); +} + +function pad(text: string, width: number, alignRight = true): string { + return alignRight ? text.padStart(width) : text.padEnd(width); +} + +function baselineReport(fixture: Fixture): string { + const { nodes, edges } = fixture; + const communities = communityPartition(nodes, edges, 1); + const columns = [13, 9, 10, 9, 11, 10, 12, 9, 9, 8]; + const row = (cells: readonly string[]): string => + cells + .map((cell, index) => pad(cell, columns[index]!, index !== 0)) + .join(" "); + + const lines: string[] = []; + lines.push( + `\n=== baseline ${fixture.name}: ${nodes.length} nodes / ${edges.length} edges ===`, + ); + const header = row([ + "engine", + "wall ms", + "worstTick", + "overlaps", + "edgeStress", + "spreadDip", + "inter/intra", + "regionOv", + "hubSpoke", + "determ.", + ]); + lines.push(header); + lines.push("-".repeat(header.length)); + + const runNodes = cloneNodes(nodes); + const layout = makeLayout( + runNodes, + edges, + new FlatGraphBuffer(runNodes.length), + ); + const result = drive(layout); + const quality = measureQuality(layout, edges, communities); + const deterministic = fixture.checkDeterminism + ? isDeterministic(nodes, edges) + ? "yes" + : "NO" + : "-"; + lines.push( + row([ + "majorization", + result.wallMs.toFixed(0), + result.worstTickMs.toFixed(1), + String(quality.overlaps), + quality.edgeStress.toFixed(3), + terminalReboundOf(result.spreads).toFixed(3), + quality.interIntra.toFixed(2), + quality.regionOverlap.toFixed(3), + quality.hubSpokeRatio.toFixed(2), + deterministic, + ]), + ); + + return lines.join("\n"); +} + +for (const fixture of buildFixtures()) { + console.log(baselineReport(fixture)); +} + +/** Statistical timing cross-check on the real shape (the primary fixture). */ +const BENCH_OPTIONS = { + time: 0, + iterations: 3, + warmupTime: 0, + warmupIterations: 1, +} as const; + +describe("community layout solve (real two-hub shape)", () => { + const { nodes, edges } = buildRealShapeFixture(); + bench( + "majorization", + () => { + const runNodes = cloneNodes(nodes); + drive(makeLayout(runNodes, edges, new FlatGraphBuffer(runNodes.length))); + }, + BENCH_OPTIONS, + ); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-config.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-config.ts new file mode 100644 index 00000000000..9835b8bdfe3 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-config.ts @@ -0,0 +1,98 @@ +/** + * Tuning for the community-force flat tier's stress-majorization engine + * ({@link "./majorization-layout"}): persistent-CG solves with + * circle-relaxation projection and a verified-clean terminal settle. + * + * Kept separate from the engine so the main thread (root `config.ts`, the dev + * harness) can import the defaults without pulling the engine's dependency + * chain (graphology, Louvain) into the page bundle. + * + * The three shaping weights shape stress targets (community-scaled edge + * lengths, hub halo bands, and the community-region floor margin), not force + * terms; set a weight to 0 to disable that shaping. + */ +export interface MajorizationConfig { + /** + * Target graph-edge length in layout space (px per hop). + * + * @defaultValue 60. Lower values tighten the layout but raise packing + * infeasibility risk on dense hubs; higher values spread components and + * increase settle work. + */ + readonly idealEdgeLength: number; + /** + * Extra gap between node disks: collision floors and the projector's pair gap. + * + * @defaultValue 8. Raising it airs the layout out at the cost of a larger + * settled footprint. + */ + readonly overlapPadding: number; + /** + * Same-community attraction: deflate same-community stress targets ÷(1 + 2·w). + * + * @defaultValue 0.02. + */ + readonly communityCohesion: number; + /** + * Cross-community separation: inflate cross-community stress targets ×(1 + 2·w) + * and widen the community-region floor margin (the keep-out disk every non-member + * is held outside) by w·idealEdgeLength. + * + * @defaultValue 0.08. + */ + readonly communitySeparation: number; + /** + * Hub breathing room: how far a packed hub's children are pushed from its rim + * toward an explicit halo shell (band floor share min(1, 2·w)). + * + * @defaultValue 0.02. + */ + readonly degreeRepulsion: number; + /** + * Hard majorization-iteration cap (logs "capped" and settles when reached). + * + * @defaultValue 150. + */ + readonly maxIterations: number; + /** + * Converged when max per-iteration displacement < ε · idealEdgeLength for + * {@link MajorizationConfig.convergenceStreak} iterations. + * + * @defaultValue 0.008. + */ + readonly convergenceEpsilon: number; + /** + * Consecutive converged iterations required before the terminal settle runs. + * + * @defaultValue 2. + */ + readonly convergenceStreak: number; + /** + * Conjugate-gradient steps allowed per majorization iteration. + * + * @defaultValue 120. Under-solving is not a saving: leftover disequilibrium + * re-appears every iteration as sustained displacement. + */ + readonly cgStepsPerIteration: number; + /** + * Pivot budget cap (stress terms ≈ pivots·nodes); the engine clamps it to + * the node count. + * + * @defaultValue 64. More pivots hold the global shape better at linear + * per-iteration cost. + */ + readonly pivotCount: number; +} + +export const defaultMajorizationConfig: MajorizationConfig = { + idealEdgeLength: 60, + overlapPadding: 8, + communityCohesion: 0.02, + communitySeparation: 0.08, + degreeRepulsion: 0.02, + maxIterations: 150, + convergenceEpsilon: 8e-3, + convergenceStreak: 2, + cgStepsPerIteration: 120, + pivotCount: 64, +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-layout.perf.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-layout.perf.test.ts new file mode 100644 index 00000000000..4d311b6477b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-layout.perf.test.ts @@ -0,0 +1,282 @@ +/** + * Perf + motion gates for the constrained stress-majorization engine (the community- + * tier engine), including the primary real-shape gate: + * + * 1. Per-tick budget: at 1k/3k/5k (cloud + 150-leaf coincident hub), at default and + * elevated shaping weights, no single 1 ms-budget tick blows past 100 ms, the + * layout settles, and the settled result is strictly overlap-free. + * 2. Primary: the real graph shape (~1000 nodes, two ~150-leaf near-coincident hubs + * + sparse background) reaches settled in ≤ 2 s wall time, overlap-free. + * 3. No contract→expand: the RMS-spread trajectory shows no significant + * dip-below-final-then-rebound after its widest point (terminal-rebound metric). + * 4. Region disjointness: on the real shape, majorization's region-overlap (the + * fraction of nodes strictly inside a foreign community's packing disk) must + * stay under an absolute ceiling of 0.08, well below the 0.32 baseline measured + * before the community-region floors existed. + */ +import { describe, expect, it } from "vitest"; + +import { + buildForceGraphWithCoincidentHub, + buildRealShapeFixture, +} from "../bench-fixtures"; +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { createMajorizationLayout } from "./majorization-layout"; +import { countOverlaps } from "./overlap-relax"; +import { measureRegionOverlap } from "./region-metrics"; + +import type { GraphShape } from "../bench-fixtures"; +import type { LayoutSimulation } from "./force-simulation"; +import type { MajorizationLayoutOptions } from "./majorization-layout"; + +/** Largest single tick must stay well below one frame; rectangle VPSC once reached ~6.5 s per tick. */ +const MAX_TICK_MS = 100; +/** Primary gate: wall-to-settled budget (2000 ms) for the real two-hub shape. */ +const REAL_SHAPE_WALL_MS = 2_000; +/** + * Region-overlap ceiling (0.08) on the real shape at default weights. The + * community-region floors keep this metric around ~0.03 versus a 0.32 baseline + * without them. Slack above ~0.03 covers the small violations the bridge-endpoint + * exemption legitimately allows, while a value approaching 0.32 indicates the + * floors have regressed toward the folding regime. + */ +const REAL_SHAPE_REGION_OVERLAP_MAX = 0.08; +const HUB_LEAVES = 150; + +/** Weight sets exercised on every fixture: gentle defaults and a clearly elevated set. */ +const WEIGHT_CONFIGS: readonly { + readonly label: string; + readonly options: MajorizationLayoutOptions | undefined; +}[] = [ + { label: "default", options: undefined }, + { + label: "elevated", + options: { + communityCohesion: 0.15, + communitySeparation: 0.4, + degreeRepulsion: 0.12, + }, + }, +]; + +function overlapCountOf(layout: LayoutSimulation): number { + const count = layout.nodes.length; + const x = new Float32Array(count); + const y = new Float32Array(count); + const radii = new Float32Array(count); + for (const [index, node] of layout.nodes.entries()) { + x[index] = node.x ?? 0; + y[index] = node.y ?? 0; + radii[index] = node.radius; + } + return countOverlaps({ x, y, radii, count, padding: 0 }); +} + +/** + * Region-overlap (disk containment) over the engine's OWN Louvain labels. The + * partition the rendered community bubbles come from, i.e. what the user sees. + */ +function regionOverlapOf(layout: LayoutSimulation): number { + // communities is on the concrete layout implementation, not LayoutSimulation. + const communities = (layout as unknown as { communities?: readonly number[] }) + .communities; + return measureRegionOverlap( + layout.nodes.map((node, index) => ({ + x: node.x ?? 0, + y: node.y ?? 0, + radius: node.radius, + community: communities?.[index] ?? -1, + })), + ).diskContainment; +} + +interface DriveResult { + readonly ticks: number; + readonly totalMs: number; + readonly maxTickMs: number; + /** RMS spread sampled after every tick (for the rebound metric). */ + readonly spreads: readonly number[]; +} + +/** RMS distance of the node positions from their centroid (the layout's "size"). */ +function rmsSpread(layout: LayoutSimulation): number { + const nodes = layout.nodes; + const count = nodes.length; + if (count === 0) { + return 0; + } + let cx = 0; + let cy = 0; + for (const node of nodes) { + cx += node.x ?? 0; + cy += node.y ?? 0; + } + cx /= count; + cy /= count; + let sumSq = 0; + for (const node of nodes) { + const dx = (node.x ?? 0) - cx; + const dy = (node.y ?? 0) - cy; + sumSq += dx * dx + dy * dy; + } + return Math.sqrt(sumSq / count); +} + +/** Drive at the production 1 ms cadence, recording per-tick cost and the spread trace. */ +function drive(layout: LayoutSimulation): DriveResult { + let maxTickMs = 0; + let totalMs = 0; + let ticks = 0; + const spreads: number[] = []; + // tick() returns publish-happened, not liveness; isSettled ends the loop. + for (let step = 0; step < 200_000 && !layout.isSettled; step++) { + const start = performance.now(); + layout.tick(1); + const tickMs = performance.now() - start; + totalMs += tickMs; + maxTickMs = Math.max(maxTickMs, tickMs); + ticks += 1; + spreads.push(rmsSpread(layout)); + } + return { ticks, totalMs, maxTickMs, spreads }; +} + +/** Terminal rebound of a spread trace: dip below the final size after the peak, then climb. */ +function terminalReboundOf(spreads: readonly number[]): number { + let peak = 0; + let peakIndex = 0; + for (const [index, spread] of spreads.entries()) { + if (spread > peak) { + peak = spread; + peakIndex = index; + } + } + let postPeakMin = Number.POSITIVE_INFINITY; + for (let index = peakIndex; index < spreads.length; index++) { + if (spreads[index]! < postPeakMin) { + postPeakMin = spreads[index]!; + } + } + const finalSpread = spreads.length > 0 ? spreads[spreads.length - 1]! : 0; + if (!Number.isFinite(postPeakMin)) { + postPeakMin = finalSpread; + } + return finalSpread > 0 ? (finalSpread - postPeakMin) / finalSpread : 0; +} + +const CLOUD_SIZES = [1_000, 3_000, 5_000] as const; + +const GATE_CASES = CLOUD_SIZES.flatMap((cloudCount) => + WEIGHT_CONFIGS.map((config) => ({ cloudCount, config })), +); + +describe("majorization layout, coincident-hub perf gate", () => { + it.each(GATE_CASES)( + "stays within the tick budget and ends overlap-free ($cloudCount-node cloud + coincident hub, $config.label weights)", + ({ cloudCount, config }) => { + const shape: GraphShape = { + nodeCount: cloudCount, + linkCount: Math.round(cloudCount * 2.6), + typeCount: 1, + hubCount: Math.max(4, Math.round(cloudCount / 40)), + rootFraction: 1, + seed: 4_000 + cloudCount, + }; + const { nodes, edges } = buildForceGraphWithCoincidentHub( + shape, + HUB_LEAVES, + ); + const layout = createMajorizationLayout( + nodes, + edges, + new FlatGraphBuffer(nodes.length), + config.options, + ); + + const result = drive(layout); + const diag = layout as unknown as { + iterations?: number; + capped?: boolean; + }; + + // eslint-disable-next-line no-console + console.log( + `[majorization] n=${nodes.length} edges=${edges.length} ` + + `weights=${config.label} ticks=${result.ticks} ` + + `iterations=${diag.iterations} capped=${diag.capped} ` + + `totalMs=${result.totalMs.toFixed(1)} ` + + `maxTickMs=${result.maxTickMs.toFixed(2)} ` + + `rebound=${terminalReboundOf(result.spreads).toFixed(3)}`, + ); + + expect(layout.isSettled).toBe(true); + expect(result.maxTickMs).toBeLessThan(MAX_TICK_MS); + expect(overlapCountOf(layout)).toBe(0); + // The motion gate holds at every size, not only on the real shape. + expect(terminalReboundOf(result.spreads)).toBeLessThan(0.08); + }, + 120_000, + ); +}); + +describe("majorization layout, real two-hub shape (primary gate)", () => { + it.each(WEIGHT_CONFIGS.map((config) => ({ config })))( + "settles the ~1k node / two 150-leaf-hub shape in ≤ 2 s, overlap-free, no contract→expand ($config.label weights)", + ({ config }) => { + const { nodes, edges } = buildRealShapeFixture(); + const layout = createMajorizationLayout( + nodes, + edges, + new FlatGraphBuffer(nodes.length), + config.options, + ); + + const result = drive(layout); + const rebound = terminalReboundOf(result.spreads); + const diag = layout as unknown as { + iterations?: number; + capped?: boolean; + }; + + // eslint-disable-next-line no-console + console.log( + `[majorization-real-shape] n=${nodes.length} edges=${edges.length} ` + + `weights=${config.label} ticks=${result.ticks} ` + + `iterations=${diag.iterations} capped=${diag.capped} ` + + `wallMs=${result.totalMs.toFixed(1)} ` + + `maxTickMs=${result.maxTickMs.toFixed(2)} rebound=${rebound.toFixed(3)}`, + ); + + expect(layout.isSettled).toBe(true); + expect(result.totalMs).toBeLessThan(REAL_SHAPE_WALL_MS); + expect(result.maxTickMs).toBeLessThan(MAX_TICK_MS); + expect(overlapCountOf(layout)).toBe(0); + expect(rebound).toBeLessThan(0.08); + }, + 60_000, + ); +}); + +describe("majorization layout, region disjointness gate", () => { + it("keeps community regions disjoint on the real shape (below the folding baseline)", () => { + const { nodes, edges } = buildRealShapeFixture(); + const layout = createMajorizationLayout( + nodes, + edges, + new FlatGraphBuffer(nodes.length), + ); + drive(layout); + + const regionOverlap = regionOverlapOf(layout); + + // eslint-disable-next-line no-console + console.log( + `[majorization-region] real-shape regionOverlap=${regionOverlap.toFixed(4)} ` + + `(pre-fix baseline: 0.32, ceiling: ${REAL_SHAPE_REGION_OVERLAP_MAX})`, + ); + + expect(layout.isSettled).toBe(true); + expect(overlapCountOf(layout)).toBe(0); + expect(regionOverlap).toBeLessThan(REAL_SHAPE_REGION_OVERLAP_MAX); + }, 60_000); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-layout.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-layout.test.ts new file mode 100644 index 00000000000..4b3e7a458e1 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-layout.test.ts @@ -0,0 +1,670 @@ +import { describe, expect, it } from "vitest"; +import { Layout } from "webcola"; + +import { buildForceGraphWithCoincidentHub } from "../bench-fixtures"; +import { + FLAT_HEADER_BYTES, + FLAT_RECORD_BYTES, + FlatGraphBuffer, +} from "../buffers/position-buffer"; +import { createMajorizationLayout } from "./majorization-layout"; +import { countOverlaps } from "./overlap-relax"; + +import type { GraphShape } from "../bench-fixtures"; +import type { + ForceEdge, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; +import type { InputNode, Link, Node as ColaNode } from "webcola"; + +function settle(layout: LayoutSimulation): void { + for (let step = 0; step < 4000 && !layout.isSettled; step++) { + layout.tick(50); + } +} + +function positionsOf(layout: LayoutSimulation): [number, number][] { + return layout.nodes.map((node) => [node.x ?? 0, node.y ?? 0]); +} + +/** Count disk-overlapping pairs in a settled layout (centres closer than r_i + r_j + padding). */ +function overlapCountOf(layout: LayoutSimulation, padding = 0): number { + const count = layout.nodes.length; + const x = new Float32Array(count); + const y = new Float32Array(count); + const radii = new Float32Array(count); + for (const [index, node] of layout.nodes.entries()) { + x[index] = node.x ?? 0; + y[index] = node.y ?? 0; + radii[index] = node.radius; + } + return countOverlaps({ x, y, radii, count, padding }); +} + +function makeNodes(count: number): ForceNode[] { + const nodes: ForceNode[] = []; + for (let idx = 0; idx < count; idx++) { + nodes.push({ id: String(idx), x: idx * 7, y: (idx % 3) * 5, radius: 4 }); + } + return nodes; +} + +function layoutOf(nodes: ForceNode[], edges: ForceEdge[]): LayoutSimulation { + return createMajorizationLayout( + nodes, + edges, + new FlatGraphBuffer(nodes.length), + ); +} + +function distanceBetween( + positions: readonly [number, number][], + lhs: number, + rhs: number, +): number { + return Math.hypot( + positions[lhs]![0] - positions[rhs]![0], + positions[lhs]![1] - positions[rhs]![1], + ); +} + +/** Normalised edge stress: mean over edges of ((len − ideal) / ideal)². */ +function edgeStressOf( + layout: LayoutSimulation, + edges: readonly ForceEdge[], + ideal: number, +): number { + const indexOfId = new Map(); + for (const [index, node] of layout.nodes.entries()) { + indexOfId.set(node.id, index); + } + const positions = positionsOf(layout); + let sum = 0; + let count = 0; + for (const edge of edges) { + const source = indexOfId.get( + typeof edge.source === "string" ? edge.source : edge.source.id, + ); + const target = indexOfId.get( + typeof edge.target === "string" ? edge.target : edge.target.id, + ); + if (source === undefined || target === undefined || source === target) { + continue; + } + const len = distanceBetween(positions, source, target); + const rel = (len - ideal) / ideal; + sum += rel * rel; + count += 1; + } + return count > 0 ? sum / count : 0; +} + +describe("createMajorizationLayout", () => { + it("settles, stays finite, and re-centres on the origin", () => { + const nodes = makeNodes(8); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + expect(layout.isSettled).toBe(true); + const positions = positionsOf(layout); + let sumX = 0; + let sumY = 0; + for (const [posX, posY] of positions) { + expect(Number.isFinite(posX)).toBe(true); + expect(Number.isFinite(posY)).toBe(true); + sumX += posX; + sumY += posY; + } + expect(Math.abs(sumX / positions.length)).toBeLessThan(1); + expect(Math.abs(sumY / positions.length)).toBeLessThan(1); + }); + + it("separates all disks (overlap projection) rather than piling them", () => { + const nodes = makeNodes(12); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + { source: "4", target: "5", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + expect(overlapCountOf(layout, 0)).toBe(0); + }); + + it("pulls a linked pair closer than the graph's overall spread", () => { + const nodes = makeNodes(5); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + { source: "3", target: "4", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + const positions = positionsOf(layout); + expect(distanceBetween(positions, 0, 1)).toBeLessThan( + distanceBetween(positions, 0, 4), + ); + }); + + it("detects Louvain communities (two cliques joined by a bridge → 2)", () => { + const nodes = makeNodes(6); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "0", target: "2", weight: 1 }, + { source: "3", target: "4", weight: 1 }, + { source: "4", target: "5", weight: 1 }, + { source: "3", target: "5", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + const communities = layout.communities!; + expect(communities).toHaveLength(6); + expect(new Set(communities).size).toBe(2); + expect(communities[0]).toBe(communities[1]); + expect(communities[0]).toBe(communities[2]); + expect(communities[3]).toBe(communities[4]); + expect(communities[3]).toBe(communities[5]); + expect(communities[0]).not.toBe(communities[3]); + }); + + it("is deterministic for identical inputs (no stochastic sampling)", () => { + const shape: GraphShape = { + nodeCount: 300, + linkCount: 500, + typeCount: 1, + hubCount: 4, + rootFraction: 1, + seed: 11, + }; + const { nodes, edges } = buildForceGraphWithCoincidentHub(shape, 40); + const first = createMajorizationLayout( + nodes.map((node) => ({ ...node })), + [...edges], + new FlatGraphBuffer(nodes.length), + ); + const second = createMajorizationLayout( + nodes.map((node) => ({ ...node })), + [...edges], + new FlatGraphBuffer(nodes.length), + ); + settle(first); + settle(second); + + const firstPositions = positionsOf(first); + const secondPositions = positionsOf(second); + for (let idx = 0; idx < firstPositions.length; idx++) { + expect(firstPositions[idx]![0]).toBe(secondPositions[idx]![0]); + expect(firstPositions[idx]![1]).toBe(secondPositions[idx]![1]); + } + }); + + it("warm-absorbs a new node in place (incremental, no restart)", () => { + const nodes = makeNodes(5); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "3", target: "4", weight: 1 }, + ]; + const buffer = new FlatGraphBuffer(8, () => {}); + const layout = createMajorizationLayout(nodes, edges, buffer); + settle(layout); + expect(layout.nodes.length).toBe(5); + + // Place the newcomer near an existing node to mimic incremental graph growth (warm absorb). + const anchor = layout.nodes.find((node) => node.id === "0")!; + const newNode: ForceNode = { + id: "5", + x: (anchor.x ?? 0) + 10, + y: (anchor.y ?? 0) + 10, + radius: 4, + }; + layout.absorb!( + [newNode], + [...edges, { source: "0", target: "5", weight: 1 }], + ); + expect(layout.isSettled).toBe(false); + + settle(layout); + expect(layout.nodes.length).toBe(6); + expect(layout.communities!).toHaveLength(6); + expect(overlapCountOf(layout, 0)).toBe(0); + + const positions = positionsOf(layout); + for (const [posX, posY] of positions) { + expect(Number.isFinite(posX)).toBe(true); + expect(Number.isFinite(posY)).toBe(true); + } + const indexOfId = (id: string): number => layout.nodeIds.indexOf(id); + expect( + distanceBetween(positions, indexOfId("5"), indexOfId("0")), + ).toBeLessThan(distanceBetween(positions, indexOfId("5"), indexOfId("3"))); + }); + + it("keeps settled nodes near their positions across a warm absorb", () => { + const shape: GraphShape = { + nodeCount: 200, + linkCount: 320, + typeCount: 1, + hubCount: 4, + rootFraction: 1, + seed: 21, + }; + const { nodes, edges } = buildForceGraphWithCoincidentHub(shape, 30); + const buffer = new FlatGraphBuffer(nodes.length + 8, () => {}); + const layout = createMajorizationLayout(nodes, edges, buffer); + settle(layout); + + const before = new Map( + layout.nodes.map((node) => [node.id, [node.x ?? 0, node.y ?? 0]]), + ); + const anchor = layout.nodes.find((node) => node.id === "0")!; + layout.absorb!( + [ + { + id: "new-a", + x: (anchor.x ?? 0) + 8, + y: (anchor.y ?? 0) + 8, + radius: 4, + }, + ], + [...edges, { source: "0", target: "new-a", weight: 1 }], + ); + settle(layout); + + // Warm start: the bulk of the layout must not teleport. Allow local motion + // (the absorb re-solves globally) but demand a small median displacement. + const displacements: number[] = []; + for (const node of layout.nodes) { + const previous = before.get(node.id); + if (previous) { + displacements.push( + Math.hypot((node.x ?? 0) - previous[0], (node.y ?? 0) - previous[1]), + ); + } + } + displacements.sort((a, b) => a - b); + const median = displacements[Math.floor(displacements.length / 2)]!; + expect(median).toBeLessThan(60); + expect(overlapCountOf(layout, 0)).toBe(0); + }); + + it("grows the SAB without losing state (ensureCapacity + absorb)", () => { + const nodes = makeNodes(4); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const buffer = new FlatGraphBuffer(4, () => {}); + const layout = createMajorizationLayout(nodes, edges, buffer); + settle(layout); + + buffer.ensureCapacity(8); + layout.absorb!( + [{ id: "4", x: 3, y: 3, radius: 4 }], + [...edges, { source: "0", target: "4", weight: 1 }], + ); + settle(layout); + + expect(layout.nodes.length).toBe(5); + const view = new Float32Array(buffer.raw, FLAT_HEADER_BYTES); + const slots = FLAT_RECORD_BYTES / 4; + const node4 = layout.nodes[4]!; + expect(view[4 * slots]).toBeCloseTo(node4.x ?? 0, 3); + expect(view[4 * slots + 1]).toBeCloseTo(node4.y ?? 0, 3); + }); + + it("settles a dense hub (1 + 120 coincident leaves) with zero disk overlaps", () => { + const count = 121; + const nodes: ForceNode[] = []; + // All nodes start essentially coincident (the production pathology). + for (let idx = 0; idx < count; idx++) { + nodes.push({ + id: String(idx), + x: (idx % 5) * 0.25, + y: (idx % 7) * 0.25, + radius: 4, + }); + } + const edges: ForceEdge[] = []; + for (let leaf = 1; leaf < count; leaf++) { + edges.push({ source: "0", target: String(leaf), weight: 1 }); + } + const layout = layoutOf(nodes, edges); + settle(layout); + + expect(layout.isSettled).toBe(true); + expect(overlapCountOf(layout, 0)).toBe(0); + }); + + // Densest single hub the suite gates (1 hub + 250 leaves). + it("settles a very dense hub (1 + 250 leaves) overlap-free", () => { + const count = 251; + const nodes = makeNodes(count); + const edges: ForceEdge[] = []; + for (let leaf = 1; leaf < count; leaf++) { + edges.push({ source: "0", target: String(leaf), weight: 1 }); + } + const layout = layoutOf(nodes, edges); + settle(layout); + + expect(layout.isSettled).toBe(true); + expect(overlapCountOf(layout, 0)).toBe(0); + }); + + // Overlap resolution must re-run after a warm absorb, not only on the cold start. + it("also removes overlaps after a bulk warm absorb (50 coincident newcomers)", () => { + const nodes = makeNodes(40); + const edges: ForceEdge[] = []; + for (let leaf = 1; leaf < 40; leaf++) { + edges.push({ source: "0", target: String(leaf), weight: 1 }); + } + const layout = layoutOf(nodes, edges); + settle(layout); + expect(overlapCountOf(layout, 0)).toBe(0); + + const newNodes: ForceNode[] = []; + const grownEdges: ForceEdge[] = [...edges]; + for (let id = 40; id < 90; id++) { + newNodes.push({ id: String(id), x: 0, y: 0, radius: 4 }); + grownEdges.push({ source: "0", target: String(id), weight: 1 }); + } + layout.absorb!(newNodes, grownEdges); + settle(layout); + + expect(layout.isSettled).toBe(true); + expect(layout.nodes.length).toBe(90); + expect(overlapCountOf(layout, 0)).toBe(0); + }); + + // Louvain labels must refresh once growth is significant, so the bubbles reflect the grown topology. + it("re-globalises (refreshes Louvain communities) after significant growth", () => { + const nodes = makeNodes(6); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "3", target: "4", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + const newNodes: ForceNode[] = []; + const grownEdges: ForceEdge[] = [...edges]; + for (let id = 6; id < 36; id++) { + newNodes.push({ id: String(id), x: 0, y: 0, radius: 4 }); + grownEdges.push({ + source: String(id), + target: String(id % 6), + weight: 1, + }); + } + layout.absorb!(newNodes, grownEdges); + settle(layout); + + expect(layout.nodes.length).toBe(36); + const communities = layout.communities!; + expect(communities).toHaveLength(36); + expect(communities.every((community) => community >= 0)).toBe(true); + }); + + /** + * Regression: a settled-but-jammed 150-leaf hub must terminate under the + * iteration cap with zero overlaps and without a capped exit. + */ + it("livelock regression: a settled-but-jammed 150-leaf hub terminates in bounded iterations", () => { + const shape: GraphShape = { + nodeCount: 850, + linkCount: 1_300, + typeCount: 1, + hubCount: 6, + rootFraction: 1, + seed: 31, + }; + const { nodes, edges } = buildForceGraphWithCoincidentHub(shape, 150); + const layout = layoutOf(nodes, edges); + + let ticks = 0; + for (let step = 0; step < 20_000 && !layout.isSettled; step++) { + layout.tick(1); + ticks += 1; + } + + // Duck-type layout diagnostics not on LayoutSimulation; createMajorizationLayout exposes these in tests. + const diag = layout as unknown as { + capped?: boolean; + iterations?: number; + }; + // eslint-disable-next-line no-console + console.log( + `[livelock-regression] ticks=${ticks} iterations=${diag.iterations} capped=${diag.capped}`, + ); + + expect(layout.isSettled).toBe(true); + expect(diag.capped).toBe(false); + expect(overlapCountOf(layout, 0)).toBe(0); + }); + + it("publishes only overlap-free frames once projection is active", () => { + const shape: GraphShape = { + nodeCount: 400, + linkCount: 650, + typeCount: 1, + hubCount: 4, + rootFraction: 1, + seed: 41, + }; + const { nodes, edges } = buildForceGraphWithCoincidentHub(shape, 80); + const layout = layoutOf(nodes, edges); + + let checkedFrames = 0; + for (let step = 0; step < 20_000 && !layout.isSettled; step++) { + layout.tick(1); + const projection = layout as unknown as { projectionActive?: boolean }; + if (projection.projectionActive) { + // Assert overlap-freedom on the same node coordinates written to the + // shared buffer each publish. + expect(overlapCountOf(layout, 0)).toBe(0); + checkedFrames += 1; + } + } + + expect(layout.isSettled).toBe(true); + expect(checkedFrames).toBeGreaterThan(0); + }); + + it("keeps the community sliders meaningful (separation spreads cross-community pairs)", () => { + const shape: GraphShape = { + nodeCount: 220, + linkCount: 380, + typeCount: 1, + hubCount: 4, + rootFraction: 1, + seed: 51, + }; + const { nodes, edges } = buildForceGraphWithCoincidentHub(shape, 20); + + const makeLayout = (separation: number) => { + const layout = createMajorizationLayout( + nodes.map((node) => ({ ...node })), + [...edges], + new FlatGraphBuffer(nodes.length), + { communitySeparation: separation }, + ); + settle(layout); + return layout; + }; + + const gentle = makeLayout(0.02); + const strong = makeLayout(0.8); + + /** Mean distance over cross-community linked pairs. */ + const crossCommunityMeanEdgeLength = (layout: LayoutSimulation) => { + const communities = layout.communities!; + const indexOfId = new Map(); + for (const [index, node] of layout.nodes.entries()) { + indexOfId.set(node.id, index); + } + const positions = positionsOf(layout); + let sum = 0; + let count = 0; + for (const edge of edges) { + const source = indexOfId.get(edge.source as string)!; + const target = indexOfId.get(edge.target as string)!; + if (communities[source] !== communities[target]) { + sum += distanceBetween(positions, source, target); + count += 1; + } + } + return count > 0 ? sum / count : 0; + }; + + expect(crossCommunityMeanEdgeLength(strong)).toBeGreaterThan( + crossCommunityMeanEdgeLength(gentle) * 1.15, + ); + }); + + // The degreeRepulsion slider must widen a high-degree hub's halo under majorization's target shaping. + it("degree repulsion widens a high-degree hub's halo", () => { + const count = 61; + const nodes = makeNodes(count); + const edges: ForceEdge[] = []; + for (let leaf = 1; leaf < count; leaf++) { + edges.push({ source: "0", target: String(leaf), weight: 1 }); + } + + const meanHubDistance = (layout: LayoutSimulation): number => { + const positions = positionsOf(layout); + const hub = layout.nodeIds.indexOf("0"); + let sum = 0; + let sampled = 0; + for (let leaf = 1; leaf < count; leaf++) { + const index = layout.nodeIds.indexOf(String(leaf)); + sum += distanceBetween(positions, hub, index); + sampled += 1; + } + return sum / sampled; + }; + + const makeLayout = (degreeRepulsion: number) => { + const layout = createMajorizationLayout( + nodes.map((node) => ({ ...node })), + [...edges], + new FlatGraphBuffer(nodes.length), + { communityCohesion: 0, communitySeparation: 0, degreeRepulsion }, + ); + settle(layout); + return layout; + }; + + const off = makeLayout(0); + const on = makeLayout(0.25); + + expect(overlapCountOf(on, 0)).toBe(0); + expect(meanHubDistance(on)).toBeGreaterThan(meanHubDistance(off) * 1.1); + }); + + it("matches the WebCola oracle at N ≤ 150 (comparable stress, zero overlaps)", () => { + const shape: GraphShape = { + nodeCount: 120, + linkCount: 200, + typeCount: 1, + hubCount: 3, + rootFraction: 1, + seed: 61, + }; + const { nodes, edges } = buildForceGraphWithCoincidentHub(shape, 20); + expect(nodes.length).toBeLessThanOrEqual(150); + + const IDEAL = 60; + const layout = createMajorizationLayout( + nodes.map((node) => ({ ...node })), + [...edges], + new FlatGraphBuffer(nodes.length), + // Pure-stress comparison: no community/degree shaping on either side. + { communityCohesion: 0, communitySeparation: 0, degreeRepulsion: 0 }, + ); + settle(layout); + expect(overlapCountOf(layout, 0)).toBe(0); + + // Oracle baseline: WebCola stress majorization with rectangle overlap removal. + const indexOfId = new Map( + nodes.map((node, index) => [node.id, index]), + ); + const colaNodes: InputNode[] = nodes.map((node) => ({ + x: node.x ?? 0, + y: node.y ?? 0, + width: node.radius * 2, + height: node.radius * 2, + })); + const colaLinks: Link[] = []; + for (const edge of edges) { + const source = indexOfId.get(edge.source as string)!; + const target = indexOfId.get(edge.target as string)!; + if (source !== target) { + colaLinks.push({ source, target, length: IDEAL }); + } + } + const cola = new (class extends Layout { + runStep(): boolean { + return this.tick(); + } + })(); + cola + .nodes(colaNodes) + .links(colaLinks) + .linkDistance((link: Link) => link.length ?? IDEAL) + .avoidOverlaps(true) + .handleDisconnected(true) + .start(0, 0, 0, 0, false); + for (let step = 0; step < 2000; step++) { + if (cola.runStep()) { + break; + } + } + + const colaAsLayout: LayoutSimulation = { + nodes: nodes.map((node, index) => ({ + ...node, + x: colaNodes[index]!.x, + y: colaNodes[index]!.y, + })), + } as unknown as LayoutSimulation; + + const ourStress = edgeStressOf(layout, edges, IDEAL); + const colaStress = edgeStressOf(colaAsLayout, edges, IDEAL); + // eslint-disable-next-line no-console + console.log( + `[webcola-oracle] n=${nodes.length} ourStress=${ourStress.toFixed(4)} ` + + `colaStress=${colaStress.toFixed(4)}`, + ); + + // Comparable quality: within 3x the dense-matrix oracle's stress plus a 0.05 + // floor (the sparse model + halo floors trade a bounded amount of edge fidelity). + expect(ourStress).toBeLessThan(Math.max(0.25, colaStress * 3 + 0.05)); + }); + + it("handles an empty graph and pause/resume", () => { + const layout = createMajorizationLayout([], [], new FlatGraphBuffer(1)); + expect(layout.isSettled).toBe(true); + expect(layout.tick(1)).toBe(false); + + const nodes = makeNodes(3); + const active = layoutOf(nodes, [{ source: "0", target: "1", weight: 1 }]); + active.pause(); + expect(active.tick(1)).toBe(false); + active.resume(); + settle(active); + expect(active.isSettled).toBe(true); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-layout.ts new file mode 100644 index 00000000000..db84f6327c3 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-layout.ts @@ -0,0 +1,3584 @@ +/** + * Constrained stress-majorization layout engine for the community tier. + * Implements {@link LayoutSimulation} and fills a shared + * {@link FlatGraphBuffer}. + * + * Architecture (IPSep-CoLa style: majorize, then project): + * + * 1. Sparse quadratic stress over graph edges + subsampled pivot terms (the Ortmann + * sparse-stress model; pivot selection and BFS graph distances come from the + * {@link StressAnalysis} passes; CSR / weak-components / pivot-BFS plus the + * PivotMDS initialisation). Weights are 1/d² in hop space and never change; the + * weighted CSR Laplacian is built once per (re)layout/absorb. + * 2. Degenerate seed piles are scattered before any stress term is emitted: + * PivotMDS quantises coordinates to hop-difference lattice points (and a warm + * absorb often lands a batch of newcomers on one parent position), so a cold + * 20k-node graph starts with thousands of nodes stacked on a few hundred spots + * and a mega-hub's spokes stacked on one. Pair relaxation is a diffusion + * process; separating a k-node coincident pile costs O(k) full passes (measured: + * an un-scattered 20k fixture burned >18 s and still failed its settle-pass + * cap), so piles are instead placed directly on an area-weighted phyllotaxis + * spiral (Vogel's sunflower model generalised to heterogeneous disk areas) at + * the engine's shared packing utilisation: deterministic, O(k log k), and + * near-feasible by construction. See {@link "#pile scatter"} stages. + * 3. Per majorization iteration (SMACOF): the majorant right-hand side is computed from + * the current positions, then L·x = b_x and L·y = b_y are solved per dimension with + * Jacobi-preconditioned conjugate gradient. CG state persists across worker ticks; + * each tick advances a bounded number of CG steps, so per-tick cost is capped by + * construction (the budget is generous enough that each majorant is solved to + * tolerance; see CG_STEPS_PER_ITERATION). + * 4. Feasibility comes from iterated circle relaxation ({@link OverlapSweep}'s + * shared spatial-grid machinery): gentle pile-bleeding passes + * at every iteration boundary, then a terminal settle phase (one bounded + * pass per unit) that runs until one full pass verifies the layout + * overlap-free. See ITERATION_RELAX_PASSES for why the rectangle-based VPSC + * solver is deliberately not used as a per-iteration projector (geometry + * mismatch ⇒ limit cycle). Positions are published to the shared buffer at + * iteration / settle-pass boundaries only, and the final published frame is + * verified overlap-free. + * + * Every phase advances in bounded work units (edge/term chunks, pair-visit + * budgets on the relaxation sweeps, region budgets on the region floors), so a + * single `advance()` call never scales with graph size: the worst unit is a few + * milliseconds at 10⁵ nodes where the previous architecture's monolithic + * project/settle units froze a 20k-node worker for 100+ ms at every iteration + * boundary. Slicing changes only when work yields, never its order, so sliced + * and unsliced runs produce bitwise-identical layouts. + * + * Feasibility scaling makes the two halves agree instead of fight: raw hop-space + * targets (hops · 60 px) are frequently infeasible for the disk packing; a ~3k-node + * cloud "wants" a ~240 px-radius layout while its disks need ~800 px; and an + * infeasible stress optimum turns majorize→project into a permanent tug-of-war (the + * solve compresses, the projector re-expands, every iteration, forever). Each weak + * component therefore gets a target scale factor `scale_c = max(1, R_packing / R_hop-ideal)` + * where `R_packing` is the radius of the disk that holds the component's nodes at the + * shared packing utilisation and `R_hop-ideal` comes from the pivot-BFS + * eccentricity. Scaled targets put the unconstrained stress optimum near the feasible + * set, so the projector's correction is small and local, the alternation contracts, + * and, because the PivotMDS seed is laid out at unscaled hop geometry, the layout + * only ever expands toward its targets: no contract→expand swing. + * + * Non-overlap is therefore the projector's job, never the dynamics': there is no + * force-summed separation term to livelock against the stress pull (the failure mode of + * the abandoned force-interleave), and termination is a max-displacement threshold plus + * a hard iteration cap that logs and stops rather than spinning. + * + * Community / degree / size awareness is target shaping, not forces: + * + * - Cross-community pair targets are inflated by `communitySeparation`; same-community + * targets are slightly deflated by `communityCohesion` (seeded Louvain ids). + * Weight-to-shaping mapping (the numeric slider ranges predate this engine, so the + * scales are documented explicitly): separation w → ×(1 + 2w) on cross-community + * targets (default 0.08 → +16 %, harness max 0.8 → +160 %); cohesion w → ×1/(1 + 2w) + * on same-community targets (default 0.02 → −4 %); degreeRepulsion w → haloShare + * min(1, 2w), the fraction of a packing-bound hub's packing radius its children + * are pushed out to (default 0.02 → compact packing against the hub's rim; + * harness max 0.3 → children start 60 % of the way to the packing shell). All + * three sliders keep working; they regenerate targets (the Laplacian, keyed to + * hop distances only, is unaffected). + * - Hub-incident edge targets are packing-aware: when a hub's one-ring radius + * (Σ_children(2r+pad)/2π) exceeds the edge target, its children cannot all sit at + * the target distance, so its spokes get the feasible band from the collision gap + * out to the hub's disk-packing radius (at the shared ~55 % utilisation) + * with slack. A 150-leaf hub thus aims its spokes at a geometrically feasible + * halo from iteration one instead of compacting to an infeasible 1-hop length and + * being exploded by a terminal overlap pass; this kills the contract→expand + * relayout swing. + * - Near-coincident non-adjacent pairs (the same {@link UniformGrid} + * 3×3-neighbourhood scan the overlap passes use) get floor terms + * d* ≥ r_i + r_j + pad, so piles separate through the stress solve itself. + * + * Deterministic throughout: seeded pivot selection, hash-derived coincident directions, + * index-ordered scans, and a deterministic projector; identical input yields identical + * output. Warm start on absorb/relayout: positions are kept, the analysis + Laplacian + * are rebuilt, and CG warm-starts from the current layout. No cold re-seed. + * + * References: + * - Emden R. Gansner, Yehuda Koren, Stephen North, "Graph Drawing by Stress Majorization" (GD 2004). + * - Tim Dwyer, Yehuda Koren, Kim Marriott, "IPSep-CoLa: An Incremental Procedure for Separation Constraint Layout of Graphs" (InfoVis 2006). + * - Mark Ortmann, Mirza Klimenta, Ulrik Brandes, "A Sparse Stress Model" (GD 2016). + * - Tim Dwyer, Kim Marriott, Peter J. Stuckey, "Fast Node Overlap Removal" (GD 2005). + * - Helmut Vogel, "A better way to construct the sunflower head" (Mathematical + * Biosciences 44, 1979): the φ-angle spiral with r ∝ √k gives uniform area + * density; the pile scatter uses the area-weighted generalisation. + */ +/* eslint-disable no-bitwise */ +/* eslint-disable id-length */ +/* eslint-disable no-param-reassign -- typed-array kernels (SpMV / CG) write through + caller-owned buffers by design */ + +import { UndirectedGraph } from "graphology"; +import louvain from "graphology-communities-louvain"; + +import { parkMillerRng } from "../../math/random"; +import { Column } from "../collections/column"; +import { UniformGrid } from "../collections/uniform-grid"; +import { defaultMajorizationConfig } from "./majorization-config"; +import { OverlapSweep } from "./overlap-relax"; +import { + REGION_MIN_COMMUNITY_SIZE, + REGION_PACKING_UTILISATION, +} from "./region-metrics"; +import { INF_DIST, StressAnalysis } from "./stress-analysis"; + +import type { FlatGraphBuffer } from "../buffers/position-buffer"; +import type { + ForceEdge, + ForceLayoutStatus, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; +import type { MajorizationConfig } from "./majorization-config"; +import type { StressAnalysisResult } from "./stress-analysis"; + +/** + * Engine defaults (ideal hop length, overlap padding, shaping weights, + * convergence budget) live in the sibling {@link "./majorization-config"} + * module; production passes the live {@link "../../config"} `majorization` + * group, tests/benches may omit options entirely. + */ +const DEFAULT_TUNING = defaultMajorizationConfig; + +/** + * Slider-to-target-shaping gains. The three tuning weights shape targets: + * cross-community target ×(1 + separation·GAIN), same-community ×1/(1 + cohesion·GAIN), + * packing-bound hub spokes: haloShare = min(1, degreeRepulsion·GAIN); the fraction + * of the hub's packing radius its children are pushed out to (0 = children may pack + * right against the hub's rim; 1 = children start at the packing radius shell). + * GAIN = 2 places the harness slider ranges (0-0.3 / 0-0.8 / 0-0.3) onto a + * ±few-percent ... +160 % shaping range. + */ +const SEPARATION_TARGET_GAIN = 2; +const COHESION_TARGET_GAIN = 2; +const DEGREE_HALO_GAIN = 2; + +/** + * Disk-packing utilisation, used for the multi-ring hub floor (the disk that + * packs a hub's children), for the per-component feasibility scale (the disk + * that packs a whole component), and for the pile-scatter spiral density. + */ +const PACKING_UTILISATION = 0.55; + +/** Near-pair floor weight: full edge weight; these terms are the separation engine. */ +const NEAR_PAIR_WEIGHT = 1; +/** Cap near-pair partners per node so a k-node pile emits O(k), not O(k²), terms. */ +const NEAR_PAIR_MAX_PARTNERS = 8; + +/** + * Pile scatter (see architecture point 2): degenerate-density detection runs + * on a uniform grid of PILE_CELL_FACTOR × mean-radius cells (floored at + * PILE_MIN_CELL_SIZE for radius-degenerate inputs). A cell is overcommitted + * when the raw disk area (Σ π r²) of the ≥ 2 members whose centres it holds + * exceeds PILE_OVERCOMMIT × the cell's own area. The threshold must be + * unreachable by overlap-free geometry (a settled cluster must never be + * exploded): with full areas credited to the centre's cell, hex-packed equal + * disks of radius cell/PILE_CELL_FACTOR can legitimately reach + * 0.9069 · (1 + 2/PILE_CELL_FACTOR)² ≈ 2.04× the cell area (boundary disks + * spill half their area outside), and the engine's padded relax packings + * measure far below that; 3× therefore only triggers on genuinely + * intersecting piles. The ≥ 2 guard covers a single disk larger than its + * cell, which is normal. Overcommitted cells are flood-filled into groups + * (8-neighbourhood over cells); only groups of at least PILE_MIN_GROUP nodes + * are scattered — a handful of stacked nodes separates fine through the + * ordinary near-pair floors and relax passes, while a many-node pile costs + * O(pile) relax-pass diffusion (measured: a 20k fixture's hub piles decayed + * ~1.5 %/pass and outlived a 1024-pass cap) and is placed directly instead. + * + * The detector runs at build time (PivotMDS quantises coordinates to + * hop-difference lattice points, so cold seeds stack thousands of nodes on a + * few hundred spots; warm absorbs land newcomer batches on one parent + * position) and again whenever the terminal settle stalls (see + * SETTLE_SCATTER_STALL_RATIO: the stress solve can re-compact a hub's spokes + * into a deep pile after the build-time scatter). + */ +const PILE_CELL_FACTOR = 4; +const PILE_MIN_CELL_SIZE = 8; +const PILE_OVERCOMMIT = 3; +const PILE_MIN_GROUP = 8; +/** Vogel's φ angle: successive spiral placements at ~137.5°. */ +const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); + +/** + * Settle-phase stall detector: when a settle pass still finds more than + * SETTLE_SCATTER_MIN_OVERLAPS overlapping pairs and shrank the count by less + * than a factor of SETTLE_SCATTER_STALL_RATIO versus the previous pass, + * pairwise relaxation has degenerated into deep-pile diffusion; the pile + * scatter then re-places the offending clusters directly. The cooldown lets + * the relax passes absorb a scatter's fringe before the detector re-arms, and + * a repeat scatter must have earned its keep: it only fires when the found + * count has dropped below SETTLE_SCATTER_PROGRESS × its level at the previous + * scatter (re-placing the same cluster onto the same spiral resets 16 passes + * of relax progress for nothing — measured as a scatter↔relax livelock). + */ +const SETTLE_SCATTER_MIN_OVERLAPS = 64; +const SETTLE_SCATTER_STALL_RATIO = 0.95; +const SETTLE_SCATTER_COOLDOWN = 16; +const SETTLE_SCATTER_PROGRESS = 0.7; + +/** + * Second settle rescue, for the stall signature the pile scatter cannot see: + * a dense-but-distinct blob. Measured at the 20k fixture's settle cap: 11k + * residual pairs of mean depth 1.6 px spread over a 1.4k-px blob at 0.69 + * disk-area density with NO cell above the pile detector's 3× overcommit + * (max 1.83×). Pairwise Gauss-Seidel relaxation is percolation-jammed there: + * every correction lands its endpoints (at clearance) into third parties, so + * the found-count stays flat for the whole pass cap — separation pressure + * diffuses at O(blob diameter) passes instead of expanding the blob. + * + * The rescue relaxes the blob at cluster granularity (a two-level multigrid + * pass, same idea as PRISM's proximity-stress overlap removal — Gansner & Hu, + * "Efficient Node Overlap Removal Using a Proximity Stress Model", GD 2008): + * bin nodes into cells of COARSE_CELL_FACTOR × mean radius, model each cell + * as a super-disk at its members' centroid sized by their packing area + * (Σπ(r+pad/2)² at PACKING_UTILISATION — the same packing model as the + * component feasibility scale, so "super-disks disjoint" ⇒ every cell's + * neighbourhood has area for its members), relax the super-disks apart with + * budgeted sweep passes (the "coarse-run" settle stage; n_super ≪ n, but a + * one-shot rescue measured 125 ms at 20k — exactly the spike class this + * engine exists to avoid), and translate members rigidly with their cell. + * Super-disk relaxation only ever separates, so repeated firings make + * monotone progress — no scatter-style progress gate is needed, only the + * shared stall cooldown. + * Single-member cells keep their true radius (no utilisation slack), so + * settled dust is never inflated apart. Fine relax passes then finish + * locally, and the fast-exit proof (a pass that moved nothing) is untouched. + */ +const COARSE_CELL_FACTOR = 8; +const COARSE_MIN_CELL_SIZE = 16; +const COARSE_RELAX_STRENGTH = 0.85; +const COARSE_RELAX_PASSES = 128; +const COARSE_RELAX_MIN_MOVE = 0.25; + +/** + * `community-region` floors: every node outside community c is kept out of c's region + * disk; centred on the members' live centroid, radius R_c from the packing area of + * c's member disks (the same packing model as the hub floors). This is region-level + * separation that target shaping alone cannot provide: cross-community inflation + * only stretches existing terms (edges + pivot pairs), so an unrelated branch; + * sharing no edge and no pivot term with a foreign community; had nothing keeping + * it out of that community's fan and folded straight through it (measured: 32 % of + * real-shape nodes sat inside a foreign community's core disk; degree-1 leaves, + * which pivot anchoring deliberately skips, interpenetrate worst). + * + * Enforced as a relax pass (violators are pushed radially out of the region disk, + * gently at every iteration boundary and to verified-clean in the terminal + * settle), not as Laplacian floor terms, for the build-once architecture's + * economics: `regions × n` interval terms would dominate the Laplacian (~5× the + * nnz at 5k) and add constant stiffness between every node and every region even + * while satisfied (an in-band interval term exerts zero net force but keeps full + * weight in L), taxing every CG step of every solve. The relax pass costs zero + * solve stiffness and is push-only with NO opposing term; the stress energy has + * no term pulling a foreign node into a region (that absence is the root cause), + * so pushed-out is a genuine fixed point: the same no-livelock argument as the hub + * bands, now enforced by the projector instead of fought over by the dynamics. + * The terminal settle exits only when a full sweep verifies the layout both + * disk-overlap-free and region-clean, so the end state is guaranteed, not asked + * of the dynamics. + * + * Exemptions, planned per build (per-node bitmasks, hence the ≤ 32 region cap): + * members of c are fully exempt; nodes with an edge into c (bridge endpoints) + * are exempt from the full-disk floor but still pushed out of the region's + * CORE (BRIDGE_CORE_FRACTION × R_c). The graduation matters on both ends: + * shoving a bridge fully out against its own edge target leaves permanent + * tension for the solve to fight (measured as a settle-phase treadmill), but + * a blanket exemption makes regions toothless on dense clouds — Louvain + * communities of a small-world cloud are so interconnected that almost every + * node holds SOME cross-community edge, and once the PivotMDS seed became + * faithful enough to land those communities at their (interleaved) hop + * geometry, nothing pushed them apart: the real-shape disjointness gate sat + * at 0.12 containment with every single contained node bridge-exempt, at + * median depth 0.71·R. Rim-allowed / core-forbidden keeps the edge target + * satisfiable (a rim bridge reaches its member neighbour within ~R·(1 − + * fraction) + edge slack) while restoring separation pressure on the + * region's mass. Only the largest communities with ≥ + * REGION_MIN_COMMUNITY_SIZE members cast a region; tiny communities and + * singleton noise are skipped, so fragmented graphs stay O(n · 32) per pass, + * ~a disk-relax pass's cost. REGION_MIN_COMMUNITY_SIZE and the packing + * utilisation are imported from {@link "./region-metrics"} so the gate + * measures exactly what the engine enforces. + */ +const REGION_FLOOR_MAX_COMMUNITIES = 32; +/** + * Fraction of a region's radius kept bridge-free (the forbidden core). + * Measured on the real-shape gate: 0.75 → 0.080 containment, 0.85 → 0.041, + * 0.9 → 0.036 — while the settle-treadmill risk (bridges shoved against + * their own edge targets) stays absent through the region-freeze valve. + */ +const BRIDGE_CORE_FRACTION = 0.85; +/** Fraction of a region violation corrected per iteration-boundary pass. */ +const REGION_RELAX_STRENGTH_ITERATION = 0.5; +/** + * Member-gather: the symmetric half of region shaping. Eviction alone is + * one-sided — it empties a region's disk of foreigners but nothing ever pulls + * the community's OWN members into it, so on interleaved graphs the disk + * becomes a half-empty hole mid-cloud (foreigners evicted, owners still + * smeared through the whole annulus: measured on the 20k fixture as spread + * 1.55–2.0× packing radius with 86–89 % foreign-majority neighbourhoods, and + * a visible void where the biggest region's disk sits). The gather stage + * pulls each member sitting outside its own region's disk radially in to + * land just inside the rim (R_r − r_v): a dead zone fills the disk interior, + * so a gathered community is force-free — the same fixed-point argument as + * eviction, now from the inside. + * + * Gather + evict together give interleaved pairs an escape dynamic that + * eviction alone lacks: members of A caught in B's core are evicted toward + * B's rim AND gathered toward A's centroid, so mass flows into the lens + * complement and the two live centroids (recomputed every pass) drift apart. + * Stress-phase only: the terminal settle never gathers, so its structural + * termination proof (pure separation, no opposing force) is untouched — by + * settle time the communities are already grouped and eviction is cheap. + * Strength is gentler than eviction's: a gathered member is pulled against + * its cross-community edge terms, and the per-iteration re-solve must stay + * ahead of that fight (plateau detection exits the stress phase if it + * becomes a treadmill). + */ +const REGION_GATHER_STRENGTH_ITERATION = 0.35; +/** + * Center separation: gather alone still deadlocks interleaved communities, + * because their LIVE centroids coincide (three of the 20k fixture's four big + * communities centre on the same mega-hub fan) — gather then pulls three + * member sets toward one contested spot and eviction picks a single winner + * (measured: c0 cohered at 27 % foreign-majority, the rest stayed smeared at + * ~75 %). So every sweep derives WORKING centers: start from the live + * centroids and relax the ≤ 32 region disks (radius R_r) pairwise apart for + * a few bounded iterations — the same disk-relaxation used everywhere else, + * at region granularity (≤ 32² pair checks per iteration, noise next to a + * node pass; coincident pairs split along a hash-derived deterministic + * angle). Members then gather toward, and foreigners evict from, the + * separated working centers, so interleaved communities get distinct + * attractor territories and their member mass — and with it the next sweep's + * live centroids — flows apart across the stress iterations instead of + * fighting over one spot. The working centers are recomputed from scratch + * each sweep (nothing persists), so there is no accumulated-offset state to + * drift: once the live centroids genuinely separate, the relax is a no-op + * and working = live. Where the topology truly cannot follow (bridge-dense + * pairs), the plateau detector exits the stress phase and the settle freeze + * valve reports the residual honestly, exactly as before. + * + * This is deliberately NOT rigid-body cluster translation (IPSep-style, which + * livelocked: it re-translates whole communities against their edge pull + * forever) — separated centers are targets for gentle per-member projector + * pulls that the per-iteration re-solve mediates, not applied displacements. + */ +const REGION_CENTER_RELAX_ITERATIONS = 8; +const REGION_CENTER_RELAX_STRENGTH = 0.5; +/** + * Region enforcement is best-effort against topologically interleaved + * communities, with the disk invariant kept absolute. Louvain communities on + * hub-dominated graphs can interleave structurally: two ~2.7k-member + * communities on the captured 20k fixture share mega-hub neighbourhoods, and + * the stress solve correctly lands them nearly concentric (111 px centre + * distance against 658 px region radii). Geometry satisfying both + * "non-members out of each disk" and the edge targets does not exist there, + * and every full-enforcement scheme tried just picked a livelock flavour: + * per-node radial pushes drive the two member sets through one another (a + * standing crush that manufactures disk overlaps every pass — 224 stuck + * overlap participants, settle cap burned), rigid-body pair separation + * (IPSep-style cluster constraints) re-translates whole communities against + * their edge pull forever, livelocking even on an 850-node fixture, and a + * pairwise mutual-exemption mask (interleaved pairs stop pushing each + * other's members) let regions rest interpenetrated on shapes where full + * enforcement CAN separate them (the real-shape disjointness gate regressed + * 0.05 → 0.16 overlap ratio). + * + * One graduated concession instead, sized by the stuck population when the + * violation count stops improving for REGION_STALL_WINDOW consecutive settle + * passes: + * + * - A LARGE stuck count (> max(REGION_FREEZE_MIN_VIOLATIONS, + * REGION_FREEZE_FRACTION × n); the 20k fixture's mega-pair sticks at ~2.4k + * of 20k nodes) is the structural-interleave signature: region enforcement + * freezes entirely and the phase exits on the disk latch alone, reporting + * the region residual honestly. Hulls then interleave exactly where the + * graph itself interleaves, which the BubbleSets corridor planner already + * renders correctly. + * - A SMALL stuck count is rim churn: a handful of nodes the region pass + * pushes out and the disk pass knocks back in every cycle (measured: 4-66 + * nodes across the 1k-5k gate fixtures). Freezing for those few would stop + * policing everyone else and let containment drift (measured: the + * real-shape disjointness gate regressed 0.03 → 0.12 under an + * unconditional freeze). Rim churn usually clears on its own, so it gets + * REGION_SMALL_STALL_PATIENCE × the stall window before any concession; + * only then is the stuck count accepted as the exit latch's floor — + * enforcement keeps running to the very end, the exit just no longer + * demands the impossible zero. + */ +const REGION_STALL_WINDOW = 12; +const REGION_FREEZE_MIN_VIOLATIONS = 64; +const REGION_FREEZE_FRACTION = 0.02; +const REGION_SMALL_STALL_PATIENCE = 4; +/** + * Region clearance margin (world units beyond R_c + r_v) while the solve runs: + * overlapPadding + communitySeparation · GAIN · idealEdgeLength (default 0.08 → + * ~13 px, harness max 0.8 → ~56 px). Combined with the pre-existing + * cross-community target inflation ×(1 + 2·separation), the slider now moves both + * the pairwise stretch and the region clearance. The terminal settle instead + * triggers on strict (margin-0) violation and lands corrections at + * SETTLE_CLEARANCE (same trigger/landing hysteresis as the disk pass): the + * margin is the stress phase's breathing room, and re-enforcing all of it + * terminally would read as a terminal expansion. + */ +const REGION_MARGIN_GAIN = 1; + +/** + * Dead-zone ceiling for floored / packing-bound terms, as a multiple of the reference + * radius. Such terms become interval targets [lo, hi]: below lo they push out, above + * hi they pull in, and in between they exert zero net force (effective target = + * current distance). Without the dead zone, majorization re-compacts every such pair + * to a single exact distance each iteration while the projector pushes the pile out to + * whatever configuration feasibility actually needs; a persistent tug-of-war whose + * amplitude never decays below the convergence threshold (the projector's constraint + * generation is discontinuous in the positions, so the alternation chatters instead + * of settling). With the dead zone, whatever configuration the projector settles a + * pile at is a fixed point. + */ +const FLOOR_CEILING_SLACK = 1.5; + +/** + * Relative half-widths of the interval bands on stress terms: target·[1−band, 1+band]. + * Demanding exact distances fights the disk packing; the projector necessarily + * distorts local geometry (its minimal-displacement x/y passes shift whole chains of + * touching rectangles), and every term left violated at the projected configuration + * pulls again next iteration, re-creating the same overlaps and re-running the same + * projection: a structural majorize↔project limit cycle whose amplitude never decays. + * With dead-zone bands, the projector's output is (mostly) a zero-force configuration, + * a genuine fixed point, while distances beyond the band still pull back, bounding + * drift. Edge terms are tighter (they carry local structure); pivot terms, which only + * hold the global shape, are looser. + */ +const EDGE_BAND = 0.15; +const PIVOT_BAND = 0.25; + +/** Relative tolerance (preconditioned residual norm²) at which a solve stops early. */ +const CG_RELATIVE_TOLERANCE = 0.01; + +/** + * Feasibility is delivered by iterated circle relaxation ({@link OverlapSweep}; + * one bounded uniform-grid sweep per pass), not by a per-iteration exact projection, + * and the engine cleanly separates shape from feasibility in time: + * + * - During majorization iterations, each iteration ends with a couple of gentle + * relax passes that bleed residual piles down while the solve spreads the + * layout toward its (feasibility-scaled, packing-aware) targets. + * - Once the stress solve converges or plateaus, a terminal settle phase runs relax + * passes; one bounded pass per settle unit, sliced by pair budget, so per-tick + * cost stays capped; until one full pass verifies the layout overlap-free. + * Because the targets were shaped feasibility-first (and the seed piles were + * scattered at packing density), this phase does small local separation, not a + * global explosion (no contract→expand swing), and pure separation with no + * opposing force always terminates; the livelock class of the abandoned + * force-interleave is structurally impossible. + * + * Per-iteration projection uses bounded circle relaxation, not rectangle VPSC (the + * mismatch between circle and rectangle separation geometry causes limit cycles: a + * circle-optimal solve packs pairs diagonally at distances the rectangle model + * forbids by up to ~30 %, so a VPSC projector shifts whole chains by 100-500 px + * every iteration and the next solve pulls them straight back). Near-pair floors are + * static, emitted once at build: regenerating them inside the loop destabilises the + * alternation instead of letting it converge. + */ +const ITERATION_RELAX_PASSES = 4; +const ITERATION_RELAX_STRENGTH = 0.85; +const SETTLE_RELAX_STRENGTH = 1; +/** + * Clearance a settle correction leaves beyond r_i + r_j (the sweep's + * `overshoot`), while the settle TRIGGER is strict intersection (padding 0). + * The trigger/target split is what makes the phase terminate: + * + * - Triggering at a padded distance manufactures work on strictly-clean pairs: + * a relax-packed cluster rests just inside any padded threshold, so every + * pass re-corrects the same ~10⁴ clean-but-snug pairs whose neighbours + * knock them back, an oscillation that burned the whole 1024-pass cap on + * the 20k fixture (measured: found-count pinned at ~17k while strict + * overlaps sat near 200; with the full 8 px padding it also inflates the + * settled cloud ~2 %, a terminal expand, the exact motion this engine + * exists to kill). + * - Correcting to exactly-touching (zero target clearance) stalls instead: + * resolved pairs land ε from re-intersecting, any later nudge re-trips + * them, and hundreds of overlaps never clear (measured: 342). + * + * Strict trigger + padded landing gives hysteresis: only true intersections + * are ever touched, and each correction buys real slack that float noise and + * knock-on cannot immediately re-trip. Breathing room beyond this sliver is + * the stress phase's job (near-pair floors and hub bands target the full + * padded distance). + */ +const SETTLE_CLEARANCE = 2; +/** + * A pass moving nothing proves feasibility (the trigger is strict + * intersection, so a no-move pass IS a strict-clean proof); the interval + * check re-measures both counts anyway as a cheap invariant guard. + */ +const SETTLE_VERIFY_INTERVAL = 8; +/** Safety cap on total settle passes: reaching it logs instead of spinning. */ +const SETTLE_MAX_PASSES = 1024; + +/** + * Plateau detector: dense graphs (feasibility scale ≫ 1) never push per-iteration + * displacement below the convergence threshold; the gentle relax passes keep nudging + * piles; so when the best max-displacement has not improved by PLATEAU_IMPROVEMENT + * for PLATEAU_WINDOW consecutive iterations, the stress phase is declared done and + * the terminal settle phase takes over. Sparse graphs converge normally instead. + */ +const PLATEAU_WINDOW = 12; +const PLATEAU_IMPROVEMENT = 0.9; + +/** + * Analysis (CSR + components + pivot BFS) work units per advance step. Default + * 16384: a larger chunk crosses fewer phase transitions but risks a slower single + * tick; a smaller chunk keeps the worker cadence smoother at more per-tick overhead. + */ +const ANALYSIS_TICK_WORK = 16384; +/** + * Term-emission / RHS / Laplacian-fill work units per advance step. Default 65536: + * a larger chunk crosses fewer phase transitions but risks a slower single tick; a + * smaller chunk keeps the worker cadence smoother at more per-tick overhead. + */ +const TERM_CHUNK = 65_536; +/** Edge-loop work units per advance step (prepare stages that scan the edge list). */ +const EDGE_CHUNK = 131_072; +/** + * Pair-visit budget per relaxation/verification unit: the resumable + * {@link OverlapSweep} yields after roughly this many candidate-pair visits. + * ~131k visits ≈ 1-2 ms; the previous monolithic 4-pass projection unit reached + * 100+ ms on a 20k graph. + */ +const PAIR_BUDGET = 131_072; +/** Node-visit budget per region-floor unit (a region scan visits every node). */ +const REGION_NODE_BUDGET = 131_072; + +/** + * Re-run Louvain after absorb once new nodes ≥ max(LOUVAIN_REFRESH_MIN_NEW_NODES, + * LOUVAIN_REFRESH_GROWTH_FRACTION of the node count at the last refresh). Default + * 24 / 30%: a lower fraction keeps community labels fresher at more rebuild cost; a + * higher fraction risks stale shaping on fast growth. + */ +const LOUVAIN_REFRESH_GROWTH_FRACTION = 0.3; +const LOUVAIN_REFRESH_MIN_NEW_NODES = 24; + +/** + * Attach phase: instant visible pull for warm-absorbed newcomers. A warm + * absorb rebuilds the whole solver, and no position moves until analysis → + * terms → Laplacian → first CG solve completes (~300 ms at 20k under the app + * tick budget). Under a streaming feed whose batch interval is SHORTER than + * that restart latency, the pipeline restarts forever and almost no iterate + * is ever published: a late-arriving hub visibly "has no pull" even though + * its edge terms would deliver it (measured: mean hub→spoke distance flat for + * a whole 6 s stream, with a single mid-stream iterate briefly reaching the + * pulled state before the next restart discarded it). + * + * The fix runs before analysis, off the one thing already known without any + * BFS — the new edge list: ATTACH_PASSES sweeps over edges incident to new + * nodes, each endpoint stepped toward the ideal edge length by + * ATTACH_STRENGTH of its excess, split inverse to attach-degree (a 200-spoke + * hub barely moves while each degree-1 spoke flies; matching both the + * energy-minimal move and the "hub pulls its neighbours" read). Each pass + * publishes, so the yank animates within a tick or two of the absorb. + * Deterministic: a fixed number of passes in fixed edge order, part of the + * solver pipeline (not tick-count-dependent), before the analysis snapshots + * positions — the solve then proceeds from the attached geometry exactly as + * if the absorb had arrived that way. + */ +const ATTACH_PASSES = 6; +const ATTACH_STRENGTH = 0.5; + +/** + * Deterministic init jitter (fraction of the ideal edge length). Default 0.01, just + * enough to break exact coincident cold seeds apart; a larger value spreads the + * initial layout further and reduces pile pathology but moves the deterministic + * start further from the raw hop layout. + */ +const SEED_JITTER = 0.01; + +const EPS = 1e-9; +const TAU = Math.PI * 2; + +const hashU32 = (value: number): number => { + let x = value >>> 0; + x ^= x >>> 16; + x = Math.imul(x, 0x7feb352d); + x ^= x >>> 15; + x = Math.imul(x, 0x846ca68b); + x ^= x >>> 16; + return x >>> 0; +}; + +/** Deterministic separation direction for two exactly-coincident term endpoints. */ +const coincidentAngle = (i: number, j: number): number => + (hashU32((((i + 1) * 0x9e3779b1) ^ ((j + 1) * 0x85ebca6b)) >>> 0) / + 0x100000000) * + TAU; + +/** + * Per-call tuning overrides; unset fields fall back to + * {@link defaultMajorizationConfig}. See {@link "./majorization-config"} for + * per-field semantics. + */ +export type MajorizationLayoutOptions = Partial; + +type ResolvedOptions = MajorizationConfig; + +/** Deduped undirected edge in index space with summed parallel weight. */ +interface IndexEdge { + readonly source: number; + readonly target: number; + readonly weight: number; +} + +interface SolverInput { + readonly n: number; + readonly src: Uint32Array; + readonly dst: Uint32Array; + /** Solver-owned position buffers; mutated in place each iteration and copied to the shared buffer at publish boundaries. */ + readonly x: Float32Array; + readonly y: Float32Array; + readonly radii: Float32Array; + /** Louvain community per node (may contain -1 for unassigned). */ + readonly communities: Int32Array | undefined; + /** Warm start: keep current positions (absorb / relayout); cold builds PivotMDS-init. */ + readonly warm: boolean; + /** + * First node index that is NEW this build (warm absorbs append); -1 ⇒ none. + * Enables the attach phase (see ATTACH_PASSES). + */ + readonly newNodesFrom?: number; +} + +type SolverPhase = + | "attach" + | "analysis" + | "prepare" + | "terms" + | "laplacian" + | "rhs" + | "cg-init" + | "cg" + | "project" + | "settle" + | "done"; + +/** + * Sub-stages of the "prepare" phase (post-analysis term/plan build), each a + * bounded unit or a cursor-resumed chunk loop. Order matters: the pile scatter + * must run before near-pair detection (floors are emitted from the scattered + * geometry) and after the hub/packing statistics (which are position-free). + */ +type PrepareStage = + | "degrees" + | "hub-rings" + | "packing" + | "max-hop" + | "scale" + | "edge-keys" + | "edge-terms" + | "pile-scatter" + | "near-pair-grid" + | "near-pairs" + | "region-count" + | "region-select" + | "region-members" + | "region-edges"; + +/** Sub-stages of one projection (iteration-boundary) round. */ +type ProjectStage = + | "adopt" + | "region" + | "gather" + | "relax-build" + | "relax-run" + | "finish"; + +/** Sub-stages of one terminal-settle pass (+ its interval verification). */ +type SettleStage = + | "region" + | "relax-run" + | "verify-run" + | "verify-region" + | "coarse-run"; + +/** + * The engine core: analysis → pile scatter → term/Laplacian build → persistent-CG majorization + * iterations with sliced circle-relaxation projection. + * + * The class is heavy in internal state, reason being not that it is a god class, but that due + * to the nature of the algorithm, most state must be retained across iterations, and allocations + * must be minimized at all cost. + */ +class MajorizationSolver { + readonly #n: number; + readonly #src: Uint32Array; + readonly #dst: Uint32Array; + readonly #x: Float32Array; + readonly #y: Float32Array; + readonly #radii: Float32Array; + readonly #communityOf: Int32Array | undefined; + readonly #options: ResolvedOptions; + + #phase: SolverPhase; + + /** First new node index this build (-1 ⇒ no attach phase). */ + readonly #newNodesFrom: number; + /** Edge indices (into src/dst) incident to a new node. */ + #attachEdges: Uint32Array = new Uint32Array(0); + /** Attach-edge count per endpoint node (the inverse-degree move split). */ + #attachDegree: Map = new Map(); + #attachBuilt = false; + #attachPassesDone = 0; + + /** Budget-sliced CSR / weak-components / pivot-BFS / PivotMDS analysis passes. */ + #analysis: StressAnalysis | null; + #analysisResult: StressAnalysisResult | undefined; + + /** + * Static stress terms (edges + near-pair floors + pivot terms), fixed after + * the build, stored in growable columns (worst-case pre-sizing would cost + * hundreds of MB at 10⁵ nodes; the columns grow to actual usage instead). + * Each term is an interval target [lo, hi]: the majorant RHS uses the + * effective target clamp(currentDistance, lo, hi), so a pair inside its band + * exerts zero net force (the IPSep one-sided treatment, generalised to a + * band). See #shapeTarget for how the bands are derived. + */ + readonly #termA = new Column(Uint32Array, 1024, { backing: "plain" }); + readonly #termB = new Column(Uint32Array, 1024, { backing: "plain" }); + readonly #termWeight = new Column(Float32Array, 1024, { backing: "plain" }); + readonly #termLo = new Column(Float32Array, 1024, { backing: "plain" }); + readonly #termHi = new Column(Float32Array, 1024, { backing: "plain" }); + + #prepareStage: PrepareStage = "degrees"; + /** Generic loop cursor within the current prepare stage. */ + #prepareCursor = 0; + #degrees = new Uint32Array(0); + #childExtent = new Float64Array(0); + #childAreaSq = new Float64Array(0); + /** One-ring radius needed to seat v's children side by side (Σ(2r+pad)/2π). */ + #hubRing = new Float32Array(0); + /** Disk radius that packs v's children at the packing utilisation. */ + #hubPack = new Float32Array(0); + #packingSq = new Float64Array(0); + #maxHop = new Float64Array(0); + #edgeKeys: Set = new Set(); + // componentScale[c] = max(1, 1.3·R_packing / R_hop); multiplies all hop targets for component c. + #componentScale = new Float64Array(0); + #componentOf: Int32Array = new Int32Array(0); + /** Shared spatial grid for pile detection and near-pair floor emission. */ + readonly #grid = new UniformGrid(); + /** Pile flood-fill scratch: group id per overloaded bucket (-1 = none). */ + #pileGroupOfBucket = new Int32Array(0); + #nearPairPartners = new Uint8Array(0); + // Chunked pivot-term emission resumes from these cursors. + #pivotRowCursor = 0; + #pivotNodeCursor = 0; + + // Community-region floor plan (see REGION_FLOOR_MAX_COMMUNITIES): the largest + // communities cast centroid-centred packing disks that non-members are relaxed out + // of. Built once per solver build; centroids are recomputed from live member + // positions at every pass. + #regionCount = 0; + #communityCount = 0; + #communityMemberCount = new Int32Array(0); + #regionOfCommunity = new Int32Array(0); + /** Region members, region-major (offsets below); feeds the centroid recompute. */ + #regionMemberNodes = new Int32Array(0); + #regionMemberOffsets = new Int32Array(0); + /** Packing radius per region (world units, before the per-node radius + margin). */ + #regionRadius = new Float32Array(0); + /** Per node, bit r set ⇔ member of region r (fully exempt from its floor). */ + #regionExempt = new Uint32Array(0); + /** Per node, bit r set ⇔ edge into region r (rim allowed, core forbidden). */ + #regionBridge = new Uint32Array(0); + #regionCentroidX = new Float64Array(0); + #regionCentroidY = new Float64Array(0); + /** Solve-time clearance beyond R_c + r_v (slider-scaled; see REGION_MARGIN_GAIN). */ + #regionMargin = 0; + + #regionSweepStage: "idle" | "centroid" | "scan" = "idle"; + #regionSweepCursor = 0; + #regionSweepMargin = 0; + #regionSweepStrength = 0; + #regionSweepOvershoot = 0; + #regionSweepViolations = 0; + /** Whether this pass derives separated working centers (stress phase only; + * see REGION_CENTER_RELAX_ITERATIONS). */ + #regionSweepSeparate = false; + + // Member-gather sweep state (see REGION_GATHER_STRENGTH_ITERATION). + #gatherCursor = 0; + #gatherMoved = 0; + + /** Resumable overlap relaxation / verification sweep (one live pass at a time). */ + readonly #sweep = new OverlapSweep(); + + // Scratch outputs of #shapeTarget (avoids a per-call tuple allocation at build time). + #shapedLo = 0; + #shapedHi = 0; + + // CSR weighted Laplacian: off-diagonals in CSR form, diagonal kept separately. + #rowPtr = new Int32Array(0); + #rowCursor = new Int32Array(0); + #colIdx = new Int32Array(0); + #offDiag = new Float32Array(0); + #diag = new Float64Array(0); + #invDiag = new Float64Array(0); + #laplacianPass = 0; + #laplacianCursor = 0; + + // Majorant RHS + persistent CG state (all Float64 for stable accumulation). + #bX = new Float64Array(0); + #bY = new Float64Array(0); + #solX = new Float64Array(0); + #solY = new Float64Array(0); + #resX = new Float64Array(0); + #resY = new Float64Array(0); + #dirX = new Float64Array(0); + #dirY = new Float64Array(0); + #applyX = new Float64Array(0); + #applyY = new Float64Array(0); + #rhsCursor = 0; + #cgStep = 0; + #rzX = 0; + #rzY = 0; + #rz0X = 0; + #rz0Y = 0; + #cgDoneX = false; + #cgDoneY = false; + + #iteration = 0; + #convergedStreak = 0; + #prevX = new Float32Array(0); + #prevY = new Float32Array(0); + #lastMaxDisplacement = Number.POSITIVE_INFINITY; + #capped = false; + // Plateau detector state (see PLATEAU_WINDOW). + #bestDisplacement = Number.POSITIVE_INFINITY; + #bestDisplacementIteration = 0; + + #projectStage: ProjectStage = "adopt"; + #projectPass = 0; + #projectMoved = false; + + #settleStage: SettleStage = "region"; + /** Total settle passes run (safety-capped by SETTLE_MAX_PASSES). */ + #settlePasses = 0; + #settleRegionViolations = 0; + /** Set when the pass cap forced a final verification before giving up. */ + #settleCapFinalising = false; + // Stall detector state (see SETTLE_SCATTER_STALL_RATIO). + #settlePrevOverlaps = Number.POSITIVE_INFINITY; + #settleScatterCooldown = 0; + #settleOverlapsAtLastScatter = Number.POSITIVE_INFINITY; + // Region-enforcement freeze state (see REGION_STALL_WINDOW). + #settleRegionFrozen = false; + #settleBestRegionViolations = Number.POSITIVE_INFINITY; + #settleRegionStallStreak = 0; + // Cluster-level expansion state (see COARSE_CELL_FACTOR): super-disk + // arrays live across the budgeted "coarse-run" slices, the sweep instance + // is dedicated (the fine sweep's pass state must survive a rescue). + readonly #coarseSweep = new OverlapSweep(); + #coarseSuperX = new Float32Array(0); + #coarseSuperY = new Float32Array(0); + #coarseSuperR = new Float32Array(0); + #coarseBaseX = new Float32Array(0); + #coarseBaseY = new Float32Array(0); + #coarseBucketCount = 0; + #coarsePasses = 0; + #coarsePassArmed = false; + /** Latches once a full relax pass verifies the layout overlap-free. */ + #everFeasible = false; + /** Whether the settle phase hit its pass cap with violations remaining. */ + settleCapped = false; + /** + * Overlapping pairs at the last measurement. During stress iterations this is + * the count the last projection pass corrected (measured at the iteration + * padding, pre-correction); at settle verifications it is the strict + * (padding-0) count. Zero after {@link projectionActive} latches. + */ + residualOverlaps = 0; + /** + * Community-region violations at the last measurement (pre-push count of the + * last region sweep; strict margin-0 count at settle verifications). + */ + residualRegionViolations = 0; + /** Iterations/settle passes whose relax passes had actual work (diagnostic). */ + projectionRuns = 0; + /** Max displacement of the last solve step alone, pre-projection (diagnostic). */ + lastSolveDisplacement = 0; + /** Max displacement the last projection added on top of the solve (diagnostic). */ + lastProjectDisplacement = 0; + /** Node index with the largest last-iteration displacement (diagnostic). */ + lastMaxDisplacementNode = -1; + /** |CG solution − adopted position| for that node after projection (diagnostic). */ + lastMaxSolveGap = 0; + /** Nodes scattered out of degenerate seed piles at build time (diagnostic). */ + scatteredPileNodes = 0; + /** Per-component feasibility-scale summary for the first few components (diagnostic). */ + scaleDiagnostics: { + size: number; + maxHop: number; + packingRadius: number; + scale: number; + }[] = []; + + constructor(input: SolverInput, options: ResolvedOptions) { + this.#n = input.n; + this.#src = input.src; + this.#dst = input.dst; + this.#x = input.x; + this.#y = input.y; + this.#radii = input.radii; + this.#communityOf = input.communities; + this.#options = options; + this.#newNodesFrom = + input.warm && + input.newNodesFrom !== undefined && + input.newNodesFrom < input.n + ? input.newNodesFrom + : -1; + + if (this.#n === 0) { + this.#phase = "done"; + this.#analysis = null; + return; + } + + // Cold builds seed positions via PivotMDS; warm keeps the current layout (see + // the StressAnalysis options below). + this.#analysis = new StressAnalysis( + { + n: this.#n, + src: this.#src, + dst: this.#dst, + x: this.#x, + y: this.#y, + }, + { + pivotCount: options.pivotCount, + keepInitialPositions: input.warm, + jitter: input.warm ? 0 : SEED_JITTER, + packComponents: !input.warm, + randomSeed: 1, + idealEdgeLength: options.idealEdgeLength, + validate: false, + }, + ); + this.#phase = this.#newNodesFrom >= 0 ? "attach" : "analysis"; + } + + get done(): boolean { + return this.#phase === "done"; + } + + /** Completed majorization iterations since the last solver build. */ + get iteration(): number { + return this.#iteration; + } + + /** Whether the last run hit the hard iteration cap instead of converging. */ + get capped(): boolean { + return this.#capped; + } + + /** Max per-node displacement of the last completed iteration (world units). */ + get lastMaxDisplacement(): number { + return this.#lastMaxDisplacement; + } + + /** + * True once terminal settle verifies zero disk overlaps. Before latch, + * iteration-boundary publishes may still contain overlaps; after latch, the + * layout is settled and overlap-free. + */ + get projectionActive(): boolean { + return this.#everFeasible; + } + + get termCount(): number { + return this.#termA.length; + } + + /** Cumulative / worst-unit wall time per phase (perf diagnostics; ~free to keep). */ + readonly phaseCumulativeMs: Partial> = {}; + readonly phaseMaxMs: Partial> = {}; + + /** Cumulative ms spent inside projection/settle relaxation (diagnostic). */ + get projectionMs(): number { + return ( + (this.phaseCumulativeMs.project ?? 0) + + (this.phaseCumulativeMs.settle ?? 0) + ); + } + + /** Worst single projection/settle advance unit (ms; diagnostic). */ + get maxProjectionMs(): number { + return Math.max(this.phaseMaxMs.project ?? 0, this.phaseMaxMs.settle ?? 0); + } + + /** + * One bounded unit of work. Returns true if the solver advanced (false once done). + * Units are budgeted so the worst single unit stays far below a frame at any + * graph size: chunked scans in the build phases, one CG step (= one SpMV per + * dimension) in the solve, pair-budgeted relaxation slices in projection and + * settle. + */ + advance(): boolean { + const phase = this.#phase; + if (phase === "done") { + return false; + } + const start = performance.now(); + const advanced = this.#advanceInner(); + const elapsed = performance.now() - start; + this.phaseCumulativeMs[phase] = + (this.phaseCumulativeMs[phase] ?? 0) + elapsed; + if (elapsed > (this.phaseMaxMs[phase] ?? 0)) { + this.phaseMaxMs[phase] = elapsed; + } + return advanced; + } + + #advanceInner(): boolean { + switch (this.#phase) { + case "attach": { + this.#attachStep(); + return true; + } + case "analysis": { + // #analysis is non-null only in the "analysis" phase; tick() returns + // result only when done. + const result = this.#analysis!.tick({ maxWork: ANALYSIS_TICK_WORK }); + if (result.done) { + this.#analysisResult = result.result!; + this.#analysis = null; + this.#prepareStage = "degrees"; + this.#prepareCursor = 0; + this.#phase = "prepare"; + } + return true; + } + case "prepare": { + this.#prepareStep(); + return true; + } + case "terms": { + this.#buildTermsChunk(TERM_CHUNK); + return true; + } + case "laplacian": { + this.#buildLaplacianStep(); + return true; + } + case "rhs": { + this.#computeRhsChunk(TERM_CHUNK); + return true; + } + case "cg-init": { + this.#initCg(); + return true; + } + case "cg": { + this.#stepCg(); + return true; + } + case "project": { + this.#projectStep(); + return true; + } + case "settle": { + this.#settleStep(); + return true; + } + case "done": { + return false; + } + } + } + + /** + * Monotonic generation counter; changes at each completed majorization iteration + * or settle pass (and each attach pass) to gate buffer publishes. + */ + get publishGeneration(): number { + return this.#attachPassesDone + this.#iteration + this.#settlePasses; + } + + /** + * One bounded attach unit (see ATTACH_PASSES): the first unit scans the + * edge list once for edges incident to new nodes (O(m)); each further unit + * runs one pass over those edges, stepping endpoints toward the ideal edge + * length split inverse to attach-degree, and counts as a publish + * generation. No-op edge case: a batch with no new-node edges (pure dust) + * skips straight to analysis. + */ + #attachStep(): void { + if (!this.#attachBuilt) { + const from = this.#newNodesFrom; + const edgeCount = this.#src.length; + const found: number[] = []; + for (let e = 0; e < edgeCount; e++) { + if (this.#src[e]! >= from || this.#dst[e]! >= from) { + found.push(e); + this.#attachDegree.set( + this.#src[e]!, + (this.#attachDegree.get(this.#src[e]!) ?? 0) + 1, + ); + this.#attachDegree.set( + this.#dst[e]!, + (this.#attachDegree.get(this.#dst[e]!) ?? 0) + 1, + ); + } + } + this.#attachEdges = Uint32Array.from(found); + this.#attachBuilt = true; + if (found.length === 0) { + this.#phase = "analysis"; + } + return; + } + + const target = this.#options.idealEdgeLength; + for (const e of this.#attachEdges) { + const u = this.#src[e]!; + const v = this.#dst[e]!; + let dx = this.#x[v]! - this.#x[u]!; + let dy = this.#y[v]! - this.#y[u]!; + let dist = Math.hypot(dx, dy); + if (dist < EPS) { + const angle = coincidentAngle(u, v); + dx = Math.cos(angle); + dy = Math.sin(angle); + dist = 1; + } else { + dx /= dist; + dy /= dist; + } + const excess = dist - target; + if (Math.abs(excess) < EPS) { + continue; + } + const degreeU = this.#attachDegree.get(u) ?? 1; + const degreeV = this.#attachDegree.get(v) ?? 1; + const shareU = degreeV / (degreeU + degreeV); + const step = excess * ATTACH_STRENGTH; + this.#x[u]! += dx * step * shareU; + this.#y[u]! += dy * step * shareU; + this.#x[v]! -= dx * step * (1 - shareU); + this.#y[v]! -= dy * step * (1 - shareU); + } + this.#attachPassesDone += 1; + if (this.#attachPassesDone >= ATTACH_PASSES) { + this.#phase = "analysis"; + } + } + + /** + * One bounded unit of the prepare phase. Stages either run one O(n)/O(m) + * loop as a unit or resume a chunked scan via {@link #prepareCursor}; see + * {@link PrepareStage} for ordering constraints. + */ + #prepareStep(): void { + switch (this.#prepareStage) { + case "degrees": { + this.#prepareDegreesChunk(); + return; + } + case "hub-rings": { + this.#prepareHubRings(); + return; + } + case "packing": { + this.#preparePacking(); + return; + } + case "max-hop": { + this.#prepareMaxHopChunk(); + return; + } + case "scale": { + this.#prepareScale(); + return; + } + case "edge-keys": { + this.#prepareEdgeKeysChunk(); + return; + } + case "edge-terms": { + this.#prepareEdgeTermsChunk(); + return; + } + case "pile-scatter": { + this.scatteredPileNodes += this.#scatterPiles(); + this.#advancePrepareStage("near-pair-grid"); + return; + } + case "near-pair-grid": { + this.#prepareNearPairGrid(); + return; + } + case "near-pairs": { + this.#prepareNearPairsChunk(); + return; + } + case "region-count": { + this.#prepareRegionCount(); + return; + } + case "region-select": { + this.#prepareRegionSelect(); + return; + } + case "region-members": { + this.#prepareRegionMembers(); + return; + } + case "region-edges": { + this.#prepareRegionEdgesChunk(); + } + } + } + + /** + * Degrees + per-node hub geometry accumulators from the (deduped) edge list. + * For a hub v two radii matter: the one-ring radius that seats its children + * side by side (Σ(2r+pad)/2π) and the disk radius that packs them at the + * packing utilisation; the accumulation happens here, the radii in + * "hub-rings". When the ring radius exceeds an edge's target the hub is + * packing-bound; its children cannot all sit at the target distance; and its + * spokes get a wide feasible band instead of an exact target (see #shapeTarget). + */ + #prepareDegreesChunk(): void { + const n = this.#n; + const pad = this.#options.overlapPadding; + if (this.#prepareCursor === 0) { + this.#degrees = new Uint32Array(n); + this.#childExtent = new Float64Array(n); + this.#childAreaSq = new Float64Array(n); + } + const edgeCount = this.#src.length; + const end = Math.min(edgeCount, this.#prepareCursor + EDGE_CHUNK); + for (let e = this.#prepareCursor; e < end; e++) { + const u = this.#src[e]!; + const v = this.#dst[e]!; + this.#degrees[u]! += 1; + this.#degrees[v]! += 1; + const ru = this.#radii[u]!; + const rv = this.#radii[v]!; + this.#childExtent[u]! += 2 * rv + pad; + this.#childExtent[v]! += 2 * ru + pad; + const halfU = ru + pad / 2; + const halfV = rv + pad / 2; + this.#childAreaSq[u]! += halfV * halfV; + this.#childAreaSq[v]! += halfU * halfU; + } + this.#prepareCursor = end; + if (end >= edgeCount) { + this.#advancePrepareStage("hub-rings"); + } + } + + #prepareHubRings(): void { + const n = this.#n; + this.#hubRing = new Float32Array(n); + this.#hubPack = new Float32Array(n); + for (let v = 0; v < n; v++) { + const ringNeed = this.#childExtent[v]! / TAU; + const diskNeed = Math.sqrt(this.#childAreaSq[v]! / PACKING_UTILISATION); + this.#hubRing[v] = ringNeed; + // A hub's own disk is part of the packing: children sit outside its radius. + this.#hubPack[v] = Math.min(ringNeed, diskNeed + this.#radii[v]!); + } + this.#advancePrepareStage("packing"); + } + + /** + * Per-component packing area for the feasibility scale: hop targets are + * multiplied by max(1, R_packing / R_hop-ideal) so the unconstrained stress + * optimum is roughly packing-feasible and the projector only has local work. + * R_packing is the radius of the disk holding Σπ(r+pad/2)² at the packing + * utilisation; R_hop-ideal comes from the pivot-BFS rows in "max-hop". + */ + #preparePacking(): void { + const analysis = this.#analysisResult!; + const components = analysis.components; + this.#componentOf = components.labels; + const pad = this.#options.overlapPadding; + this.#packingSq = new Float64Array(components.count); + for (let v = 0; v < this.#n; v++) { + const half = this.#radii[v]! + pad / 2; + this.#packingSq[this.#componentOf[v]!]! += half * half; + } + this.#maxHop = new Float64Array(components.count); + this.#advancePrepareStage("max-hop"); + } + + /** Max BFS distance per component, one pivot row per unit (row scans a component). */ + #prepareMaxHopChunk(): void { + const analysis = this.#analysisResult!; + const pivots = analysis.pivots; + const components = analysis.components; + const n = this.#n; + const distances = pivots.distances; + + let scanned = 0; + while (this.#prepareCursor < pivots.pivots.length && scanned < TERM_CHUNK) { + const row = this.#prepareCursor; + const component = pivots.components[row]!; + const rowBase = row * n; + const start = components.offsets[component]!; + const end = components.offsets[component + 1]!; + let rowMax = this.#maxHop[component]!; + for (let i = start; i < end; i++) { + const d = distances[rowBase + components.nodes[i]!]!; + if (d !== INF_DIST && d > rowMax) { + rowMax = d; + } + } + this.#maxHop[component] = rowMax; + scanned += end - start; + this.#prepareCursor += 1; + } + if (this.#prepareCursor >= pivots.pivots.length) { + this.#advancePrepareStage("scale"); + } + } + + #prepareScale(): void { + const components = this.#analysisResult!.components; + /** + * Feasibility-scale headroom. Default 1.3: without it, hop-scaled targets sit + * just inside the packing envelope and terminal settle must inflate the cloud + * (~8 % RMS rebound at 3k), the exact motion this engine exists to kill. + */ + const SCALE_SAFETY = 1.3; + this.#componentScale = new Float64Array(components.count); + for (let c = 0; c < components.count; c++) { + const packingRadius = Math.sqrt( + this.#packingSq[c]! / PACKING_UTILISATION, + ); + const hopRadius = + Math.max(1, this.#maxHop[c]! / 2) * this.#options.idealEdgeLength; + this.#componentScale[c] = Math.max( + 1, + (SCALE_SAFETY * packingRadius) / hopRadius, + ); + } + this.scaleDiagnostics = Array.from( + { length: Math.min(4, components.count) }, + (_, c) => ({ + size: components.offsets[c + 1]! - components.offsets[c]!, + maxHop: this.#maxHop[c]!, + packingRadius: Math.sqrt(this.#packingSq[c]! / PACKING_UTILISATION), + scale: this.#componentScale[c]!, + }), + ); + this.#advancePrepareStage("edge-keys"); + } + + /** + * Packed edge keys so near-pair floors never duplicate an edge term (their + * targets would conflict: the floor would pull an adjacent pair inward + * against its edge). Chunked: Set inserts on a 10⁵-edge list are a + * double-digit-ms unit otherwise. + */ + #prepareEdgeKeysChunk(): void { + const n = this.#n; + if (this.#prepareCursor === 0) { + this.#edgeKeys = new Set(); + } + const edgeCount = this.#src.length; + const end = Math.min(edgeCount, this.#prepareCursor + EDGE_CHUNK); + for (let e = this.#prepareCursor; e < end; e++) { + const u = this.#src[e]!; + const v = this.#dst[e]!; + this.#edgeKeys.add(Math.min(u, v) * n + Math.max(u, v)); + } + this.#prepareCursor = end; + if (end >= edgeCount) { + this.#termA.clear(); + this.#termB.clear(); + this.#termWeight.clear(); + this.#termLo.clear(); + this.#termHi.clear(); + this.#advancePrepareStage("edge-terms"); + } + } + + #prepareEdgeTermsChunk(): void { + const edgeCount = this.#src.length; + const end = Math.min(edgeCount, this.#prepareCursor + EDGE_CHUNK); + for (let e = this.#prepareCursor; e < end; e++) { + const u = this.#src[e]!; + const v = this.#dst[e]!; + this.#shapeTarget(u, v, 1); + this.#pushTerm(u, v, 1, this.#shapedLo, this.#shapedHi); + } + this.#prepareCursor = end; + if (end >= edgeCount) { + this.#advancePrepareStage("pile-scatter"); + } + } + + /** + * Detect degenerate piles and scatter each onto an area-weighted phyllotaxis + * spiral around the group's centroid: member k (ascending node index) lands + * at radius √(Σ_{j≤k}(r_j + pad/2)² / utilisation) — the rim of the disk + * that packs the members placed so far — at angle k·φ plus a hash-derived + * group offset. Deterministic (bucket ids ascend in first-seen node order, + * the flood fill scans buckets in id order and neighbours in a fixed 3×3 + * order, members sort ascending) and near-feasible by construction: what + * pair relaxation would need O(pile) diffusion passes to achieve happens in + * one placement. See the PILE_CELL_FACTOR doc for the detection rule and + * its no-false-positive argument; runs as one O(n) unit, at build time and + * on settle stall. Returns the number of nodes scattered. + */ + #scatterPiles(): number { + const n = this.#n; + const pad = this.#options.overlapPadding; + + let radiusSum = 0; + for (let v = 0; v < n; v++) { + radiusSum += this.#radii[v]!; + } + const cellSize = Math.max( + PILE_MIN_CELL_SIZE, + PILE_CELL_FACTOR * (radiusSum / Math.max(1, n)), + ); + + const grid = this.#grid; + grid.build(this.#x, this.#y, n, cellSize); + const bucketCount = grid.bucketCount; + const starts = grid.starts; + const order = grid.order; + + // Overcommit test per bucket: Σ πr² of members vs the cell's own area. + const areaLimit = PILE_OVERCOMMIT * cellSize * cellSize; + if (this.#pileGroupOfBucket.length < bucketCount) { + this.#pileGroupOfBucket = new Int32Array(bucketCount); + } + const groupOf = this.#pileGroupOfBucket; + groupOf.fill(-1, 0, bucketCount); + + const overloaded = (bucket: number): boolean => { + const start = starts[bucket]!; + const end = starts[bucket + 1]!; + if (end - start < 2) { + return false; + } + let area = 0; + for (let m = start; m < end; m++) { + const radius = this.#radii[order[m]!]!; + area += Math.PI * radius * radius; + } + return area > areaLimit; + }; + + const stack: number[] = []; + const members: number[] = []; + let scattered = 0; + let groupCount = 0; + for (let seed = 0; seed < bucketCount; seed++) { + if (groupOf[seed]! !== -1 || !overloaded(seed)) { + continue; + } + const group = groupCount; + groupCount += 1; + + members.length = 0; + stack.length = 0; + stack.push(seed); + groupOf[seed] = group; + while (stack.length > 0) { + const bucket = stack.pop()!; + for (let m = starts[bucket]!; m < starts[bucket + 1]!; m++) { + members.push(order[m]!); + } + // 8-neighbourhood over cells; grid cells are keyed by coordinates, so + // neighbours resolve through the same exact-match lookup as the sweeps. + const cellX = grid.cellXOf(order[starts[bucket]!]!); + const cellY = grid.cellYOf(order[starts[bucket]!]!); + for (let ox = -1; ox <= 1; ox++) { + for (let oy = -1; oy <= 1; oy++) { + if (ox === 0 && oy === 0) { + continue; + } + const neighbour = grid.bucketAt(cellX + ox, cellY + oy); + if ( + neighbour >= 0 && + groupOf[neighbour]! === -1 && + overloaded(neighbour) + ) { + groupOf[neighbour] = group; + stack.push(neighbour); + } + } + } + } + + if (members.length < PILE_MIN_GROUP) { + continue; + } + + if (process.env.PILE_DEBUG) { + // eslint-disable-next-line no-console + console.log( + `[pile-scatter] group size=${members.length} seedCell=(${grid.cellXOf( + order[starts[seed]!]!, + )},${grid.cellYOf(order[starts[seed]!]!)})`, + ); + } + + members.sort((a, b) => a - b); + let centroidX = 0; + let centroidY = 0; + for (const node of members) { + centroidX += this.#x[node]!; + centroidY += this.#y[node]!; + } + centroidX /= members.length; + centroidY /= members.length; + + const baseAngle = (hashU32(members[0]! + 1) / 0x100000000) * TAU; + let cumHalfSq = 0; + for (const [rank, node] of members.entries()) { + const half = this.#radii[node]! + pad / 2; + cumHalfSq += half * half; + const radius = Math.sqrt(cumHalfSq / PACKING_UTILISATION); + const angle = baseAngle + rank * GOLDEN_ANGLE; + this.#x[node] = centroidX + Math.cos(angle) * radius; + this.#y[node] = centroidY + Math.sin(angle) * radius; + } + scattered += members.length; + } + + return scattered; + } + + /** + * Arm the cluster-level expansion for a percolation-jammed settle stall + * (see the COARSE_CELL_FACTOR doc for the failure signature and the + * multigrid argument). One O(n) unit: grid the nodes and derive one + * super-disk per occupied cell — members' centroid, radius from their + * packing area. The "coarse-run" stage then relaxes the super-disks apart + * in budgeted slices, and {@link #coarseApply} translates members rigidly + * with their cell. Rigid translations of separating cells never create new + * member overlaps (cells only move apart), and cells whose super-disks are + * already disjoint do not move at all, so a feasible-density layout is a + * fixed point. Deterministic: bucket ids ascend in first-seen node order + * and the sweep is itself deterministic. + * + * The node grid snapshot (`#grid`) must survive untouched until + * {@link #coarseApply}; nothing else builds `#grid` during the settle + * phase (the relax/verify sweeps own their grids, and the pile scatter + * only runs from the same stall branch). + */ + #coarseInit(): void { + const n = this.#n; + const pad = this.#options.overlapPadding; + + let radiusSum = 0; + for (let v = 0; v < n; v++) { + radiusSum += this.#radii[v]!; + } + const cellSize = Math.max( + COARSE_MIN_CELL_SIZE, + COARSE_CELL_FACTOR * (radiusSum / Math.max(1, n)), + ); + + const grid = this.#grid; + grid.build(this.#x, this.#y, n, cellSize); + const bucketCount = grid.bucketCount; + const starts = grid.starts; + const order = grid.order; + + if (this.#coarseSuperX.length < bucketCount) { + this.#coarseSuperX = new Float32Array(bucketCount); + this.#coarseSuperY = new Float32Array(bucketCount); + this.#coarseSuperR = new Float32Array(bucketCount); + this.#coarseBaseX = new Float32Array(bucketCount); + this.#coarseBaseY = new Float32Array(bucketCount); + } + for (let bucket = 0; bucket < bucketCount; bucket++) { + const start = starts[bucket]!; + const end = starts[bucket + 1]!; + let cx = 0; + let cy = 0; + let halfSq = 0; + for (let m = start; m < end; m++) { + const v = order[m]!; + cx += this.#x[v]!; + cy += this.#y[v]!; + const half = this.#radii[v]! + pad / 2; + halfSq += half * half; + } + const members = end - start; + cx /= members; + cy /= members; + this.#coarseSuperX[bucket] = cx; + this.#coarseSuperY[bucket] = cy; + this.#coarseBaseX[bucket] = cx; + this.#coarseBaseY[bucket] = cy; + // Single disks need no packing slack; piles/clusters claim the disk + // that packs their members at the engine-wide utilisation. + this.#coarseSuperR[bucket] = + members === 1 + ? Math.sqrt(halfSq) + : Math.sqrt(halfSq / PACKING_UTILISATION); + } + + this.#coarseBucketCount = bucketCount; + this.#coarsePasses = 0; + this.#coarsePassArmed = false; + this.#settleStage = "coarse-run"; + } + + /** Rigid per-cell displacement of members after the super-disk relaxation. */ + #coarseApply(): void { + const starts = this.#grid.starts; + const order = this.#grid.order; + for (let bucket = 0; bucket < this.#coarseBucketCount; bucket++) { + const dx = this.#coarseSuperX[bucket]! - this.#coarseBaseX[bucket]!; + const dy = this.#coarseSuperY[bucket]! - this.#coarseBaseY[bucket]!; + if (dx === 0 && dy === 0) { + continue; + } + const start = starts[bucket]!; + const end = starts[bucket + 1]!; + for (let m = start; m < end; m++) { + const v = order[m]!; + this.#x[v]! += dx; + this.#y[v]! += dy; + } + } + } + + /** + * Near-pair floor grid over the (scattered) seed/warm positions: the same + * cell sizing as the overlap sweeps, so any pair within the collision floor + * lands within one cell. + */ + #prepareNearPairGrid(): void { + const n = this.#n; + const pad = this.#options.overlapPadding; + let maxRadius = 0; + for (let v = 0; v < n; v++) { + if (this.#radii[v]! > maxRadius) { + maxRadius = this.#radii[v]!; + } + } + this.#grid.build(this.#x, this.#y, n, Math.max(1e-6, 2 * maxRadius + pad)); + if (this.#nearPairPartners.length < n) { + this.#nearPairPartners = new Uint8Array(n); + } else { + this.#nearPairPartners.fill(0, 0, n); + } + this.#advancePrepareStage("near-pairs"); + } + + /** + * Grid-detected near-pair floors, pair-budget sliced. Deterministic: nodes + * scanned in index order, each unordered pair visited once (3×3 scan with + * the b ≤ a skip), partners capped per node in scan order. Pairs currently + * overlapping (or nearly) that share no edge get push-only floor terms + * [r_i + r_j + pad, ∞): they break residual piles apart through the stress + * solve itself. Pairs that become overlapping only mid-solve are separated + * in the terminal settle phase; static floors keep the Laplacian build-once. + */ + #prepareNearPairsChunk(): void { + const n = this.#n; + const pad = this.#options.overlapPadding; + const grid = this.#grid; + const starts = grid.starts; + const order = grid.order; + const partners = this.#nearPairPartners; + + let visits = 0; + let a = this.#prepareCursor; + for (; a < n && visits < PAIR_BUDGET; a++) { + if (partners[a]! >= NEAR_PAIR_MAX_PARTNERS) { + continue; + } + const baseX = grid.cellXOf(a); + const baseY = grid.cellYOf(a); + for (let ox = -1; ox <= 1; ox++) { + for (let oy = -1; oy <= 1; oy++) { + const bucket = grid.bucketAt(baseX + ox, baseY + oy); + if (bucket < 0) { + continue; + } + const end = starts[bucket + 1]!; + for (let m = starts[bucket]!; m < end; m++) { + const b = order[m]!; + if (b <= a) { + continue; + } + visits += 1; + if ( + partners[a]! >= NEAR_PAIR_MAX_PARTNERS || + partners[b]! >= NEAR_PAIR_MAX_PARTNERS + ) { + continue; + } + const floor = this.#radii[a]! + this.#radii[b]! + pad; + const dx = this.#x[b]! - this.#x[a]!; + const dy = this.#y[b]! - this.#y[a]!; + if (dx * dx + dy * dy >= floor * floor) { + continue; + } + if (this.#edgeKeys.has(a * n + b)) { + continue; + } + this.#pushTerm( + a, + b, + NEAR_PAIR_WEIGHT, + floor, + Number.POSITIVE_INFINITY, + ); + partners[a]! += 1; + partners[b]! += 1; + } + } + } + } + this.#prepareCursor = a; + if (a >= n) { + this.#advancePrepareStage("region-count"); + } + } + + #prepareRegionCount(): void { + const n = this.#n; + const communityOf = this.#communityOf; + this.#regionCount = 0; + this.#regionExempt = new Uint32Array(0); + if (!communityOf || n === 0) { + // No communities ⇒ no regions; skip straight to the pivot-term phase. + this.#finishPrepare(); + return; + } + + let communityCount = 0; + for (let v = 0; v < n; v++) { + if (communityOf[v]! + 1 > communityCount) { + communityCount = communityOf[v]! + 1; + } + } + this.#communityCount = communityCount; + this.#communityMemberCount = new Int32Array(communityCount); + for (let v = 0; v < n; v++) { + // -1 = community-less (absorbed past the provisional labeler's reach); + // such nodes join no region and are counted nowhere. + if (communityOf[v]! >= 0) { + this.#communityMemberCount[communityOf[v]!]! += 1; + } + } + this.#advancePrepareStage("region-select"); + } + + #prepareRegionSelect(): void { + const communityCount = this.#communityCount; + const memberCount = this.#communityMemberCount; + + const candidates: number[] = []; + for (let c = 0; c < communityCount; c++) { + if (memberCount[c]! >= REGION_MIN_COMMUNITY_SIZE) { + candidates.push(c); + } + } + // Largest first; community id breaks ties; deterministic under the dense + // (first-seen) community numbering. + candidates.sort((a, b) => memberCount[b]! - memberCount[a]! || a - b); + const regions = candidates.slice(0, REGION_FLOOR_MAX_COMMUNITIES); + if (regions.length === 0) { + this.#finishPrepare(); + return; + } + + this.#regionOfCommunity = new Int32Array(communityCount).fill(-1); + for (const [regionIndex, community] of regions.entries()) { + this.#regionOfCommunity[community] = regionIndex; + } + + const count = regions.length; + this.#regionCount = count; + this.#regionRadius = new Float32Array(count); + this.#regionCentroidX = new Float64Array(count); + this.#regionCentroidY = new Float64Array(count); + this.#regionMemberOffsets = new Int32Array(count + 1); + this.#regionExempt = new Uint32Array(this.#n); + this.#regionBridge = new Uint32Array(this.#n); + this.#regionMargin = + this.#options.overlapPadding + + this.#options.communitySeparation * + REGION_MARGIN_GAIN * + this.#options.idealEdgeLength; + this.#advancePrepareStage("region-members"); + } + + /** Counting-sort member lists so region scans stay cache-friendly and deterministic. */ + #prepareRegionMembers(): void { + const n = this.#n; + const communityOf = this.#communityOf!; + const count = this.#regionCount; + const pad = this.#options.overlapPadding; + const areaSq = new Float64Array(count); + for (let v = 0; v < n; v++) { + const community = communityOf[v]!; + // The community >= 0 guard is load-bearing: communityOf can hold -1 + // (community-less), and regionOfCommunity[-1] is undefined — which + // `region < 0` does NOT catch, and `1 << undefined` is 1, silently + // exempting every unlabeled node from region 0's floor. + const region = community >= 0 ? this.#regionOfCommunity[community]! : -1; + if (region < 0) { + continue; + } + const half = this.#radii[v]! + pad / 2; + areaSq[region]! += half * half; + this.#regionExempt[v]! |= 1 << region; + this.#regionMemberOffsets[region + 1]! += 1; + } + for (let r = 0; r < count; r++) { + this.#regionRadius[r] = Math.sqrt( + areaSq[r]! / REGION_PACKING_UTILISATION, + ); + this.#regionMemberOffsets[r + 1]! += this.#regionMemberOffsets[r]!; + } + this.#regionMemberNodes = new Int32Array(this.#regionMemberOffsets[count]!); + const cursor = this.#regionMemberOffsets.slice(0, count); + for (let v = 0; v < n; v++) { + const community = communityOf[v]!; + const region = community >= 0 ? this.#regionOfCommunity[community]! : -1; + if (region >= 0) { + this.#regionMemberNodes[cursor[region]!] = v; + cursor[region]! += 1; + } + } + this.#advancePrepareStage("region-edges"); + } + + /** + * Edge-adjacency exemption: a bridge endpoint may sit at the foreign region's + * rim (its own edge target puts it there); shoving it out would fight the edge. + */ + #prepareRegionEdgesChunk(): void { + const communityOf = this.#communityOf!; + const edgeCount = this.#src.length; + const end = Math.min(edgeCount, this.#prepareCursor + EDGE_CHUNK); + for (let e = this.#prepareCursor; e < end; e++) { + const u = this.#src[e]!; + const v = this.#dst[e]!; + const communityU = communityOf[u]!; + const communityV = communityOf[v]!; + const regionU = + communityU >= 0 ? this.#regionOfCommunity[communityU]! : -1; + const regionV = + communityV >= 0 ? this.#regionOfCommunity[communityV]! : -1; + if (regionU >= 0) { + this.#regionBridge[v]! |= 1 << regionU; + } + if (regionV >= 0) { + this.#regionBridge[u]! |= 1 << regionV; + } + } + this.#prepareCursor = end; + if (end >= edgeCount) { + this.#finishPrepare(); + } + } + + #advancePrepareStage(next: PrepareStage): void { + this.#prepareStage = next; + this.#prepareCursor = 0; + } + + #finishPrepare(): void { + this.#pivotRowCursor = 0; + this.#pivotNodeCursor = 0; + this.#phase = "terms"; + } + + // ---------------------------------------------------- region-floor sweep + + /** + * Arm one community-region relax pass: recompute each region's centroid from + * its live members, then push every non-exempt node inside + * R_region + r_node + `margin` radially out to that trigger distance plus + * `overshoot` (the same trigger/landing hysteresis as the settle disk pass; + * a zero-overshoot full-strength push lands exactly on the trigger and + * ε-refires forever). With `strength` 0 it only counts (the settle phase's + * verification read). Deterministic: fixed region order, index-ordered node + * scan, hash-derived direction for a node exactly on a centroid. One-sided + * by design; the stress energy has no term pulling a foreign node into a + * region, so pushed-out is a fixed point (no force to fight) — but see + * REGION_STALL_WINDOW: topologically interleaved communities admit no + * region-clean geometry at all, and the settle phase freezes enforcement + * rather than livelock against them. + */ + #regionSweepStart( + margin: number, + strength: number, + overshoot = 0, + separateCenters = false, + ): void { + this.#regionSweepMargin = margin; + this.#regionSweepStrength = strength; + this.#regionSweepOvershoot = overshoot; + this.#regionSweepSeparate = separateCenters; + this.#regionSweepViolations = 0; + this.#regionSweepCursor = 0; + this.#regionSweepStage = this.#regionCount === 0 ? "idle" : "centroid"; + } + + /** + * Advance the armed region pass by a bounded number of node visits. + * Returns true when the pass is complete (violation count in + * {@link #regionSweepViolations}). + */ + #regionSweepRun(): boolean { + if (this.#regionSweepStage === "idle") { + return true; + } + const count = this.#regionCount; + const n = this.#n; + + if (this.#regionSweepStage === "centroid") { + // All centroids in one unit: Σ members ≤ n. + for (let r = 0; r < count; r++) { + const start = this.#regionMemberOffsets[r]!; + const end = this.#regionMemberOffsets[r + 1]!; + let cx = 0; + let cy = 0; + for (let m = start; m < end; m++) { + const member = this.#regionMemberNodes[m]!; + cx += this.#x[member]!; + cy += this.#y[member]!; + } + const members = end - start; + this.#regionCentroidX[r] = members > 0 ? cx / members : 0; + this.#regionCentroidY[r] = members > 0 ? cy / members : 0; + } + if (this.#regionSweepSeparate) { + this.#separateRegionCenters(); + } + this.#regionSweepStage = "scan"; + this.#regionSweepCursor = 0; + return false; + } + + // Scan stage: whole regions per unit, budgeted by node visits. + const regionsPerUnit = Math.max(1, Math.floor(REGION_NODE_BUDGET / n)); + const margin = this.#regionSweepMargin; + const strength = this.#regionSweepStrength; + const overshoot = this.#regionSweepOvershoot; + let processed = 0; + let violations = this.#regionSweepViolations; + let r = this.#regionSweepCursor; + for (; r < count && processed < regionsPerUnit; r++, processed++) { + const cx = this.#regionCentroidX[r]!; + const cy = this.#regionCentroidY[r]!; + const base = this.#regionRadius[r]! + margin; + // Bridges (nodes with an edge into r) may sit on r's rim but not in + // its core; see BRIDGE_CORE_FRACTION. + const core = BRIDGE_CORE_FRACTION * this.#regionRadius[r]! + margin; + const skipMask = 1 << r; + for (let v = 0; v < n; v++) { + if ((this.#regionExempt[v]! & skipMask) !== 0) { + continue; + } + const isBridge = (this.#regionBridge[v]! & skipMask) !== 0; + const need = (isBridge ? core : base) + this.#radii[v]!; + let dx = this.#x[v]! - cx; + let dy = this.#y[v]! - cy; + const distSq = dx * dx + dy * dy; + if (distSq >= need * need) { + continue; + } + violations += 1; + if (strength === 0) { + continue; + } + let dist = Math.sqrt(distSq); + if (dist < EPS) { + const angle = coincidentAngle(v, n + r); + dx = Math.cos(angle); + dy = Math.sin(angle); + dist = 1; + } else { + dx /= dist; + dy /= dist; + } + const shift = (need + overshoot - dist) * strength; + this.#x[v]! += dx * shift; + this.#y[v]! += dy * shift; + } + } + this.#regionSweepCursor = r; + this.#regionSweepViolations = violations; + if (r >= count) { + this.#regionSweepStage = "idle"; + return true; + } + return false; + } + + /** + * Relax the live centroids into separated working centers, in place (see + * REGION_CENTER_RELAX_ITERATIONS): pairwise disk relaxation over the ≤ 32 + * region disks, mass-weighted so a small community's center yields before a + * big one's. Deterministic: fixed pair order, fixed iteration count, early + * exit only on a clean iteration; a coincident pair splits along a + * hash-derived angle. + */ + #separateRegionCenters(): void { + const count = this.#regionCount; + for (let pass = 0; pass < REGION_CENTER_RELAX_ITERATIONS; pass++) { + let anyPush = false; + for (let a = 0; a < count; a++) { + const membersA = + this.#regionMemberOffsets[a + 1]! - this.#regionMemberOffsets[a]!; + for (let b = a + 1; b < count; b++) { + const need = this.#regionRadius[a]! + this.#regionRadius[b]!; + let dx = this.#regionCentroidX[b]! - this.#regionCentroidX[a]!; + let dy = this.#regionCentroidY[b]! - this.#regionCentroidY[a]!; + const distSq = dx * dx + dy * dy; + if (distSq >= need * need) { + continue; + } + anyPush = true; + let dist = Math.sqrt(distSq); + if (dist < EPS) { + const angle = coincidentAngle(this.#n + a, this.#n + b); + dx = Math.cos(angle); + dy = Math.sin(angle); + dist = 1; + } else { + dx /= dist; + dy /= dist; + } + const membersB = + this.#regionMemberOffsets[b + 1]! - this.#regionMemberOffsets[b]!; + const total = Math.max(1, membersA + membersB); + const shift = (need - dist) * REGION_CENTER_RELAX_STRENGTH; + const shareA = membersB / total; + this.#regionCentroidX[a]! -= dx * shift * shareA; + this.#regionCentroidY[a]! -= dy * shift * shareA; + this.#regionCentroidX[b]! += dx * shift * (1 - shareA); + this.#regionCentroidY[b]! += dy * shift * (1 - shareA); + } + } + if (!anyPush) { + return; + } + } + } + + /** Arm one member-gather pass (see REGION_GATHER_STRENGTH_ITERATION). */ + #gatherStart(): void { + this.#gatherCursor = 0; + this.#gatherMoved = 0; + } + + /** + * Advance the armed gather pass by a bounded number of member visits: every + * member sitting outside its own region's disk (dist + r_v > R_r) is pulled + * radially toward the region centroid by + * REGION_GATHER_STRENGTH_ITERATION × its excess. Uses the centroids the + * region sweep just recomputed (eviction moves only non-members, so they + * are still exact). Total work is Σ members ≤ n per pass. Returns true when + * the pass is complete (moved count in {@link #gatherMoved}). + */ + #gatherSweepRun(): boolean { + const total = this.#regionMemberNodes.length; + if (this.#regionCount === 0 || total === 0) { + return true; + } + const end = Math.min(total, this.#gatherCursor + REGION_NODE_BUDGET); + const offsets = this.#regionMemberOffsets; + // Locate the region containing the cursor (≤ 32 regions; linear is fine). + let r = 0; + while (offsets[r + 1]! <= this.#gatherCursor) { + r += 1; + } + let moved = this.#gatherMoved; + for (let m = this.#gatherCursor; m < end; m++) { + while (offsets[r + 1]! <= m) { + r += 1; + } + const v = this.#regionMemberNodes[m]!; + const inside = Math.max(0, this.#regionRadius[r]! - this.#radii[v]!); + const dx = this.#x[v]! - this.#regionCentroidX[r]!; + const dy = this.#y[v]! - this.#regionCentroidY[r]!; + const distSq = dx * dx + dy * dy; + if (distSq <= inside * inside) { + continue; + } + const dist = Math.sqrt(distSq); + // dist ≥ inside ≥ 0 here; dist can only be 0 when inside is too, and + // then the shift below is 0 regardless of direction. + const shift = + dist < EPS + ? 0 + : ((dist - inside) * REGION_GATHER_STRENGTH_ITERATION) / dist; + this.#x[v]! -= dx * shift; + this.#y[v]! -= dy * shift; + moved += 1; + } + this.#gatherMoved = moved; + this.#gatherCursor = end; + return end >= total; + } + + // ----------------------------------------------------------- term shaping + + /** + * Community-shaped target band for a pair at `hops` graph distance, written to + * `#shapedLo` / `#shapedHi`. + * + * - Edge terms (hops = 1) get an exact target; they carry local structure; + * unless an endpoint hub is packing-bound (its one-ring radius exceeds the + * target, i.e. its children cannot all sit at the target distance). Such spokes + * get the wide feasible band [collision + haloShare·(pack − collision), + * max(pack, target)·slack]: the compact packing the projector produces (children + * at radii from the hub's rim out to the packing radius) lies inside the band, + * so a packed hub is a fixed point instead of a fight. `degreeRepulsion` sets + * haloShare; how far children are pushed from the rim toward an explicit + * halo shell. + * - Pivot terms (hops ≥ 2) get a ±PIVOT_BAND dead zone around the target so + * packing distortion does not generate perpetual pulls. + * - The collision floor r_i + r_j + pad applies to every band (floored terms get a + * FLOOR_CEILING_SLACK dead zone rather than an exact floor). + * + * The hub band is applied here, not just on edge terms, because a pivot row + * re-emits (pivot, neighbour) pairs at d = 1 and a conflicting un-banded target + * would average against the edge term and undercut the halo. All d = 1 terms for a + * pair agree exactly, so duplicates only add weight. + */ + #shapeTarget(u: number, v: number, hops: number): void { + const opts = this.#options; + // Both endpoints share a component (terms come from edges / same-component + // pivot-BFS rows), so u's feasibility scale is the pair's. + let target = + hops * + opts.idealEdgeLength * + this.#componentScale[this.#componentOf[u]!]!; + const communityOf = this.#communityOf; + if (communityOf) { + const cu = communityOf[u]!; + const cv = communityOf[v]!; + // Community-less (-1) pairs are neither same (two unlabeled newcomers + // share no discovered affinity) nor different (don't stretch a newcomer + // away from its labeled neighbour): they take the unshaped target. + if (cu >= 0 && cv >= 0) { + if (cu !== cv) { + target *= 1 + opts.communitySeparation * SEPARATION_TARGET_GAIN; + } else { + target /= 1 + opts.communityCohesion * COHESION_TARGET_GAIN; + } + } + } + const collision = this.#radii[u]! + this.#radii[v]! + opts.overlapPadding; + + if (hops === 1) { + const ringNeed = Math.max(this.#hubRing[u]!, this.#hubRing[v]!); + if (ringNeed > target) { + const packRadius = Math.max( + collision, + Math.max(this.#hubPack[u]!, this.#hubPack[v]!), + ); + const haloShare = Math.min(1, opts.degreeRepulsion * DEGREE_HALO_GAIN); + this.#shapedLo = collision + haloShare * (packRadius - collision); + this.#shapedHi = Math.max(packRadius, target) * FLOOR_CEILING_SLACK; + return; + } + } + + const band = hops >= 2 ? PIVOT_BAND : EDGE_BAND; + let lo = target * (1 - band); + let hi = target * (1 + band); + if (lo < collision) { + lo = collision; + const flooredHi = collision * FLOOR_CEILING_SLACK; + if (hi < flooredHi) { + hi = flooredHi; + } + } + this.#shapedLo = lo; + this.#shapedHi = hi; + } + + #pushTerm( + a: number, + b: number, + weight: number, + lo: number, + hi: number, + ): void { + this.#termA.push(a); + this.#termB.push(b); + this.#termWeight.push(weight); + this.#termLo.push(lo); + this.#termHi.push(hi); + } + + /** + * Pivot terms, chunked: for pivot row p and node v at BFS distance d, a term with + * weight 1/d² and community-shaped target d·ideal. Degree-1 nodes are skipped as the + * non-pivot endpoint: a leaf's position is fully determined by its single edge plus + * the projector, and anchoring it to k pivots at hop-scaled targets is exactly the + * inward compaction pressure that fought the hub halos (its own pivot row, if a leaf + * is selected as a pivot, still anchors the rest of the graph to it). + */ + #buildTermsChunk(budget: number): void { + const analysis = this.#analysisResult!; + const pivots = analysis.pivots; + const components = analysis.components; + const n = this.#n; + const distances = pivots.distances; + let work = 0; + + while (work < budget) { + if (this.#pivotRowCursor >= pivots.pivots.length) { + this.#laplacianPass = 0; + this.#laplacianCursor = 0; + this.#phase = "laplacian"; + return; + } + const row = this.#pivotRowCursor; + const pivotNode = pivots.pivots[row]!; + const component = pivots.components[row]!; + const start = components.offsets[component]!; + const end = components.offsets[component + 1]!; + if (this.#pivotNodeCursor === 0) { + this.#pivotNodeCursor = start; + } + const rowBase = row * n; + + while (this.#pivotNodeCursor < end && work < budget) { + const v = components.nodes[this.#pivotNodeCursor]!; + this.#pivotNodeCursor += 1; + work += 1; + if (v === pivotNode || this.#degrees[v]! <= 1) { + continue; + } + const d = distances[rowBase + v]!; + if (d === 0 || d === INF_DIST) { + continue; + } + this.#shapeTarget(pivotNode, v, d); + this.#pushTerm( + pivotNode, + v, + 1 / (d * d), + this.#shapedLo, + this.#shapedHi, + ); + } + + if (this.#pivotNodeCursor >= end) { + this.#pivotRowCursor += 1; + this.#pivotNodeCursor = 0; + } + } + } + + /** + * CSR weighted Laplacian over the terms, built once per (re)layout/absorb, in + * bounded passes (count / prefix+allocate / fill, the term scans chunked). + * Off-diagonals only; the diagonal lives in its own array (also the Jacobi + * preconditioner). Duplicate (i,j) entries (an edge that is also a pivot + * pair) simply accumulate in the SpMV; no dedupe pass. + */ + #buildLaplacianStep(): void { + const n = this.#n; + const terms = this.#termA.length; + const termA = this.#termA.raw; + const termB = this.#termB.raw; + + if (this.#laplacianPass === 0) { + if (this.#laplacianCursor === 0) { + this.#rowPtr = new Int32Array(n + 1); + } + const end = Math.min(terms, this.#laplacianCursor + TERM_CHUNK); + for (let t = this.#laplacianCursor; t < end; t++) { + this.#rowPtr[termA[t]! + 1]! += 1; + this.#rowPtr[termB[t]! + 1]! += 1; + } + this.#laplacianCursor = end; + if (end >= terms) { + this.#laplacianPass = 1; + this.#laplacianCursor = 0; + } + return; + } + + if (this.#laplacianPass === 1) { + for (let v = 0; v < n; v++) { + this.#rowPtr[v + 1]! += this.#rowPtr[v]!; + } + const nnz = this.#rowPtr[n]!; + this.#colIdx = new Int32Array(nnz); + this.#offDiag = new Float32Array(nnz); + this.#diag = new Float64Array(n); + this.#invDiag = new Float64Array(n); + this.#rowCursor = this.#rowPtr.slice(0, n); + this.#laplacianPass = 2; + this.#laplacianCursor = 0; + return; + } + + if (this.#laplacianPass === 2) { + const cursor = this.#rowCursor; + const weight = this.#termWeight.raw; + const end = Math.min(terms, this.#laplacianCursor + TERM_CHUNK); + for (let t = this.#laplacianCursor; t < end; t++) { + const a = termA[t]!; + const b = termB[t]!; + const w = weight[t]!; + this.#colIdx[cursor[a]!] = b; + this.#offDiag[cursor[a]!] = w; + cursor[a]! += 1; + this.#colIdx[cursor[b]!] = a; + this.#offDiag[cursor[b]!] = w; + cursor[b]! += 1; + this.#diag[a]! += w; + this.#diag[b]! += w; + } + this.#laplacianCursor = end; + if (end >= terms) { + this.#laplacianPass = 3; + } + return; + } + + for (let v = 0; v < n; v++) { + // Term-less nodes (singleton components) have a zero row; they never move. + this.#invDiag[v] = this.#diag[v]! > 0 ? 1 / this.#diag[v]! : 0; + } + + // Zero-allocation iterate loop (worker hot path). + this.#bX = new Float64Array(n); + this.#bY = new Float64Array(n); + this.#solX = new Float64Array(n); + this.#solY = new Float64Array(n); + this.#resX = new Float64Array(n); + this.#resY = new Float64Array(n); + this.#dirX = new Float64Array(n); + this.#dirY = new Float64Array(n); + this.#applyX = new Float64Array(n); + this.#applyY = new Float64Array(n); + this.#prevX = new Float32Array(n); + this.#prevY = new Float32Array(n); + this.#prevX.set(this.#x.subarray(0, n)); + this.#prevY.set(this.#y.subarray(0, n)); + + this.#iteration = 0; + this.#convergedStreak = 0; + this.#capped = false; + this.#bestDisplacement = Number.POSITIVE_INFINITY; + this.#bestDisplacementIteration = 0; + this.#settlePasses = 0; + this.#everFeasible = false; + this.settleCapped = false; + this.#settleCapFinalising = false; + this.residualOverlaps = 0; + this.residualRegionViolations = 0; + this.#rhsCursor = 0; + this.#phase = "rhs"; + } + + /** + * Majorant right-hand side from the current positions: for each term (a, b, w, d*) + * the contribution is w·d*·û along the current separation direction û (a hash-derived + * deterministic direction for exactly-coincident endpoints). This is (L_Z·z) of + * Gansner/Koren/North, computed term-wise in one chunked pass. + */ + #computeRhsChunk(budget: number): void { + if (this.#rhsCursor === 0) { + this.#bX.fill(0); + this.#bY.fill(0); + } + const terms = this.#termA.length; + const termA = this.#termA.raw; + const termB = this.#termB.raw; + const termWeight = this.#termWeight.raw; + const termLo = this.#termLo.raw; + const termHi = this.#termHi.raw; + const end = Math.min(terms, this.#rhsCursor + budget); + for (let t = this.#rhsCursor; t < end; t++) { + const a = termA[t]!; + const b = termB[t]!; + const dx = this.#x[a]! - this.#x[b]!; + const dy = this.#y[a]! - this.#y[b]!; + const distSq = dx * dx + dy * dy; + let target; + let ux; + let uy; + if (distSq > EPS) { + const dist = Math.sqrt(distSq); + // Interval target: inside [lo, hi] the effective target is the current + // distance, so the term's contribution matches L·z exactly and exerts zero + // net force (the IPSep one-sided treatment, generalised to a band). + const lo = termLo[t]!; + const hi = termHi[t]!; + target = dist < lo ? lo : dist > hi ? hi : dist; + const inv = 1 / dist; + ux = dx * inv; + uy = dy * inv; + } else { + target = termLo[t]!; + const angle = coincidentAngle(a, b); + ux = Math.cos(angle); + uy = Math.sin(angle); + } + const c = termWeight[t]! * target; + this.#bX[a]! += c * ux; + this.#bY[a]! += c * uy; + this.#bX[b]! -= c * ux; + this.#bY[b]! -= c * uy; + } + this.#rhsCursor = end; + if (this.#rhsCursor >= terms) { + this.#rhsCursor = 0; + this.#phase = "cg-init"; + } + } + + /** y ← L·v (diag − off-diagonal CSR accumulate). */ + #spmv(v: Float64Array, out: Float64Array): void { + const n = this.#n; + const rowPtr = this.#rowPtr; + const colIdx = this.#colIdx; + const offDiag = this.#offDiag; + const diag = this.#diag; + for (let i = 0; i < n; i++) { + let sum = diag[i]! * v[i]!; + const end = rowPtr[i + 1]!; + for (let k = rowPtr[i]!; k < end; k++) { + sum -= offDiag[k]! * v[colIdx[k]!]!; + } + out[i] = sum; + } + } + + /** + * (Re)start CG warm from the current positions. L is PSD with one constant-vector + * nullspace per weak component; b has zero component sums (it is L_Z·z), so CG is + * consistent and preserves each component's centroid from the warm start. + */ + #initCg(): void { + const n = this.#n; + for (let v = 0; v < n; v++) { + this.#solX[v] = this.#x[v]!; + this.#solY[v] = this.#y[v]!; + } + this.#spmv(this.#solX, this.#applyX); + this.#spmv(this.#solY, this.#applyY); + let rzX = 0; + let rzY = 0; + for (let v = 0; v < n; v++) { + const rx = this.#bX[v]! - this.#applyX[v]!; + const ry = this.#bY[v]! - this.#applyY[v]!; + this.#resX[v] = rx; + this.#resY[v] = ry; + const zx = rx * this.#invDiag[v]!; + const zy = ry * this.#invDiag[v]!; + this.#dirX[v] = zx; + this.#dirY[v] = zy; + rzX += rx * zx; + rzY += ry * zy; + } + this.#rzX = rzX; + this.#rzY = rzY; + this.#rz0X = rzX; + this.#rz0Y = rzY; + this.#cgDoneX = rzX <= EPS; + this.#cgDoneY = rzY <= EPS; + this.#cgStep = 0; + if (this.#cgDoneX && this.#cgDoneY) { + this.#startProject(); + } else { + this.#phase = "cg"; + } + } + + /** One preconditioned-CG step per dimension (bounded: ≤ 2 SpMV per unit). */ + #stepCg(): void { + const relTolSq = CG_RELATIVE_TOLERANCE * CG_RELATIVE_TOLERANCE; + if (!this.#cgDoneX) { + this.#rzX = this.#cgKernel( + this.#solX, + this.#resX, + this.#dirX, + this.#applyX, + this.#rzX, + ); + if (this.#rzX <= relTolSq * this.#rz0X || this.#rzX <= EPS) { + this.#cgDoneX = true; + } + } + if (!this.#cgDoneY) { + this.#rzY = this.#cgKernel( + this.#solY, + this.#resY, + this.#dirY, + this.#applyY, + this.#rzY, + ); + if (this.#rzY <= relTolSq * this.#rz0Y || this.#rzY <= EPS) { + this.#cgDoneY = true; + } + } + this.#cgStep += 1; + if ( + (this.#cgDoneX && this.#cgDoneY) || + this.#cgStep >= this.#options.cgStepsPerIteration + ) { + this.#startProject(); + } + } + + /** One PCG update over one dimension's persistent state; returns the new r·z. */ + #cgKernel( + sol: Float64Array, + res: Float64Array, + dir: Float64Array, + apply: Float64Array, + rz: number, + ): number { + const n = this.#n; + this.#spmv(dir, apply); + let pAp = 0; + for (let i = 0; i < n; i++) { + pAp += dir[i]! * apply[i]!; + } + // Degenerate search direction: treat dimension as converged (pAp ≤ ε avoids + // divide-by-zero and infinite step). + if (pAp <= EPS) { + return 0; + } + const invDiag = this.#invDiag; + const alpha = rz / pAp; + let rzNew = 0; + for (let i = 0; i < n; i++) { + sol[i]! += alpha * dir[i]!; + const r = res[i]! - alpha * apply[i]!; + res[i] = r; + rzNew += r * (r * invDiag[i]!); + } + const beta = rzNew / rz; + for (let i = 0; i < n; i++) { + dir[i] = res[i]! * invDiag[i]! + beta * dir[i]!; + } + return rzNew; + } + + #startProject(): void { + this.#projectStage = "adopt"; + this.#projectPass = 0; + this.#projectMoved = false; + this.#phase = "project"; + } + + /** + * One bounded unit of the iteration boundary: adopt the CG iterate, run the + * region floor (it can create disk overlaps for the relax passes to bleed; + * the reverse order would leave region pushes un-cleaned until next + * iteration), gather stray community members into their region disks (the + * symmetric half; also a disk-overlap source for the relax passes), bleed + * piles with a couple of gentle relax passes (each sliced by pair budget), + * measure displacement, and decide; converge (→ settle), plateau + * (→ settle), cap (log, → settle), or loop (→ rhs). + */ + #projectStep(): void { + switch (this.#projectStage) { + case "adopt": { + const n = this.#n; + for (let v = 0; v < n; v++) { + this.#x[v] = this.#solX[v]!; + this.#y[v] = this.#solY[v]!; + } + let solveDispSq = 0; + for (let v = 0; v < n; v++) { + const dx = this.#x[v]! - this.#prevX[v]!; + const dy = this.#y[v]! - this.#prevY[v]!; + const dispSq = dx * dx + dy * dy; + if (dispSq > solveDispSq) { + solveDispSq = dispSq; + } + } + this.lastSolveDisplacement = Math.sqrt(solveDispSq); + this.#regionSweepStart( + this.#regionMargin, + REGION_RELAX_STRENGTH_ITERATION, + 0, + true, + ); + this.#projectStage = "region"; + return; + } + case "region": { + if (!this.#regionSweepRun()) { + return; + } + this.residualRegionViolations = this.#regionSweepViolations; + if (this.#regionSweepViolations > 0) { + this.#projectMoved = true; + } + this.#gatherStart(); + this.#projectStage = "gather"; + return; + } + case "gather": { + if (!this.#gatherSweepRun()) { + return; + } + if (this.#gatherMoved > 0) { + this.#projectMoved = true; + } + this.#projectStage = "relax-build"; + return; + } + case "relax-build": { + if (this.#projectPass >= ITERATION_RELAX_PASSES) { + this.#projectStage = "finish"; + return; + } + this.#sweep.reset({ + x: this.#x, + y: this.#y, + radii: this.#radii, + count: this.#n, + padding: this.#options.overlapPadding, + strength: ITERATION_RELAX_STRENGTH, + }); + this.#sweep.buildGrid(); + this.#projectStage = "relax-run"; + return; + } + case "relax-run": { + if (!this.#sweep.run(PAIR_BUDGET)) { + return; + } + const { maxMove, overlapsFound } = this.#sweep.result; + this.residualOverlaps = overlapsFound; + if (maxMove === 0) { + this.#projectStage = "finish"; + return; + } + this.#projectMoved = true; + this.#projectPass += 1; + this.#projectStage = "relax-build"; + return; + } + case "finish": { + this.#finishIteration(); + } + } + } + + #finishIteration(): void { + const n = this.#n; + if (this.#projectMoved) { + this.projectionRuns += 1; + } + + let maxDispSq = 0; + let maxDispNode = -1; + for (let v = 0; v < n; v++) { + const dx = this.#x[v]! - this.#prevX[v]!; + const dy = this.#y[v]! - this.#prevY[v]!; + const dispSq = dx * dx + dy * dy; + if (dispSq > maxDispSq) { + maxDispSq = dispSq; + maxDispNode = v; + } + this.#prevX[v] = this.#x[v]!; + this.#prevY[v] = this.#y[v]!; + } + this.#lastMaxDisplacement = Math.sqrt(maxDispSq); + this.lastMaxDisplacementNode = maxDispNode; + this.lastMaxSolveGap = + maxDispNode >= 0 + ? Math.hypot( + this.#solX[maxDispNode]! - this.#x[maxDispNode]!, + this.#solY[maxDispNode]! - this.#y[maxDispNode]!, + ) + : 0; + this.lastProjectDisplacement = Math.max( + 0, + this.#lastMaxDisplacement - this.lastSolveDisplacement, + ); + this.#iteration += 1; + + const threshold = + this.#options.convergenceEpsilon * this.#options.idealEdgeLength; + if (this.#lastMaxDisplacement < threshold) { + this.#convergedStreak += 1; + } else { + this.#convergedStreak = 0; + } + if ( + this.#lastMaxDisplacement < + this.#bestDisplacement * PLATEAU_IMPROVEMENT + ) { + this.#bestDisplacement = this.#lastMaxDisplacement; + this.#bestDisplacementIteration = this.#iteration; + } + + // Stress phase exits on convergence, plateau, or iteration cap; terminal settle + // then seeks verified feasibility (unless {@link settleCapped}; see SETTLE_MAX_PASSES). + if (this.#convergedStreak >= this.#options.convergenceStreak) { + this.#startSettle(); + return; + } + if (this.#iteration - this.#bestDisplacementIteration >= PLATEAU_WINDOW) { + this.#startSettle(); + return; + } + if (this.#iteration >= this.#options.maxIterations) { + this.#capped = true; + // eslint-disable-next-line no-console + console.warn( + `[majorization] capped at ${this.#iteration} iterations ` + + `(maxDisplacement=${this.#lastMaxDisplacement.toFixed(3)}, ` + + `threshold=${threshold.toFixed(3)})`, + ); + this.#startSettle(); + return; + } + this.#phase = "rhs"; + } + + #startSettle(): void { + this.#phase = "settle"; + this.#settleStage = "region"; + this.#settlePrevOverlaps = Number.POSITIVE_INFINITY; + this.#settleScatterCooldown = 0; + this.#settleOverlapsAtLastScatter = Number.POSITIVE_INFINITY; + this.#settleRegionFrozen = false; + this.#settleBestRegionViolations = Number.POSITIVE_INFINITY; + this.#settleRegionStallStreak = 0; + // The stress solve can have re-compacted piles the build-time scatter + // separated (or created new ones); re-scatter before relaxation so the + // settle starts near-feasible instead of diffusing a deep pile apart. + this.scatteredPileNodes += this.#scatterPiles(); + this.#regionSweepStart(0, SETTLE_RELAX_STRENGTH, SETTLE_CLEARANCE); + } + + /** + * One bounded unit of the terminal settle: full-strength relax passes + * (region floor, then disk overlap), one pass per publish generation, each + * sliced by pair/region budgets. The phase ends once a pass proves the + * layout clean; the disk pass triggers on strict intersection, so a pass + * that moved nothing IS a strict-clean proof, and a pre-push region count + * of zero proves the regions clean; or once an interval verification + * (strict overlap + region count) confirms it. Pure separation with no + * opposing force, so termination is structural — with two safety valves for + * the structurally-impossible cases: deep-pile diffusion re-scatters (see + * SETTLE_SCATTER_STALL_RATIO) and unsatisfiable region constraints freeze + * (see REGION_STALL_WINDOW; the disk guarantee survives, the region + * residual is reported honestly). + * + * When the cap is hit, a final verification runs so the residual counts are + * accurate, `settleCapped` latches true, and `#phase` still moves to "done": + * `residualOverlaps` / `residualRegionViolations` may be non-zero and + * `isSettled` is still true. Callers must treat a capped settle as a hard + * failure mode to surface, not a benign alternate exit. + */ + #settleStep(): void { + switch (this.#settleStage) { + case "region": { + if (this.#settleRegionFrozen) { + this.#settleRegionViolations = 0; + } else { + if (!this.#regionSweepRun()) { + return; + } + const violations = this.#regionSweepViolations; + this.#settleRegionViolations = violations; + if (violations < this.#settleBestRegionViolations) { + this.#settleBestRegionViolations = violations; + this.#settleRegionStallStreak = 0; + } else if (violations > 0) { + this.#settleRegionStallStreak += 1; + // Structural interleave (big stuck set) freezes immediately at + // the stall window; rim churn (small set) gets extended patience + // first — see REGION_FREEZE_MIN_VIOLATIONS. + const structural = + violations > + Math.max( + REGION_FREEZE_MIN_VIOLATIONS, + REGION_FREEZE_FRACTION * this.#n, + ); + const patience = + REGION_STALL_WINDOW * + (structural ? 1 : REGION_SMALL_STALL_PATIENCE); + if (this.#settleRegionStallStreak >= patience) { + this.#settleRegionFrozen = true; + this.residualRegionViolations = violations; + // eslint-disable-next-line no-console + console.warn( + `[majorization] region enforcement frozen after ` + + `${this.#settlePasses} settle passes: ${violations} ` + + `violations not improving (interleaved communities); ` + + `settling disks only`, + ); + } + } + } + this.#sweep.reset({ + x: this.#x, + y: this.#y, + radii: this.#radii, + count: this.#n, + padding: 0, + strength: SETTLE_RELAX_STRENGTH, + overshoot: SETTLE_CLEARANCE, + }); + this.#sweep.buildGrid(); + this.#settleStage = "relax-run"; + return; + } + case "relax-run": { + if (!this.#sweep.run(PAIR_BUDGET)) { + return; + } + const { maxMove, overlapsFound } = this.#sweep.result; + this.#settlePasses += 1; + this.projectionRuns += 1; + this.residualOverlaps = overlapsFound; + + if (maxMove === 0 && this.#settleRegionViolations === 0) { + // A full pass that moved nothing proves the layout strictly + // overlap-free (and region-clean, unless enforcement froze — then + // the region residual keeps its last honest measurement). + this.residualOverlaps = 0; + if (!this.#settleRegionFrozen) { + this.residualRegionViolations = 0; + } + this.#everFeasible = true; + this.#phase = "done"; + return; + } + + // Stall detection: near-flat overlap decay on a still-dirty layout. + // Deep piles (which the overcommit detector can prove) are re-placed + // directly; when no pile qualifies the stall is a dense-blob + // percolation jam and the layout expands at cluster granularity + // instead (see COARSE_CELL_FACTOR). + if (this.#settleScatterCooldown > 0) { + this.#settleScatterCooldown -= 1; + } else if ( + overlapsFound > SETTLE_SCATTER_MIN_OVERLAPS && + overlapsFound > this.#settlePrevOverlaps * SETTLE_SCATTER_STALL_RATIO + ) { + this.#settleScatterCooldown = SETTLE_SCATTER_COOLDOWN; + this.#settlePrevOverlaps = overlapsFound; + let scattered = 0; + if ( + overlapsFound < + this.#settleOverlapsAtLastScatter * SETTLE_SCATTER_PROGRESS + ) { + scattered = this.#scatterPiles(); + if (scattered > 0) { + this.scatteredPileNodes += scattered; + this.#settleOverlapsAtLastScatter = overlapsFound; + } + } + if (scattered === 0) { + this.#coarseInit(); + return; + } + } + this.#settlePrevOverlaps = overlapsFound; + + if ( + this.#settlePasses % SETTLE_VERIFY_INTERVAL === 0 || + this.#settlePasses >= SETTLE_MAX_PASSES + ) { + this.#settleCapFinalising = this.#settlePasses >= SETTLE_MAX_PASSES; + this.#sweep.reset({ + x: this.#x, + y: this.#y, + radii: this.#radii, + count: this.#n, + padding: 0, + strength: 0, + }); + this.#sweep.buildGrid(); + this.#settleStage = "verify-run"; + return; + } + this.#regionSweepStart(0, SETTLE_RELAX_STRENGTH, SETTLE_CLEARANCE); + this.#settleStage = "region"; + return; + } + case "verify-run": { + if (!this.#sweep.run(PAIR_BUDGET)) { + return; + } + this.residualOverlaps = this.#sweep.result.overlapsFound; + this.#regionSweepStart(0, 0); + this.#settleStage = "verify-region"; + return; + } + case "verify-region": { + if (!this.#regionSweepRun()) { + return; + } + this.residualRegionViolations = this.#regionSweepViolations; + if ( + this.residualOverlaps === 0 && + (this.residualRegionViolations === 0 || this.#settleRegionFrozen) + ) { + this.#everFeasible = true; + this.#phase = "done"; + return; + } + if (this.#settleCapFinalising) { + this.settleCapped = true; + // eslint-disable-next-line no-console + console.warn( + `[majorization] settle pass cap hit at ${this.#settlePasses} passes: ` + + `${this.residualOverlaps} overlaps, ` + + `${this.residualRegionViolations} region violations remain`, + ); + this.#phase = "done"; + return; + } + this.#regionSweepStart(0, SETTLE_RELAX_STRENGTH, SETTLE_CLEARANCE); + this.#settleStage = "region"; + return; + } + case "coarse-run": { + // One super-disk relax pass per arm: positions persist across + // passes, the grid snapshot is per pass (same pass semantics as the + // fine sweep). Converged (or pass-capped) ⇒ apply the rigid per-cell + // displacements and resume the ordinary region → relax cycle. + if (!this.#coarsePassArmed) { + this.#coarseSweep.reset({ + x: this.#coarseSuperX, + y: this.#coarseSuperY, + radii: this.#coarseSuperR, + count: this.#coarseBucketCount, + padding: 0, + strength: COARSE_RELAX_STRENGTH, + }); + this.#coarseSweep.buildGrid(); + this.#coarsePassArmed = true; + return; + } + if (!this.#coarseSweep.run(PAIR_BUDGET)) { + return; + } + this.#coarsePassArmed = false; + this.#coarsePasses += 1; + if ( + this.#coarseSweep.result.maxMove >= COARSE_RELAX_MIN_MOVE && + this.#coarsePasses < COARSE_RELAX_PASSES + ) { + return; + } + this.#coarseApply(); + this.#regionSweepStart(0, SETTLE_RELAX_STRENGTH, SETTLE_CLEARANCE); + this.#settleStage = "region"; + } + } + } +} + +class MajorizationLayout implements LayoutSimulation { + readonly #nodes: ForceNode[]; + readonly #buffer: FlatGraphBuffer; + readonly #idToIndex = new Map(); + readonly #communities: number[]; + readonly #options: ResolvedOptions; + + #indexEdges: IndexEdge[] = []; + /** Solver coordinates (mutated in place by the solver). Source of truth. */ + #x: Float32Array; + #y: Float32Array; + #radii: Float32Array; + + #solver: MajorizationSolver | null = null; + #status: ForceLayoutStatus; + /** + * Last published solver generation (iterations + settle passes). Starts at 0 so + * nothing is published until the first majorization iterate completes; the + * analysis phases mutate positions incrementally (PivotMDS init, pile + * scatter), and a mid-init frame must never be displayed. + */ + #publishedGeneration = 0; + + // Public diagnostic fields for perf/regression harnesses (iteration count, tick + // budget, residual overlaps). + /** Cumulative wall time (ms) spent in solver ticks. */ + overlapProjectionMs = 0; + /** Majorization iterations completed. */ + overlapProjectionCalls = 0; + /** Worst single tick (ms); the per-tick budget guard. */ + maxTickMs = 0; + /** + * Overlap count at the last measurement (padded pass count during stress, + * strict at settle verifications; see the solver field). Non-zero during + * stress is expected; non-zero after settle indicates {@link settleCapped}. + * Published SAB frames during stress may still show overlaps until + * {@link projectionActive} latches. + */ + overlapsRemaining = 0; + /** Laplacian (re)builds (cold build + every warm absorb/relayout). */ + laplacianRebuilds = 0; + + #absorbedSinceLouvain = 0; + #countAtLastLouvain = 0; + + constructor( + nodes: ForceNode[], + edges: ForceEdge[], + buffer: FlatGraphBuffer, + options: MajorizationLayoutOptions = {}, + ) { + this.#nodes = nodes; + this.#buffer = buffer; + this.#options = { + idealEdgeLength: + options.idealEdgeLength ?? DEFAULT_TUNING.idealEdgeLength, + overlapPadding: options.overlapPadding ?? DEFAULT_TUNING.overlapPadding, + communityCohesion: + options.communityCohesion ?? DEFAULT_TUNING.communityCohesion, + communitySeparation: + options.communitySeparation ?? DEFAULT_TUNING.communitySeparation, + degreeRepulsion: + options.degreeRepulsion ?? DEFAULT_TUNING.degreeRepulsion, + maxIterations: options.maxIterations ?? DEFAULT_TUNING.maxIterations, + convergenceEpsilon: + options.convergenceEpsilon ?? DEFAULT_TUNING.convergenceEpsilon, + convergenceStreak: + options.convergenceStreak ?? DEFAULT_TUNING.convergenceStreak, + cgStepsPerIteration: + options.cgStepsPerIteration ?? DEFAULT_TUNING.cgStepsPerIteration, + // The pivot budget never exceeds the node count (every node a pivot). + pivotCount: Math.min( + nodes.length, + options.pivotCount ?? DEFAULT_TUNING.pivotCount, + ), + }; + + const count = nodes.length; + for (const [index, node] of nodes.entries()) { + this.#idToIndex.set(node.id, index); + } + this.#communities = Array.from({ length: count }).fill(-1); + this.#countAtLastLouvain = count; + + this.#x = new Float32Array(count); + this.#y = new Float32Array(count); + for (let index = 0; index < count; index++) { + this.#x[index] = nodes[index]!.x ?? 0; + this.#y[index] = nodes[index]!.y ?? 0; + } + this.#radii = this.#buildRadii(); + + this.#indexEdges = MajorizationLayout.resolveEdges(edges, this.#idToIndex); + this.#runLouvain(); + + this.#solver = count > 0 ? this.#buildSolver(false) : null; + this.#status = count > 0 ? "running" : "settled"; + this.#writePositions(); + } + + get status(): ForceLayoutStatus { + return this.#status; + } + + get isSettled(): boolean { + return this.#status === "settled"; + } + + get nodes(): readonly ForceNode[] { + return this.#nodes; + } + + get buffer(): SharedArrayBuffer | ArrayBuffer { + return this.#buffer.raw; + } + + get nodeIds(): string[] { + return this.#nodes.map((node) => node.id); + } + + get alpha(): number { + return this.#status === "settled" ? 0 : 1; + } + + /** + * Louvain community id per node, in buffer order: the membership BubbleSets + * group by. Each solver build densifies a snapshot of it for target shaping and + * community-region floors; live relabels reach the solver only at the next + * build (see {@link refreshCommunities}). + */ + get communities(): readonly number[] { + return this.#communities; + } + + /** Deduped edge count after parallel merge. */ + get edgeCount(): number { + return this.#indexEdges.length; + } + + /** Whether the last solve hit the iteration cap instead of converging. */ + get capped(): boolean { + return this.#solver?.capped ?? false; + } + + /** Whether the settle phase hit its pass cap with violations remaining. */ + get settleCapped(): boolean { + return this.#solver?.settleCapped ?? false; + } + + /** Community-region violations at the last measurement. */ + get regionViolations(): number { + return this.#solver?.residualRegionViolations ?? 0; + } + + /** Majorization iterations completed so far. */ + get iterations(): number { + return this.#solver?.iteration ?? 0; + } + + /** The live solver instance, source of phase-timing and projection diagnostics. */ + get solverDiagnostics(): { + readonly phaseCumulativeMs: Partial>; + readonly phaseMaxMs: Partial>; + readonly projectionMs: number; + readonly maxProjectionMs: number; + readonly projectionRuns: number; + readonly lastSolveDisplacement: number; + readonly lastProjectDisplacement: number; + readonly lastMaxDisplacementNode: number; + readonly lastMaxSolveGap: number; + readonly termCount: number; + readonly scatteredPileNodes: number; + } | null { + return this.#solver; + } + + /** Whether published frames are projected (overlap-free) already. */ + get projectionActive(): boolean { + return this.#solver?.projectionActive ?? false; + } + + /** + * Advance the solver by up to `budgetMs` of bounded work units. Returns + * whether positions were committed to the shared buffer this call: the + * solver publishes only at majorization-iteration / settle-pass boundaries, + * so most working ticks return false. Callers use the return to gate frame + * emission (the {@link LayoutSimulation} contract), NOT to detect liveness; + * `isSettled` is the termination signal. (Returning "advanced" here instead + * made the worker rebuild all edge geometry every tick: ~13 ms of emit per + * ~1.5 ms of solve on a 20k graph, and an in-app settle wall ~9x the + * solver's.) + */ + tick(budgetMs: number): boolean { + if (this.#status === "settled" || this.#status === "paused") { + return false; + } + this.#status = "running"; + const startTime = performance.now(); + let stepped = false; + let published = false; + const solver = this.#solver; + + if (solver) { + // while: at least one advance per tick even if the budget is already gone + // (a pre-empted worker can lose >1 ms between taking startTime and the first + // check; never advancing while unsettled would read as a dead layout). + while (!solver.done) { + solver.advance(); + stepped = true; + if (performance.now() - startTime >= budgetMs) { + break; + } + } + this.overlapProjectionCalls = solver.iteration; + this.overlapsRemaining = solver.residualOverlaps; + // Publish at iteration/settle-pass boundaries only. Frames during stress may + // still overlap; overlap-free publish is guaranteed only after + // {@link projectionActive} latches. + if ( + solver.publishGeneration !== this.#publishedGeneration || + (solver.done && stepped) + ) { + this.#writePositions(); + this.#publishedGeneration = solver.publishGeneration; + published = true; + } + if (solver.done) { + this.#status = "settled"; + } + } else { + this.#status = "settled"; + } + + const elapsed = performance.now() - startTime; + this.overlapProjectionMs += elapsed; + if (elapsed > this.maxTickMs) { + this.maxTickMs = elapsed; + } + return published; + } + + pause(): void { + if (this.#status === "running") { + this.#status = "paused"; + } + } + + resume(): void { + if (this.#status === "paused") { + this.#status = "running"; + } + } + + /** + * Absorb newly-arrived nodes without a cold restart: append them (existing indices + * keep their slot so the shared buffer grows in place), rebuild the analysis + + * Laplacian over the new topology, and continue majorization warm from the preserved + * positions (projection re-enables after the first iteration). Refreshes Louvain once + * the layout has grown enough that stale Louvain labels would mis-shape targets. + */ + absorb(newNodes: ForceNode[], edges: ForceEdge[]): void { + const previousCount = this.#nodes.length; + for (const node of newNodes) { + this.#idToIndex.set(node.id, this.#nodes.length); + this.#nodes.push(node); + this.#communities.push(-1); + } + const count = this.#nodes.length; + + const nextX = new Float32Array(count); + const nextY = new Float32Array(count); + nextX.set(this.#x.subarray(0, previousCount)); + nextY.set(this.#y.subarray(0, previousCount)); + for (let index = previousCount; index < count; index++) { + const node = this.#nodes[index]!; + nextX[index] = node.x ?? 0; + nextY[index] = node.y ?? 0; + } + this.#x = nextX; + this.#y = nextY; + this.#radii = this.#buildRadii(); + + this.#indexEdges = MajorizationLayout.resolveEdges(edges, this.#idToIndex); + this.#absorbedSinceLouvain += newNodes.length; + + const refreshAt = Math.max( + LOUVAIN_REFRESH_MIN_NEW_NODES, + Math.ceil(this.#countAtLastLouvain * LOUVAIN_REFRESH_GROWTH_FRACTION), + ); + if (count > 0 && this.#absorbedSinceLouvain >= refreshAt) { + this.#runLouvain(); + this.#absorbedSinceLouvain = 0; + this.#countAtLastLouvain = count; + } else { + this.#labelNewcomersByNeighbors(previousCount); + } + + this.#solver = count > 0 ? this.#buildSolver(true, previousCount) : null; + this.#publishedGeneration = 0; + this.#status = count > 0 ? "running" : "settled"; + this.#writePositions(); + } + + /** + * Provisional community labels for absorbed nodes below the Louvain refresh + * threshold: each newcomer adopts the plurality label among its labeled + * neighbours (ties → smaller label; no labeled neighbour → stays -1, which + * the solver treats as community-less). Between refreshes the labels drive + * live shaping — gather, region floors, target bands — and leaving + * newcomers at -1 makes those forces actively WRONG for them: a + * late-arriving hub stayed unlabeled through a whole stream (the 30 % + * growth refresh needs thousands of nodes at 20k), so member-gather pulled + * its spokes toward their old territories while region eviction pushed the + * hub out of every core — the star was torn apart instead of pulled + * together. Two label-propagation rounds (each an O(m) scan + assignment in + * node-arrival order), so a newcomer wired only to OTHER newcomers of the + * same batch inherits via round two once its neighbours got labeled in + * round one; unlabeled islands (dust) stay -1. The next real Louvain + * refresh replaces all provisional labels. + */ + #labelNewcomersByNeighbors(fromIndex: number): void { + const count = this.#nodes.length; + if (fromIndex >= count) { + return; + } + for (let round = 0; round < 2; round++) { + const votes = new Map>(); + for (const edge of this.#indexEdges) { + for (const [newcomer, other] of [ + [edge.source, edge.target], + [edge.target, edge.source], + ] as const) { + if (newcomer < fromIndex || this.#communities[newcomer]! >= 0) { + continue; + } + const label = this.#communities[other] ?? -1; + if (label < 0) { + continue; + } + const tally = votes.get(newcomer) ?? new Map(); + tally.set(label, (tally.get(label) ?? 0) + 1); + votes.set(newcomer, tally); + } + } + if (votes.size === 0) { + return; + } + for (let index = fromIndex; index < count; index++) { + const tally = votes.get(index); + if (!tally) { + continue; + } + let best = -1; + let bestVotes = 0; + for (const [label, voteCount] of tally) { + if ( + voteCount > bestVotes || + (voteCount === bestVotes && (best < 0 || label < best)) + ) { + best = label; + bestVotes = voteCount; + } + } + this.#communities[index] = best; + } + } + } + + /** + * Force a Louvain refresh if nodes were absorbed since the last one (trailing-edge + * complement to the growth trigger in {@link absorb}). Returns whether it ran. + * + * Relabel-only, never a re-solve: `#communities` (the {@link communities} getter, + * republished for BubbleSets grouping) updates immediately, but the live solver + * keeps the membership snapshot densified into it at build time, with the + * community-shaped target bands and the community-region floor plan baked into + * its terms. Whether that solve is mid-flight (the usual case: the trailing + * debounce that invokes this is far shorter than a solve) or already settled, it + * proceeds exactly as if the relabel had not happened. Positions never move + * because of a refresh; the new labels reach the physics at the next warm solver + * build (the next {@link absorb} or relayout). + * + * The hull/physics divergence this permits is bounded and self-healing: + * {@link absorb} re-runs Louvain before building the solver once growth crosses + * the refresh threshold, so the published membership and the solver's snapshot + * describe graphs that differ by less than one sub-threshold tail of absorbed + * nodes (see LOUVAIN_REFRESH_GROWTH_FRACTION). Worst case a relabeled node keeps + * the spot the old shaping gave it while BubbleSets paint it into its new + * community; the corridor planner ({@link "../../render/bubble-corridors"}) + * still connects it there, so the artifact is a stretched hull, not a wrong + * grouping, and the next absorb re-shapes it away. + * + * Rebuilding the solver here instead would be strictly worse: warm-starting from + * whatever positions the timer happened to catch would make the settled layout + * depend on tick/timer interleaving (breaking the engine's guarantee that + * identical input yields identical output), a rebuild after settle would + * re-animate a resting layout (the post-settle motion this engine exists to + * kill), and the linger caller does not re-kick the tick scheduler, so a solver + * rebuilt after the scheduler stopped would sit unticked forever. + */ + refreshCommunities(): boolean { + if (this.#absorbedSinceLouvain === 0) { + return false; + } + this.#runLouvain(); + this.#absorbedSinceLouvain = 0; + this.#countAtLastLouvain = this.#nodes.length; + return true; + } + + #buildRadii(): Float32Array { + const count = this.#nodes.length; + const radii = new Float32Array(count); + for (let index = 0; index < count; index++) { + radii[index] = this.#nodes[index]!.radius; + } + return radii; + } + + #buildSolver(warm: boolean, newNodesFrom = -1): MajorizationSolver { + const count = this.#nodes.length; + const edgeCount = this.#indexEdges.length; + const src = new Uint32Array(edgeCount); + const dst = new Uint32Array(edgeCount); + for (let index = 0; index < edgeCount; index++) { + const edge = this.#indexEdges[index]!; + src[index] = edge.source; + dst[index] = edge.target; + } + + // Densified Louvain ids (arbitrary ids → dense ints) for target shaping; + // only materialised when a community shaping weight is active. Raw -1 + // (absorbed, not yet labeled) stays -1: densifying it would mint a fake + // community out of ALL unlabeled newcomers, and once a stream's worth of + // them crossed the region-floor size threshold they would be GATHERED + // into one blob regardless of topology (measured: a late hub dragged + // ~4.3k px away from its own spokes, toward the unrelated dust batches it + // happened to share the -1 label with). + const communityActive = + this.#options.communityCohesion > 0 || + this.#options.communitySeparation > 0; + let communities: Int32Array | undefined; + if (communityActive) { + communities = new Int32Array(count); + const denseByRaw = new Map(); + for (let index = 0; index < count; index++) { + const raw = this.#communities[index] ?? -1; + if (raw < 0) { + communities[index] = -1; + continue; + } + let dense = denseByRaw.get(raw); + if (dense === undefined) { + dense = denseByRaw.size; + denseByRaw.set(raw, dense); + } + communities[index] = dense; + } + } + + this.laplacianRebuilds += 1; + return new MajorizationSolver( + { + n: count, + src, + dst, + x: this.#x, + y: this.#y, + radii: this.#radii, + communities, + warm, + newNodesFrom, + }, + { + ...this.#options, + pivotCount: Math.min(count, this.#options.pivotCount), + }, + ); + } + + /** Run seeded Louvain over the link graph; fill `#communities` (singletons if no edges). */ + #runLouvain(): void { + if (this.#indexEdges.length === 0) { + for (let index = 0; index < this.#nodes.length; index++) { + this.#communities[index] = index; + } + return; + } + + const graph = new UndirectedGraph< + Record, + { weight: number } + >(); + for (const node of this.#nodes) { + graph.addNode(node.id); + } + for (const edge of this.#indexEdges) { + graph.mergeEdge( + this.#nodes[edge.source]!.id, + this.#nodes[edge.target]!.id, + { weight: edge.weight }, + ); + } + + const membership = louvain(graph, { + getEdgeWeight: "weight", + randomWalk: false, + rng: parkMillerRng(1), + }); + for (let index = 0; index < this.#nodes.length; index++) { + this.#communities[index] = membership[this.#nodes[index]!.id] ?? index; + } + } + + /** + * Re-centre the solver coordinates on their centroid and publish them to the shared + * buffer + the mirrored ForceNode view (so a warm absorb can read settled coords). + */ + #writePositions(): void { + const count = this.#nodes.length; + + let centroidX = 0; + let centroidY = 0; + for (let index = 0; index < count; index++) { + centroidX += this.#x[index]!; + centroidY += this.#y[index]!; + } + centroidX = count > 0 ? centroidX / count : 0; + centroidY = count > 0 ? centroidY / count : 0; + + for (let index = 0; index < count; index++) { + const localX = this.#x[index]! - centroidX; + const localY = this.#y[index]! - centroidY; + this.#nodes[index]!.x = localX; + this.#nodes[index]!.y = localY; + this.#buffer.setPosition(index, localX, localY); + } + this.#buffer.commit(); + } + + /** Resolve string/object endpoints to index pairs, drop self/dangling, merge parallels. */ + private static resolveEdges( + edges: ForceEdge[], + idToIndex: Map, + ): IndexEdge[] { + const weightByPair = new Map(); + for (const edge of edges) { + const sourceId = + typeof edge.source === "string" ? edge.source : edge.source.id; + const targetId = + typeof edge.target === "string" ? edge.target : edge.target.id; + const source = idToIndex.get(sourceId); + const target = idToIndex.get(targetId); + if (source === undefined || target === undefined || source === target) { + continue; + } + const lo = Math.min(source, target); + const hi = Math.max(source, target); + const key = `${lo}:${hi}`; + const existing = weightByPair.get(key); + weightByPair.set(key, { + source: lo, + target: hi, + weight: (existing?.weight ?? 0) + edge.weight, + }); + } + return [...weightByPair.values()]; + } +} + +/** + * Constructs a budget-sliced stress-majorization layout that implements + * {@link LayoutSimulation}. + * + * Cold construction seeds positions via PivotMDS (degenerate seed piles are + * scattered onto packing spirals; see the module doc) and builds the + * sparse-stress Laplacian once; each `tick` call then advances a bounded unit + * of analysis, term-build, CG, or relaxation work and publishes to `buffer` + * only at majorization-iteration or settle-pass boundaries. Calling `absorb` + * on the returned layout keeps existing positions (warm start), rebuilds the + * analysis and Laplacian over the grown topology, and continues majorization + * without a cold restart. + * + * Deterministic: identical `nodes` / `edges` / `options` produce bitwise-identical + * output. The terminal (settled) layout is guaranteed overlap-free unless the + * returned instance's `settleCapped` diagnostic is set, which signals a hard + * failure of the safety cap rather than a benign alternate exit. See + * {@link MajorizationLayoutOptions} for tunable defaults. + */ +export function createMajorizationLayout( + nodes: ForceNode[], + edges: ForceEdge[], + buffer: FlatGraphBuffer, + options?: MajorizationLayoutOptions, +): LayoutSimulation { + return new MajorizationLayout(nodes, edges, buffer, options); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-scale.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-scale.bench.ts new file mode 100644 index 00000000000..b2324299d2c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-scale.bench.ts @@ -0,0 +1,364 @@ +/* eslint-disable no-console -- committed metrics harness whose whole purpose is to PRINT + the majorization engine's scalability profile table. */ +/** + * Scalability profile for the community-tier stress-majorization engine on + * large graphs (20k real capture, 100k / 200k synthetic scale-ups). + * + * Unlike {@link "./majorization-baseline.bench"} (quality metrics on small + * fixtures), this harness answers two questions about big graphs: + * + * 1. Wall time to settled at the production 1 ms tick cadence, and + * 2. Tick-time distribution (p50/p95/p99/max, frame-budget violations), i.e. + * whether any single tick would freeze the worker long enough to be felt. + * + * Fixtures: + * - `real-20k`: a captured production layout graph + * (fixtures/graph-fixture-20000n-22379e.json), replayed cold (scrambled + * seed positions) — one ~15k giant component whose edge set is dominated + * by four ~3.8k-degree mega-hubs, ~4.1k singleton nodes, a tail of tiny + * fragments. + * - `synthetic-100k` / `synthetic-200k`: the same structural fingerprint + * scaled up (see {@link buildScaledFixture}): 75 % of nodes in one giant + * component, four mega-hubs wired to ~19 % of nodes each, tree-ish + * background at median degree 2, ~21 % singletons, ~4 % tiny fragments. + * + * Run (from apps/hash-frontend): + * node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer/worker/layout/majorization-scale.bench.ts + * + * Env knobs: + * MAJORIZATION_SCALE_SIZES comma list from {20k,100k,200k} (default all) + * MAJORIZATION_SCALE_BUDGET wall budget per fixture in seconds (default 900) + */ +import { readFileSync } from "node:fs"; + +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { mulberry32 } from "../../math/random"; +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { createMajorizationLayout } from "./majorization-layout"; +import { countOverlaps } from "./overlap-relax"; + +import type { + ForceEdge, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; + +/** Radius model of the captured fixture: leaves ~4-5.5 px, mega-hubs ~15.7 px. */ +const radiusForDegree = (degree: number): number => + 4 + Math.min(12, Math.sqrt(Math.max(0, degree)) * 0.19); + +interface ScaleFixture { + readonly name: string; + readonly nodes: ForceNode[]; + readonly edges: ForceEdge[]; +} + +interface CapturedFixtureJson { + readonly nodes: readonly { + readonly id: string; + readonly x: number; + readonly y: number; + readonly radius: number; + }[]; + readonly edges: readonly { + readonly source: string; + readonly target: string; + readonly weight: number; + }[]; +} + +/** + * Cold replay of the captured 20k graph: keep ids/radii/topology, re-seed + * positions with a deterministic phyllotaxis scatter (the captured positions + * are a settled layout; replaying them warm would skip the interesting work). + */ +function loadReal20k(): ScaleFixture { + const raw = readFileSync( + new URL("./fixtures/graph-fixture-20000n-22379e.json", import.meta.url), + "utf8", + ); + const fixture = JSON.parse(raw) as CapturedFixtureJson; + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + const random = mulberry32(20_000); + const nodes: ForceNode[] = fixture.nodes.map((node, index) => { + const distance = 20 * Math.sqrt(index + 1); + const angle = index * goldenAngle + random() * Math.PI * 2; + return { + id: node.id, + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + radius: node.radius, + }; + }); + return { + name: "real-20k", + nodes, + edges: fixture.edges.map((edge) => ({ ...edge })), + }; +} + +/** + * Synthesizes an n-node graph with the captured fixture's structural + * fingerprint (measured on graph-fixture-20000n-22379e.json): + * + * - one giant component holding 75 % of nodes, + * - four mega-hubs, each wired to ~19 % of ALL nodes (⇒ ~15.2k of the 22.4k + * edges at 20k are hub spokes; hubs share endpoints), + * - a spanning tree over the giant component's non-hub nodes at median + * degree 2 (chain-biased random tree), + * - ~21 % singleton nodes (isolated dust), + * - remaining ~4 % in fragments of 2-5 nodes, + * - total edges ≈ 1.12·n. + */ +function buildScaledFixture(name: string, nodeCount: number): ScaleFixture { + const random = mulberry32(nodeCount); + const giantCount = Math.round(nodeCount * 0.75); + const hubCount = 4; + const hubDegree = Math.round(nodeCount * 0.19); + + const edgePairs: [number, number][] = []; + + // Chain-biased random tree over the giant component (nodes [0, giantCount)): + // parent is a recent node 70% of the time (chains), uniform otherwise + // (bushy). Hubs are nodes [0, hubCount). + for (let node = hubCount; node < giantCount; node++) { + const recentSpan = Math.min(node, 32); + const parent = + random() < 0.7 + ? node - 1 - Math.floor(random() * recentSpan) + : Math.floor(random() * node); + edgePairs.push([Math.min(parent, node), Math.max(parent, node)]); + } + + // Mega-hub spokes: each hub picks hubDegree distinct giant-component + // targets. Duplicate (hub, target) pairs are skipped, matching the + // captured graph's deduped edge list. + const seen = new Set(); + for (const [sourceIndex, targetIndex] of edgePairs) { + seen.add(sourceIndex * nodeCount + targetIndex); + } + for (let hub = 0; hub < hubCount; hub++) { + let added = 0; + while (added < hubDegree) { + const target = hubCount + Math.floor(random() * (giantCount - hubCount)); + const lo = Math.min(hub, target); + const hi = Math.max(hub, target); + const key = lo * nodeCount + hi; + if (seen.has(key)) { + // Collisions are rare (hubDegree ≪ giantCount); resample. + added += 1; + continue; + } + seen.add(key); + edgePairs.push([lo, hi]); + added += 1; + } + } + + // Tiny fragments: pair/triple chains over [giantCount, fragmentEnd). + const fragmentCount = Math.round(nodeCount * 0.04); + const fragmentEnd = giantCount + fragmentCount; + let cursor = giantCount; + while (cursor < fragmentEnd - 1) { + const size = Math.min(2 + Math.floor(random() * 4), fragmentEnd - cursor); + for (let link = 1; link < size; link++) { + edgePairs.push([cursor + link - 1, cursor + link]); + } + cursor += size; + } + // Nodes [fragmentEnd, nodeCount) stay isolated (the singleton dust). + + const degree = new Uint32Array(nodeCount); + for (const [sourceIndex, targetIndex] of edgePairs) { + degree[sourceIndex]! += 1; + degree[targetIndex]! += 1; + } + + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + const nodes: ForceNode[] = []; + for (let node = 0; node < nodeCount; node++) { + const distance = 20 * Math.sqrt(node + 1); + const angle = node * goldenAngle; + nodes.push({ + id: String(node), + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + radius: radiusForDegree(degree[node]!), + }); + } + const edges: ForceEdge[] = edgePairs.map(([sourceIndex, targetIndex]) => ({ + source: String(sourceIndex), + target: String(targetIndex), + weight: 1, + })); + return { name, nodes, edges }; +} + +interface ProfileResult { + readonly constructMs: number; + readonly wallMs: number; + readonly ticks: number; + readonly settled: boolean; + readonly tickP50: number; + readonly tickP95: number; + readonly tickP99: number; + readonly tickMax: number; + readonly ticksOver16: number; + readonly ticksOver50: number; + readonly ticksOver100: number; + readonly overlaps: number; +} + +function percentile(sorted: readonly number[], quantile: number): number { + if (sorted.length === 0) { + return 0; + } + const index = Math.min( + sorted.length - 1, + Math.floor(sorted.length * quantile), + ); + return sorted[index]!; +} + +function printDiagnostics( + fixture: ScaleFixture, + layout: LayoutSimulation, + result: ProfileResult, +): void { + const diag = layout as unknown as { + iterations?: number; + capped?: boolean; + settleCapped?: boolean; + edgeCount?: number; + solverDiagnostics?: { + phaseCumulativeMs: Partial>; + phaseMaxMs: Partial>; + projectionMs: number; + maxProjectionMs: number; + projectionRuns: number; + termCount: number; + } | null; + }; + + console.log( + `\n=== scale ${fixture.name}: ${fixture.nodes.length} nodes / ` + + `${fixture.edges.length} edges ===`, + ); + console.log( + `constructMs=${result.constructMs.toFixed(0)} ` + + `wallMs=${result.wallMs.toFixed(0)} ticks=${result.ticks} ` + + `settled=${result.settled} iterations=${diag.iterations} ` + + `capped=${diag.capped}/${diag.settleCapped} overlaps=${result.overlaps}`, + ); + console.log( + `tickMs p50=${result.tickP50.toFixed(2)} p95=${result.tickP95.toFixed(2)} ` + + `p99=${result.tickP99.toFixed(2)} max=${result.tickMax.toFixed(1)} | ` + + `>16ms: ${result.ticksOver16}, >50ms: ${result.ticksOver50}, ` + + `>100ms: ${result.ticksOver100}`, + ); + + const solver = diag.solverDiagnostics; + if (solver) { + const phases = Object.entries(solver.phaseCumulativeMs) + .map( + ([phase, ms]) => + `${phase}=${(ms ?? 0).toFixed(0)}/${( + solver.phaseMaxMs[phase] ?? 0 + ).toFixed(1)}`, + ) + .join(" "); + console.log(`phase cum/maxMs: ${phases}`); + console.log( + `terms=${solver.termCount} projection cum=${solver.projectionMs.toFixed(0)}ms ` + + `max=${solver.maxProjectionMs.toFixed(1)}ms runs=${solver.projectionRuns}`, + ); + } +} + +function profileFixture( + fixture: ScaleFixture, + wallBudgetMs: number, +): ProfileResult { + const constructStart = performance.now(); + const layout = createMajorizationLayout( + fixture.nodes, + fixture.edges, + new FlatGraphBuffer(fixture.nodes.length), + ); + const constructMs = performance.now() - constructStart; + + const tickMs: number[] = []; + const wallStart = performance.now(); + // tick() returns publish-happened (not liveness), so the loop is keyed on + // isSettled alone; the wall budget is the runaway guard. + while (!layout.isSettled && performance.now() - wallStart < wallBudgetMs) { + const start = performance.now(); + layout.tick(1); + tickMs.push(performance.now() - start); + } + const wallMs = performance.now() - wallStart; + + const count = fixture.nodes.length; + const x = new Float32Array(count); + const y = new Float32Array(count); + const radii = new Float32Array(count); + for (const [index, node] of layout.nodes.entries()) { + x[index] = node.x ?? 0; + y[index] = node.y ?? 0; + radii[index] = node.radius; + } + + const sorted = [...tickMs].sort((a, b) => a - b); + const result: ProfileResult = { + constructMs, + wallMs, + ticks: tickMs.length, + settled: layout.isSettled, + tickP50: percentile(sorted, 0.5), + tickP95: percentile(sorted, 0.95), + tickP99: percentile(sorted, 0.99), + tickMax: sorted.length > 0 ? sorted[sorted.length - 1]! : 0, + ticksOver16: tickMs.filter((ms) => ms > 16).length, + ticksOver50: tickMs.filter((ms) => ms > 50).length, + ticksOver100: tickMs.filter((ms) => ms > 100).length, + overlaps: countOverlaps({ x, y, radii, count, padding: 0 }), + }; + + printDiagnostics(fixture, layout, result); + return result; +} + +const requestedSizes = (process.env.MAJORIZATION_SCALE_SIZES ?? "20k,100k,200k") + .split(",") + .map((size) => size.trim()); +const wallBudgetMs = + Number(process.env.MAJORIZATION_SCALE_BUDGET ?? 900) * 1000; + +const fixtures: ScaleFixture[] = []; +if (requestedSizes.includes("20k")) { + fixtures.push(loadReal20k()); +} +if (requestedSizes.includes("100k")) { + fixtures.push(buildScaledFixture("synthetic-100k", 100_000)); +} +if (requestedSizes.includes("200k")) { + fixtures.push(buildScaledFixture("synthetic-200k", 200_000)); +} + +for (const fixture of fixtures) { + profileFixture(fixture, wallBudgetMs); +} + +/** Statistical cross-check on the smallest requested fixture only. */ +describe("majorization scale (smoke)", () => { + bench( + "noop (profiles run at module scope)", + () => { + /* The profile table above is the deliverable. */ + }, + { time: 0, iterations: 1, warmupTime: 0, warmupIterations: 0 }, + ); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-visual.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-visual.bench.ts new file mode 100644 index 00000000000..b7395cffe44 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/majorization-visual.bench.ts @@ -0,0 +1,462 @@ +/* eslint-disable no-console, no-bitwise -- committed diagnostic harness: renders the + settled 20k layout to a PNG (bit ops are the PNG CRC/adler kernels) and PRINTS + community-cohesion metrics for visual regression hunts. */ +/** + * Visual + cohesion snapshot of the majorization engine on a captured + * fixture: settles a cold replay, writes a community-coloured PNG to /tmp, + * and prints per-community spatial cohesion metrics (RMS spread over packing + * radius, and how foreign each member's spatial neighbourhood is). + * + * Run (from apps/hash-frontend): + * node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer/worker/layout/majorization-visual.bench.ts \ + * --disable-console-intercept + * + * Env knobs: + * MAJORIZATION_VISUAL_BUDGET wall budget in seconds (default 300) + * MAJORIZATION_VISUAL_EDGES 1 to draw edges (default on) + * MAJORIZATION_VISUAL_OUT output path (default /tmp/majorization-visual.png) + * MAJORIZATION_VISUAL_FIXTURE captured-fixture JSON path (default the + * committed 20k capture) + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { deflateSync } from "node:zlib"; + +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { mulberry32 } from "../../math/random"; +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { createMajorizationLayout } from "./majorization-layout"; + +import type { ForceEdge, ForceNode } from "./force-simulation"; + +const WALL_BUDGET_MS = + Number(process.env.MAJORIZATION_VISUAL_BUDGET ?? 300) * 1000; +const DRAW_EDGES = process.env.MAJORIZATION_VISUAL_EDGES !== "0"; +const OUT_PATH = + process.env.MAJORIZATION_VISUAL_OUT ?? "/tmp/majorization-visual.png"; +const SIZE = 2048; + +interface CapturedFixtureJson { + readonly nodes: readonly { + readonly id: string; + readonly x: number; + readonly y: number; + readonly radius: number; + }[]; + readonly edges: readonly { + readonly source: string; + readonly target: string; + readonly weight: number; + }[]; +} + +/** Same cold replay as the scale bench: keep topology/radii, scramble seeds. */ +function loadFixture(): { nodes: ForceNode[]; edges: ForceEdge[] } { + const fixturePath = process.env.MAJORIZATION_VISUAL_FIXTURE; + const raw = readFileSync( + fixturePath ?? + new URL("./fixtures/graph-fixture-20000n-22379e.json", import.meta.url), + "utf8", + ); + const fixture = JSON.parse(raw) as CapturedFixtureJson; + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + const random = mulberry32(20_000); + const nodes: ForceNode[] = fixture.nodes.map((node, index) => { + const distance = 20 * Math.sqrt(index + 1); + const angle = index * goldenAngle + random() * Math.PI * 2; + return { + id: node.id, + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + radius: node.radius, + }; + }); + return { nodes, edges: fixture.edges.map((edge) => ({ ...edge })) }; +} + +// ---------------------------------------------------------------- PNG output + +const CRC_TABLE = new Uint32Array(256); +for (let byte = 0; byte < 256; byte++) { + let crc = byte; + for (let bit = 0; bit < 8; bit++) { + crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1; + } + CRC_TABLE[byte] = crc >>> 0; +} + +function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc = CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function pngChunk(type: string, data: Uint8Array): Uint8Array { + const out = new Uint8Array(12 + data.length); + const view = new DataView(out.buffer); + view.setUint32(0, data.length); + for (let i = 0; i < 4; i++) { + out[4 + i] = type.charCodeAt(i); + } + out.set(data, 8); + view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length))); + return out; +} + +/** Encode an RGB image (row-major, 3 bytes/px) as a PNG buffer. */ +function encodePng(rgb: Uint8Array, width: number, height: number): Buffer { + const ihdr = new Uint8Array(13); + const ihdrView = new DataView(ihdr.buffer); + ihdrView.setUint32(0, width); + ihdrView.setUint32(4, height); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // colour type: truecolour + const raw = new Uint8Array(height * (1 + width * 3)); + for (let row = 0; row < height; row++) { + // filter byte 0 per scanline + raw.set( + rgb.subarray(row * width * 3, (row + 1) * width * 3), + row * (1 + width * 3) + 1, + ); + } + const idat = deflateSync(raw, { level: 6 }); + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk("IHDR", ihdr), + pngChunk("IDAT", new Uint8Array(idat)), + pngChunk("IEND", new Uint8Array(0)), + ]); +} + +// ------------------------------------------------------------- rasterisation + +/** Golden-ratio hue walk: distinct saturated colours per big community. */ +function communityColor(rank: number): [number, number, number] { + const hue = (rank * 0.618_033_988_749_895) % 1; + const saturation = 0.75; + const value = 0.95; + const sector = Math.floor(hue * 6); + const fraction = hue * 6 - sector; + const low = value * (1 - saturation); + const falling = value * (1 - fraction * saturation); + const rising = value * (1 - (1 - fraction) * saturation); + const [red, green, blue] = [ + [value, rising, low], + [falling, value, low], + [low, value, rising], + [low, falling, value], + [rising, low, value], + [value, low, falling], + ][sector % 6]!; + return [ + Math.round(red! * 255), + Math.round(green! * 255), + Math.round(blue! * 255), + ]; +} + +interface RenderInput { + readonly nodes: readonly ForceNode[]; + readonly edges: readonly ForceEdge[]; + readonly communities: readonly number[]; + /** Community → palette rank for the big communities; others render gray. */ + readonly paletteRank: ReadonlyMap; + /** Fit the viewport to these node indices (all drawn regardless). */ + readonly focus: readonly number[]; +} + +function render(input: RenderInput): Buffer { + const { nodes } = input; + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const index of input.focus) { + const node = nodes[index]!; + minX = Math.min(minX, node.x ?? 0); + minY = Math.min(minY, node.y ?? 0); + maxX = Math.max(maxX, node.x ?? 0); + maxY = Math.max(maxY, node.y ?? 0); + } + const scale = (SIZE - 40) / Math.max(maxX - minX, maxY - minY, 1); + const offsetX = 20 - minX * scale + (SIZE - 40 - (maxX - minX) * scale) / 2; + const offsetY = 20 - minY * scale + (SIZE - 40 - (maxY - minY) * scale) / 2; + + const rgb = new Uint8Array(SIZE * SIZE * 3).fill(16); + + const blend = ( + px: number, + py: number, + red: number, + green: number, + blue: number, + alpha: number, + ): void => { + if (px < 0 || py < 0 || px >= SIZE || py >= SIZE) { + return; + } + const at = (py * SIZE + px) * 3; + rgb[at] = rgb[at]! + (red - rgb[at]!) * alpha; + rgb[at + 1] = rgb[at + 1]! + (green - rgb[at + 1]!) * alpha; + rgb[at + 2] = rgb[at + 2]! + (blue - rgb[at + 2]!) * alpha; + }; + + if (DRAW_EDGES) { + const indexOfId = new Map(nodes.map((node, index) => [node.id, index])); + for (const edge of input.edges) { + const source = nodes[indexOfId.get(edge.source as string)!]; + const target = nodes[indexOfId.get(edge.target as string)!]; + if (!source || !target) { + continue; + } + const x0 = (source.x ?? 0) * scale + offsetX; + const y0 = (source.y ?? 0) * scale + offsetY; + const x1 = (target.x ?? 0) * scale + offsetX; + const y1 = (target.y ?? 0) * scale + offsetY; + const steps = Math.max( + 1, + Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))), + ); + for (let step = 0; step <= steps; step++) { + const along = step / steps; + blend( + Math.round(x0 + (x1 - x0) * along), + Math.round(y0 + (y1 - y0) * along), + 90, + 90, + 110, + 0.16, + ); + } + } + } + + for (const [index, node] of nodes.entries()) { + const community = input.communities[index] ?? -1; + const rank = input.paletteRank.get(community); + const [red, green, blue] = + rank === undefined ? [140, 140, 140] : communityColor(rank); + const cx = (node.x ?? 0) * scale + offsetX; + const cy = (node.y ?? 0) * scale + offsetY; + const radius = Math.max(0.8, node.radius * scale); + const px0 = Math.floor(cx - radius); + const px1 = Math.ceil(cx + radius); + const py0 = Math.floor(cy - radius); + const py1 = Math.ceil(cy + radius); + for (let py = py0; py <= py1; py++) { + for (let px = px0; px <= px1; px++) { + const dx = px - cx; + const dy = py - cy; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist <= radius + 0.5) { + const alpha = Math.min(1, radius + 0.5 - dist) * 0.95; + blend(px, py, red, green, blue, alpha); + } + } + } + } + + return encodePng(rgb, SIZE, SIZE); +} + +// ------------------------------------------------------------------ metrics + +interface CommunityCohesion { + readonly community: number; + readonly members: number; + /** RMS member distance to community centroid ÷ ideal packing radius: ~1 = + * compact disk, ≫1 = scattered. */ + readonly spread: number; + /** Fraction of members whose 8 nearest spatial neighbours are mostly + * foreign (majority another community): ~0 = spatially coherent. */ + readonly foreign: number; +} + +function cohesionMetrics( + nodes: readonly ForceNode[], + communities: readonly number[], + minMembers: number, +): CommunityCohesion[] { + const memberOf = new Map(); + for (const [index, community] of communities.entries()) { + const list = memberOf.get(community) ?? []; + list.push(index); + memberOf.set(community, list); + } + + // Spatial grid for the neighbourhood-majority metric. + const cell = 24; + const bucketOf = new Map(); + for (const [index, node] of nodes.entries()) { + const key = `${Math.floor((node.x ?? 0) / cell)}:${Math.floor( + (node.y ?? 0) / cell, + )}`; + const bucket = bucketOf.get(key) ?? []; + bucket.push(index); + bucketOf.set(key, bucket); + } + const neighboursOf = (index: number): number[] => { + const node = nodes[index]!; + const baseX = Math.floor((node.x ?? 0) / cell); + const baseY = Math.floor((node.y ?? 0) / cell); + const found: { index: number; distSq: number }[] = []; + for (let ox = -1; ox <= 1; ox++) { + for (let oy = -1; oy <= 1; oy++) { + for (const other of bucketOf.get(`${baseX + ox}:${baseY + oy}`) ?? []) { + if (other === index) { + continue; + } + const dx = (nodes[other]!.x ?? 0) - (node.x ?? 0); + const dy = (nodes[other]!.y ?? 0) - (node.y ?? 0); + found.push({ index: other, distSq: dx * dx + dy * dy }); + } + } + } + found.sort((first, second) => first.distSq - second.distSq); + return found.slice(0, 8).map((entry) => entry.index); + }; + + const result: CommunityCohesion[] = []; + for (const [community, members] of memberOf) { + if (members.length < minMembers) { + continue; + } + let centroidX = 0; + let centroidY = 0; + let areaSq = 0; + for (const member of members) { + centroidX += nodes[member]!.x ?? 0; + centroidY += nodes[member]!.y ?? 0; + const half = nodes[member]!.radius + 4; + areaSq += half * half; + } + centroidX /= members.length; + centroidY /= members.length; + let rmsSq = 0; + let foreignMajority = 0; + for (const member of members) { + const dx = (nodes[member]!.x ?? 0) - centroidX; + const dy = (nodes[member]!.y ?? 0) - centroidY; + rmsSq += dx * dx + dy * dy; + const neighbours = neighboursOf(member); + if (neighbours.length >= 4) { + const foreign = neighbours.filter( + (other) => communities[other] !== community, + ).length; + if (foreign * 2 > neighbours.length) { + foreignMajority += 1; + } + } + } + const packingRadius = Math.sqrt(areaSq / 0.55); + result.push({ + community, + members: members.length, + spread: Math.sqrt(rmsSq / members.length) / packingRadius, + foreign: foreignMajority / members.length, + }); + } + result.sort((first, second) => second.members - first.members); + return result; +} + +// --------------------------------------------------------------------- main + +function run(): void { + const { nodes, edges } = loadFixture(); + + const constructStart = performance.now(); + const layout = createMajorizationLayout( + nodes, + edges, + new FlatGraphBuffer(nodes.length), + ); + const wallStart = performance.now(); + let ticks = 0; + while (!layout.isSettled && performance.now() - wallStart < WALL_BUDGET_MS) { + layout.tick(1); + ticks += 1; + } + const wallMs = performance.now() - wallStart; + console.log( + "\n=== majorization-visual ===\n" + + `constructMs=${(wallStart - constructStart).toFixed(0)} ` + + `wallMs=${wallMs.toFixed(0)} ticks=${ticks} settled=${layout.isSettled}`, + ); + + const communities: readonly number[] = + layout.communities ?? nodes.map((_, index) => index); + const cohesion = cohesionMetrics(layout.nodes, communities, 200); + const paletteRank = new Map( + cohesion.map((entry, rank) => [entry.community, rank]), + ); + console.log("community cohesion (members / spread / foreign-majority):"); + for (const entry of cohesion.slice(0, 12)) { + console.log( + ` c${entry.community}: ${entry.members} / ${entry.spread.toFixed(2)} / ${(entry.foreign * 100).toFixed(1)}%`, + ); + } + + // Fit the viewport to the giant component: dust and fragments are packed + // onto a far larger component grid and would shrink the giant component + // (where every regression lives) to a corner blob. + const indexOfId = new Map(nodes.map((node, index) => [node.id, index])); + const parent = Array.from({ length: nodes.length }, (_, index) => index); + const rootOf = (start: number): number => { + let root = start; + while (parent[root]! !== root) { + parent[root] = parent[parent[root]!]!; + root = parent[root]!; + } + return root; + }; + for (const edge of edges) { + const source = rootOf(indexOfId.get(edge.source as string)!); + const target = rootOf(indexOfId.get(edge.target as string)!); + if (source !== target) { + parent[source] = target; + } + } + const componentSize = new Map(); + for (let index = 0; index < nodes.length; index++) { + const root = rootOf(index); + componentSize.set(root, (componentSize.get(root) ?? 0) + 1); + } + let giantRoot = 0; + let giantSize = 0; + for (const [root, size] of componentSize) { + if (size > giantSize) { + giantRoot = root; + giantSize = size; + } + } + const focus: number[] = []; + for (let index = 0; index < nodes.length; index++) { + if (rootOf(index) === giantRoot) { + focus.push(index); + } + } + console.log(`focus: giant component ${giantSize} nodes`); + + writeFileSync( + OUT_PATH, + render({ nodes: layout.nodes, edges, communities, paletteRank, focus }), + ); + console.log(`wrote ${OUT_PATH}`); +} + +run(); + +describe("majorization visual (smoke)", () => { + bench( + "noop (renders at module scope)", + () => { + /* The PNG + metrics above are the deliverable. */ + }, + { time: 0, iterations: 1, warmupTime: 0, warmupIterations: 0 }, + ); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/overlap-relax.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/overlap-relax.test.ts new file mode 100644 index 00000000000..8628f248c43 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/overlap-relax.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; + +import { + countOverlaps, + overlapRelaxPass, + OverlapSweep, + relaxOverlaps, +} from "./overlap-relax"; + +/** Grid of `side × side` points spaced `spacing` apart, all radius `radius`. */ +function grid( + side: number, + spacing: number, + radius: number, +): { x: Float32Array; y: Float32Array; radii: Float32Array; count: number } { + const count = side * side; + const x = new Float32Array(count); + const y = new Float32Array(count); + const radii = new Float32Array(count).fill(radius); + for (let index = 0; index < count; index++) { + x[index] = (index % side) * spacing; + y[index] = Math.floor(index / side) * spacing; + } + return { x, y, radii, count }; +} + +describe("overlapRelaxPass", () => { + it("resolves all overlaps given enough passes (no-overlap invariant)", () => { + // 6×6 dots of radius 5 spaced only 4 apart: every neighbour overlaps. + const { x, y, radii, count } = grid(6, 4, 5); + expect(countOverlaps({ x, y, radii, count, padding: 0 })).toBeGreaterThan( + 0, + ); + + // Asymptotic convergence: a padding-0 check passes only once separation + // exceeds r_i + r_j by a margin, since relaxation approaches the padded + // target from below. + relaxOverlaps(x, y, radii, count, { + padding: 2, + strength: 0.8, + maxPasses: 200, + minMove: 1e-3, + }); + + expect(countOverlaps({ x, y, radii, count, padding: 0 })).toBe(0); + }); + + it("leaves well-separated nodes untouched (returns zero move)", () => { + const { x, y, radii, count } = grid(5, 100, 5); + const beforeX = Float32Array.from(x); + const beforeY = Float32Array.from(y); + + const { maxMove, overlapsFound } = overlapRelaxPass({ + x, + y, + radii, + count, + padding: 0, + strength: 0.5, + }); + + expect(maxMove).toBe(0); + expect(overlapsFound).toBe(0); + expect([...x]).toEqual([...beforeX]); + expect([...y]).toEqual([...beforeY]); + }); + + it("produces bitwise-identical results when run in budget slices", () => { + const oneShot = grid(8, 4, 5); + const sliced = grid(8, 4, 5); + + const oneShotResult = overlapRelaxPass({ + ...oneShot, + padding: 2, + strength: 0.7, + }); + + const sweep = new OverlapSweep(); + sweep.reset({ ...sliced, padding: 2, strength: 0.7 }); + sweep.buildGrid(); + let steps = 0; + while (!sweep.run(7)) { + steps += 1; + } + + expect(steps).toBeGreaterThan(2); + expect(sweep.result).toEqual(oneShotResult); + expect([...sliced.x]).toEqual([...oneShot.x]); + expect([...sliced.y]).toEqual([...oneShot.y]); + }); + + it("reports the pass's overlap count (counting pass at strength 0)", () => { + const { x, y, radii, count } = grid(4, 4, 5); + const strict = countOverlaps({ x, y, radii, count, padding: 0 }); + const viaPass = overlapRelaxPass({ + x, + y, + radii, + count, + padding: 0, + strength: 0, + }); + expect(viaPass.overlapsFound).toBe(strict); + expect(viaPass.maxMove).toBe(0); + }); + + it("separates exactly-coincident nodes deterministically", () => { + const x = new Float32Array([0, 0, 0]); + const y = new Float32Array([0, 0, 0]); + const radii = new Float32Array([5, 5, 5]); + + relaxOverlaps(x, y, radii, 3, { + padding: 2, + strength: 0.8, + maxPasses: 200, + minMove: 1e-3, + }); + + expect(countOverlaps({ x, y, radii, count: 3, padding: 0 })).toBe(0); + for (let index = 0; index < 3; index++) { + expect(Number.isFinite(x[index]!)).toBe(true); + expect(Number.isFinite(y[index]!)).toBe(true); + } + }); + + it("is deterministic for identical inputs", () => { + const first = grid(6, 4, 5); + const second = grid(6, 4, 5); + relaxOverlaps(first.x, first.y, first.radii, first.count, { + strength: 0.7, + }); + relaxOverlaps(second.x, second.y, second.radii, second.count, { + strength: 0.7, + }); + expect([...first.x]).toEqual([...second.x]); + expect([...first.y]).toEqual([...second.y]); + }); + + it("honours padding (enforces a gap beyond the radii)", () => { + const x = new Float32Array([0, 6]); + const y = new Float32Array([0, 0]); + const radii = new Float32Array([2, 2]); + // radii sum 4, but padding 6 ⇒ required centre distance 10. + relaxOverlaps(x, y, radii, 2, { + padding: 6, + strength: 0.8, + maxPasses: 200, + minMove: 1e-4, + }); + const distance = Math.hypot(x[1]! - x[0]!, y[1]! - y[0]!); + expect(distance).toBeGreaterThan(10 - 0.1); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/overlap-relax.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/overlap-relax.ts new file mode 100644 index 00000000000..ea4b2c1ffa9 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/overlap-relax.ts @@ -0,0 +1,317 @@ +/** + * Node-size overlap resolution for the stress-based layout. + * + * Stress / MDS layouts place nodes to match graph-theoretic distances and carry no + * notion of a node's drawn radius, so dots can overlap. This is a uniform-grid + * relaxation that pushes overlapping pairs apart, run as its own pass between + * solver iterations (the majorization engine's projection step) so the stress + * solve itself stays untouched. `countOverlaps` is the zero-overlap oracle the + * engine's settle verification and the test gates share. + * + * Deterministic: nodes are visited in index order, each unordered pair is resolved + * once, and coincident nodes separate along a hash-derived direction, so a seeded + * layout stays reproducible. + * + * Execution model: the heavy lifting lives in {@link OverlapSweep}, a + * resumable single pass over a {@link UniformGrid} snapshot. The layout engine + * drives it in bounded slices (`run(pairBudget)`) so one pass over 10⁵ nodes + * never freezes a worker tick; slicing changes only when the sweep yields, + * never the visit order, so sliced and one-shot runs produce bitwise-identical + * positions. The one-shot helpers ({@link overlapRelaxPass}, + * {@link countOverlaps}, {@link relaxOverlaps}) wrap a module-level sweep for + * callers without a budget (tests, batch use). + */ + +import { UniformGrid } from "../collections/uniform-grid"; + +const EPS = 1e-6; + +/* eslint-disable no-bitwise */ +/** Deterministic angle in [0, 2π) used to separate exactly-coincident nodes. */ +function coincidentAngle(nodeA: number, nodeB: number): number { + let hash = + (Math.imul(nodeA + 1, 2654435761) ^ Math.imul(nodeB + 1, 40503)) >>> 0; + hash ^= hash >>> 15; + return (hash / 0x1_0000_0000) * Math.PI * 2; +} +/* eslint-enable no-bitwise */ + +export interface OverlapGridInput { + readonly x: Float32Array; + readonly y: Float32Array; + readonly radii: ArrayLike; + readonly count: number; + /** Extra gap enforced beyond `radius_i + radius_j` (world units). */ + readonly padding: number; +} + +export interface OverlapPassInput extends OverlapGridInput { + /** + * Fraction of each overlap corrected per pass, in [0, 1]. Lower = gentler; + * 0 turns the sweep into a pure counting pass (nothing moves). + */ + readonly strength: number; + /** + * Hysteresis: extra clearance (world units) inserted beyond + * `r_i + r_j + padding` when a violated pair is corrected. The trigger + * threshold stays at `padding`, so a pass over a jammed just-touching + * packing only moves genuine violators, while corrected pairs land with + * headroom that float noise and neighbour knock-on cannot immediately + * re-trip (placing pairs exactly at the trigger distance makes the trigger + * metastable: ε-below re-counts as violated forever). @defaultValue 0 + */ + readonly overshoot?: number; +} + +export interface OverlapPassResult { + /** Largest single-node displacement of the pass (0 ⇒ nothing moved). */ + readonly maxMove: number; + /** + * Pairs whose centres were closer than `r_i + r_j + padding` when the + * sweep visited them (pre-correction), i.e. the pass's overlap count. + */ + readonly overlapsFound: number; +} + +/** + * One resumable overlap pass: build a grid snapshot over the current + * positions, then sweep nodes in index order pushing every overlapping pair + * apart symmetrically by `strength · overlap / 2`. + * + * Lifecycle: `reset(input)` → `buildGrid()` (one bounded unit) → `run(budget)` + * until it returns true → read {@link result}. Positions mutate in place + * mid-pass; the grid snapshot is deliberately NOT re-binned (pass semantics: + * every pair is adjudicated against the positions current when it is + * visited, but membership comes from the pass's start-of-pass geometry). + * + * Instances retain their grid buffers across passes; the layout engine owns + * long-lived instances, and the module-level one-shot helpers share one. + */ +export class OverlapSweep { + readonly #grid = new UniformGrid(); + + #x: Float32Array = new Float32Array(0); + #y: Float32Array = new Float32Array(0); + #radii: ArrayLike = []; + #count = 0; + #padding = 0; + #strength = 0; + #overshoot = 0; + + #cursor = 0; + #maxMove = 0; + #overlapsFound = 0; + + /** Arm the sweep for a new pass over `input` (no work done yet). */ + reset(input: OverlapPassInput): void { + this.#x = input.x; + this.#y = input.y; + this.#radii = input.radii; + this.#count = input.count; + this.#padding = input.padding; + this.#strength = input.strength; + this.#overshoot = input.overshoot ?? 0; + this.#cursor = 0; + this.#maxMove = 0; + this.#overlapsFound = 0; + } + + /** + * Snapshot the grid over the current positions: one O(n) unit. Cell size is + * `2·maxRadius + padding` so any overlapping pair lands within one cell of + * each other and the 3×3 scan in {@link run} finds every overlap. + */ + buildGrid(): void { + let maxRadius = 0; + for (let index = 0; index < this.#count; index++) { + const radius = this.#radii[index]!; + if (radius > maxRadius) { + maxRadius = radius; + } + } + const cellSize = Math.max(EPS, 2 * maxRadius + Math.max(0, this.#padding)); + this.#grid.build(this.#x, this.#y, this.#count, cellSize); + } + + /** + * Advance the sweep by roughly `pairBudget` candidate-pair visits (checked + * at node granularity so the deterministic visit order is unaffected). + * Returns true once the pass is complete. + */ + run(pairBudget: number): boolean { + const x = this.#x; + const y = this.#y; + const radii = this.#radii; + const count = this.#count; + const padding = this.#padding; + const strength = this.#strength; + const overshoot = this.#overshoot; + const grid = this.#grid; + const starts = grid.starts; + const order = grid.order; + + let visits = 0; + let maxMove = this.#maxMove; + let found = this.#overlapsFound; + + let nodeA = this.#cursor; + for (; nodeA < count && visits < pairBudget; nodeA++) { + const radiusA = radii[nodeA]!; + const baseCellX = grid.cellXOf(nodeA); + const baseCellY = grid.cellYOf(nodeA); + + for (let offsetX = -1; offsetX <= 1; offsetX++) { + for (let offsetY = -1; offsetY <= 1; offsetY++) { + const bucket = grid.bucketAt( + baseCellX + offsetX, + baseCellY + offsetY, + ); + if (bucket < 0) { + continue; + } + + const end = starts[bucket + 1]!; + for (let member = starts[bucket]!; member < end; member++) { + const nodeB = order[member]!; + // nodeB <= nodeA skips self-pairs and double-counting across the + // 3x3 bucket scan. + if (nodeB <= nodeA) { + continue; + } + visits += 1; + + const minDist = radiusA + radii[nodeB]! + padding; + let deltaX = x[nodeB]! - x[nodeA]!; + let deltaY = y[nodeB]! - y[nodeA]!; + const distSq = deltaX * deltaX + deltaY * deltaY; + if (distSq >= minDist * minDist) { + continue; + } + found += 1; + if (strength === 0) { + continue; + } + + let dist = Math.sqrt(distSq); + if (dist < EPS) { + const angle = coincidentAngle(nodeA, nodeB); + deltaX = Math.cos(angle); + deltaY = Math.sin(angle); + dist = EPS; + } else { + deltaX /= dist; + deltaY /= dist; + } + + const shift = (minDist + overshoot - dist) * 0.5 * strength; + x[nodeA]! -= deltaX * shift; + y[nodeA]! -= deltaY * shift; + x[nodeB]! += deltaX * shift; + y[nodeB]! += deltaY * shift; + if (shift > maxMove) { + maxMove = shift; + } + } + } + } + } + + this.#cursor = nodeA; + this.#maxMove = maxMove; + this.#overlapsFound = found; + return nodeA >= count; + } + + get result(): OverlapPassResult { + return { maxMove: this.#maxMove, overlapsFound: this.#overlapsFound }; + } +} + +/** Shared sweep behind the one-shot helpers (worker/test code is single-threaded). */ +const oneShotSweep = new OverlapSweep(); + +/** + * One complete overlap-relaxation pass (unbudgeted). Mutates `x`/`y` in place; + * see {@link OverlapPassResult} for what comes back. + */ +export function overlapRelaxPass(input: OverlapPassInput): OverlapPassResult { + if (input.count < 2) { + return { maxMove: 0, overlapsFound: 0 }; + } + oneShotSweep.reset(input); + oneShotSweep.buildGrid(); + oneShotSweep.run(Number.POSITIVE_INFINITY); + return oneShotSweep.result; +} + +/** Number of overlapping pairs (centres closer than `radius_i + radius_j + padding`). */ +export function countOverlaps(input: OverlapGridInput): number { + if (input.count < 2) { + return 0; + } + oneShotSweep.reset({ ...input, strength: 0 }); + oneShotSweep.buildGrid(); + oneShotSweep.run(Number.POSITIVE_INFINITY); + return oneShotSweep.result.overlapsFound; +} + +export interface RelaxOverlapsOptions { + /** Extra gap enforced beyond `radius_i + radius_j`, in world units. @defaultValue 0 */ + readonly padding?: number; + /** + * Fraction of each overlap corrected per pass. Lower is gentler but + * converges more slowly. @defaultValue 0.5 + */ + readonly strength?: number; + /** Safety cap on relaxation passes. @defaultValue 40 */ + readonly maxPasses?: number; + /** + * Stop once a pass's largest displacement drops below this (world units). + * @defaultValue 0.05 + */ + readonly minMove?: number; +} + +export interface RelaxOverlapsResult { + readonly passes: number; + readonly lastMaxMove: number; +} + +/** + * Run overlap-relaxation passes until they converge (max displacement below + * `minMove`) or `maxPasses` is reached. Convenience wrapper over + * {@link overlapRelaxPass} for callers that resolve overlap in one shot (tests, + * batch use); the streaming layout drives {@link OverlapSweep} in budget + * slices instead so the separation animates. + */ +export function relaxOverlaps( + x: Float32Array, + y: Float32Array, + radii: ArrayLike, + count: number, + options: RelaxOverlapsOptions = {}, +): RelaxOverlapsResult { + const padding = options.padding ?? 0; + const strength = options.strength ?? 0.5; + const maxPasses = options.maxPasses ?? 40; + const minMove = options.minMove ?? 0.05; + + let passes = 0; + let lastMaxMove = 0; + while (passes < maxPasses) { + lastMaxMove = overlapRelaxPass({ + x, + y, + radii, + count, + padding, + strength, + }).maxMove; + passes += 1; + if (lastMaxMove < minMove) { + break; + } + } + + return { passes, lastMaxMove }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/region-metrics.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/region-metrics.ts new file mode 100644 index 00000000000..b4d88bc674c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/region-metrics.ts @@ -0,0 +1,305 @@ +/** + * Community-region disjointness metrics for the layout engines' A/B benches and + * gates. + * + * Zero disk overlaps does not imply visually separate community regions: a branch + * of one community can fold deep into another community's fan without any pair of + * disks intersecting (classic sparse-stress folding: cross-community pairs carry + * no stress term, so nothing pushes folded branches apart). The bench's inter/intra + * edge-length ratio cannot see this (it only measures edges, and the folded pairs + * share none), which is exactly how region interpenetration shipped while every + * edge metric looked fine. These metrics measure the regions themselves. + * + * Primary metric: `diskContainment`, the fraction of nodes lying strictly inside + * a foreign community's disk, where community c's disk is centred on its member + * centroid with the community's packing radius (the radius of the disk that holds + * the members' drawn areas at the engines' shared utilisation). Chosen as primary + * because it counts the user-visible artifact directly ("blue nodes deep inside + * the green bubble's core"), it is engine-agnostic (positions + radii + a shared + * partition; no per-engine anchor or force model), and it is robust to community + * shape: a stringy community has a small packing disk, so brushing past its tail + * does not count, only intruding into a region's mass does. + * + * Secondary (reported, not gated): convex-hull `foreignContainment` and worst + * pairwise hull intersection-over-min-area. Hulls also see boundary + * interpenetration between region perimeters, but they systematically overcount + * for concave shapes. A crescent community's hull covers its bay, so everything + * inside the bay counts as "contained" even when the drawn bubbles are disjoint. + * Hulls complement the disk metric rather than replace it. + */ + +import type { Position } from "../../geometry"; + +/** + * Minimum community size that casts a measurable region disk; smaller + * communities are excluded from overlap stats. + */ +export const REGION_MIN_COMMUNITY_SIZE = 8; + +/** + * Disk-packing utilisation for a community's region radius. The same fraction + * the layout engines use for their scale-to-fit / hub packing sizing. + */ +export const REGION_PACKING_UTILISATION = 0.55; + +type Point = Position; + +/** Andrew monotone-chain convex hull; returns CCW vertices (no repeated endpoint). */ +export function convexHull(points: readonly Point[]): Point[] { + if (points.length < 3) { + return [...points]; + } + const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y); + const cross = (origin: Point, a: Point, b: Point): number => + (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x); + + const lower: Point[] = []; + for (const point of sorted) { + while ( + lower.length >= 2 && + cross(lower[lower.length - 2]!, lower[lower.length - 1]!, point) <= 0 + ) { + lower.pop(); + } + lower.push(point); + } + const upper: Point[] = []; + for (let i = sorted.length - 1; i >= 0; i--) { + const point = sorted[i]!; + while ( + upper.length >= 2 && + cross(upper[upper.length - 2]!, upper[upper.length - 1]!, point) <= 0 + ) { + upper.pop(); + } + upper.push(point); + } + lower.pop(); + upper.pop(); + return [...lower, ...upper]; +} + +/** Strict interior test against a CCW convex polygon (boundary counts as outside). */ +export function pointInConvexHull( + point: Point, + hull: readonly Point[], +): boolean { + if (hull.length < 3) { + return false; + } + for (let i = 0; i < hull.length; i++) { + const a = hull[i]!; + const b = hull[(i + 1) % hull.length]!; + if ((b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x) <= 0) { + return false; + } + } + return true; +} + +/** Returns the absolute area of a CCW polygon via the shoelace formula. */ +export function polygonArea(polygon: readonly Point[]): number { + let doubled = 0; + for (let i = 0; i < polygon.length; i++) { + const a = polygon[i]!; + const b = polygon[(i + 1) % polygon.length]!; + doubled += a.x * b.y - b.x * a.y; + } + return Math.abs(doubled) / 2; +} + +/** Line-segment / infinite-clip-edge intersection (callers guarantee non-parallel crossing). */ +function intersect(from: Point, to: Point, edgeA: Point, edgeB: Point): Point { + const dcX = edgeA.x - edgeB.x; + const dcY = edgeA.y - edgeB.y; + const dpX = from.x - to.x; + const dpY = from.y - to.y; + const n1 = edgeA.x * edgeB.y - edgeA.y * edgeB.x; + const n2 = from.x * to.y - from.y * to.x; + const denom = dcX * dpY - dcY * dpX; + // Safe: denom is the 2×2 cross of the two edges' direction vectors, zero + // only when the subject and clip edges are parallel; Sutherland-Hodgman's + // convex-clip invariant excludes that case for the crossings this is + // called on. + return { + x: (n1 * dpX - n2 * dcX) / denom, + y: (n1 * dpY - n2 * dcY) / denom, + }; +} + +/** Sutherland-Hodgman clip of a convex polygon by a convex CCW clip polygon. */ +function clipConvex( + subject: readonly Point[], + clip: readonly Point[], +): Point[] { + let output: Point[] = [...subject]; + for (let i = 0; i < clip.length && output.length > 0; i++) { + const edgeA = clip[i]!; + const edgeB = clip[(i + 1) % clip.length]!; + const input = output; + output = []; + const inside = (point: Point): boolean => + (edgeB.x - edgeA.x) * (point.y - edgeA.y) - + (edgeB.y - edgeA.y) * (point.x - edgeA.x) >= + 0; + for (let vertex = 0; vertex < input.length; vertex++) { + const current = input[vertex]!; + const previous = input[(vertex + input.length - 1) % input.length]!; + const currentInside = inside(current); + const previousInside = inside(previous); + if (currentInside) { + if (!previousInside) { + output.push(intersect(previous, current, edgeA, edgeB)); + } + output.push(current); + } else if (previousInside) { + output.push(intersect(previous, current, edgeA, edgeB)); + } + } + } + return output; +} + +export interface RegionNode extends Position { + /** Drawn disk radius (feeds the community packing radius). */ + readonly radius: number; + /** Community label; nodes sharing a label form one region. Negative = unassigned. */ + readonly community: number; +} + +export interface RegionOverlapStats { + /** + * Primary: fraction of all nodes lying strictly inside ≥ 1 foreign community's + * packing disk (centroid-centred, packing radius). + */ + readonly diskContainment: number; + /** Number of nodes counted in {@link diskContainment} (numerator before division). */ + readonly diskContainedNodes: number; + /** Fraction of nodes strictly inside ≥ 1 foreign community's convex hull. */ + readonly hullContainment: number; + /** Worst pairwise hull intersection ÷ min(hull areas) among region-casting communities. */ + readonly hullIntersectionOverMin: number; + /** Communities large enough to cast a region. */ + readonly regionCount: number; +} + +/** + * Region-overlap statistics over a settled layout. Deterministic; O(regions · + * (n + hull²)). Bench/test-scale cost only. + * + * @param overlapPadding - Layout units added to each member's radius before + * squaring for the packing-radius estimate. Default 8; raising it enlarges + * every community's disk and increases containment sensitivity (more + * borderline nodes get flagged as foreign-contained). + */ +export function measureRegionOverlap( + nodes: readonly RegionNode[], + overlapPadding = 8, +): RegionOverlapStats { + const membersByCommunity = new Map(); + for (const node of nodes) { + if (node.community < 0) { + continue; + } + const members = membersByCommunity.get(node.community); + if (members) { + members.push(node); + } else { + membersByCommunity.set(node.community, [node]); + } + } + + interface Region { + readonly community: number; + readonly centroidX: number; + readonly centroidY: number; + readonly packingRadius: number; + readonly hull: Point[]; + readonly area: number; + } + const regions: Region[] = []; + for (const [community, members] of membersByCommunity) { + if (members.length < REGION_MIN_COMMUNITY_SIZE) { + continue; + } + let centroidX = 0; + let centroidY = 0; + let areaSq = 0; + for (const member of members) { + centroidX += member.x; + centroidY += member.y; + const half = member.radius + overlapPadding / 2; + areaSq += half * half; + } + centroidX /= members.length; + centroidY /= members.length; + const hull = convexHull(members); + regions.push({ + community, + centroidX, + centroidY, + packingRadius: Math.sqrt(areaSq / REGION_PACKING_UTILISATION), + hull, + area: hull.length >= 3 ? polygonArea(hull) : 0, + }); + } + // Deterministic order regardless of Map insertion order. + regions.sort((a, b) => a.community - b.community); + + let diskContainedNodes = 0; + let hullContainedNodes = 0; + for (const node of nodes) { + let inForeignDisk = false; + let inForeignHull = false; + for (const region of regions) { + if (region.community === node.community) { + continue; + } + if (!inForeignDisk) { + const dx = node.x - region.centroidX; + const dy = node.y - region.centroidY; + if (dx * dx + dy * dy < region.packingRadius * region.packingRadius) { + inForeignDisk = true; + } + } + if (!inForeignHull && pointInConvexHull(node, region.hull)) { + inForeignHull = true; + } + if (inForeignDisk && inForeignHull) { + break; + } + } + if (inForeignDisk) { + diskContainedNodes += 1; + } + if (inForeignHull) { + hullContainedNodes += 1; + } + } + + let worstIoM = 0; + for (let first = 0; first < regions.length; first++) { + for (let second = first + 1; second < regions.length; second++) { + const a = regions[first]!; + const b = regions[second]!; + const minArea = Math.min(a.area, b.area); + if (minArea <= 0) { + continue; + } + const intersection = polygonArea(clipConvex(a.hull, b.hull)); + const ioM = intersection / minArea; + if (ioM > worstIoM) { + worstIoM = ioM; + } + } + } + + const total = nodes.length; + return { + diskContainment: total > 0 ? diskContainedNodes / total : 0, + diskContainedNodes, + hullContainment: total > 0 ? hullContainedNodes / total : 0, + hullIntersectionOverMin: worstIoM, + regionCount: regions.length, + }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/stress-analysis.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/stress-analysis.test.ts new file mode 100644 index 00000000000..30e97eabb33 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/stress-analysis.test.ts @@ -0,0 +1,173 @@ +/** + * Tests for the graph-analysis pipeline (CSR → weak components → pivot BFS rows → + * PivotMDS init → component packing) that feeds the majorization engine. + * + * Covers analysis-stage behaviour only; solver settle/gate tests are elsewhere in + * the layout test suite. + */ +import { describe, expect, it } from "vitest"; + +import { StressAnalysis } from "./stress-analysis"; + +import type { StressAnalysisOptions } from "./stress-analysis"; + +const expectFiniteCoords = (x: Float32Array, y: Float32Array): void => { + for (let index = 0; index < x.length; index++) { + expect(Number.isFinite(x[index])).toBe(true); + expect(Number.isFinite(y[index])).toBe(true); + } +}; + +/** A path graph 0-1-2-...-(n-1). */ +const pathGraph = ( + nodeCount: number, +): { src: Uint32Array; dst: Uint32Array } => { + const src = new Uint32Array(Math.max(0, nodeCount - 1)); + const dst = new Uint32Array(Math.max(0, nodeCount - 1)); + for (let index = 0; index < nodeCount - 1; index++) { + src[index] = index; + dst[index] = index + 1; + } + return { src, dst }; +}; + +const analyse = ( + nodeCount: number, + src: Uint32Array, + dst: Uint32Array, + options: StressAnalysisOptions = {}, +) => new StressAnalysis({ n: nodeCount, src, dst }, options).run(); + +describe("StressAnalysis", () => { + it("is deterministic for identical inputs (seeded init, no sampling)", () => { + const { src, dst } = pathGraph(120); + const first = analyse(120, src, dst, { idealEdgeLength: 30 }); + const second = analyse(120, src, dst, { idealEdgeLength: 30 }); + + expect(first.x).toEqual(second.x); + expect(first.y).toEqual(second.y); + expect(first.pivots.pivots).toEqual(second.pivots.pivots); + expect(first.pivots.distances).toEqual(second.pivots.distances); + }); + + it("produces the same result under budget-sliced ticking as under run()", () => { + const { src, dst } = pathGraph(200); + const whole = analyse(200, src, dst, { idealEdgeLength: 24 }); + + const sliced = new StressAnalysis( + { n: 200, src, dst }, + { idealEdgeLength: 24 }, + ); + let guard = 0; + while (!sliced.tick({ maxWork: 64 }).done) { + guard += 1; + expect(guard).toBeLessThan(10_000); + } + + expect(sliced.result!.x).toEqual(whole.x); + expect(sliced.result!.y).toEqual(whole.y); + }); + + it("spreads a path graph out (PivotMDS init, no collapse)", () => { + const nodeCount = 64; + const { src, dst } = pathGraph(nodeCount); + const result = analyse(nodeCount, src, dst, { idealEdgeLength: 30 }); + + expectFiniteCoords(result.x, result.y); + // Endpoints of the path must land far apart relative to adjacent nodes. + const endToEnd = Math.hypot( + result.x[nodeCount - 1]! - result.x[0]!, + result.y[nodeCount - 1]! - result.y[0]!, + ); + const adjacent = Math.hypot( + result.x[1]! - result.x[0]!, + result.y[1]! - result.y[0]!, + ); + expect(endToEnd).toBeGreaterThan(adjacent * 5); + }); + + it("exposes exact BFS rows for each pivot", () => { + const nodeCount = 40; + const { src, dst } = pathGraph(nodeCount); + const result = analyse(nodeCount, src, dst); + + const { pivots, distances } = result.pivots; + expect(pivots.length).toBeGreaterThan(0); + for (const [row, pivot] of pivots.entries()) { + for (let node = 0; node < nodeCount; node++) { + // On a path graph the BFS distance is |pivot − node|. + expect(distances[row * nodeCount + node]).toBe(Math.abs(pivot - node)); + } + } + }); + + it("labels weak components and packs them apart", () => { + // Two disjoint 10-node paths. + const nodeCount = 20; + const src = new Uint32Array(18); + const dst = new Uint32Array(18); + for (let index = 0; index < 9; index++) { + src[index] = index; + dst[index] = index + 1; + src[index + 9] = 10 + index; + dst[index + 9] = 10 + index + 1; + } + const result = analyse(nodeCount, src, dst, { idealEdgeLength: 20 }); + + expect(result.components.count).toBe(2); + const labels = result.components.labels; + expect(new Set(labels.subarray(0, 10)).size).toBe(1); + expect(new Set(labels.subarray(10)).size).toBe(1); + expect(labels[0]).not.toBe(labels[10]); + + // Packing must separate the two components' bounding boxes. + const bounds = (from: number, to: number) => { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (let index = from; index < to; index++) { + minX = Math.min(minX, result.x[index]!); + maxX = Math.max(maxX, result.x[index]!); + minY = Math.min(minY, result.y[index]!); + maxY = Math.max(maxY, result.y[index]!); + } + return { minX, maxX, minY, maxY }; + }; + const first = bounds(0, 10); + const second = bounds(10, 20); + const disjointX = first.maxX < second.minX || second.maxX < first.minX; + const disjointY = first.maxY < second.minY || second.maxY < first.minY; + expect(disjointX || disjointY).toBe(true); + }); + + it("handles empty and tiny graphs", () => { + const empty = analyse(0, new Uint32Array(0), new Uint32Array(0)); + expect(empty.x.length).toBe(0); + + const single = analyse(1, new Uint32Array(0), new Uint32Array(0)); + expect(single.x.length).toBe(1); + expectFiniteCoords(single.x, single.y); + + const pair = analyse(2, new Uint32Array([0]), new Uint32Array([1]), { + idealEdgeLength: 40, + }); + expectFiniteCoords(pair.x, pair.y); + expect( + Math.hypot(pair.x[1]! - pair.x[0]!, pair.y[1]! - pair.y[0]!), + ).toBeGreaterThan(0); + }); + + it("warm-continues from supplied positions (keepInitialPositions)", () => { + const x = new Float32Array([10, 20]); + const y = new Float32Array([5, 9]); + + const result = new StressAnalysis( + { n: 2, src: new Uint32Array([0]), dst: new Uint32Array([1]), x, y }, + { jitter: 0, keepInitialPositions: true, packComponents: false }, + ).run(); + + expect(result.x[1]! - result.x[0]!).toBeCloseTo(10, 5); + expect(result.y[1]! - result.y[0]!).toBeCloseTo(4, 5); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/stress-analysis.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/stress-analysis.ts new file mode 100644 index 00000000000..5d680bbbe38 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/stress-analysis.ts @@ -0,0 +1,1739 @@ +/* + * Owns graph analysis and coordinate initialisation for stress majorization: CSR + * build, weak-component decomposition, min-fill/max-min pivot selection with + * per-pivot BFS distance rows, PivotMDS-style coordinate initialisation, and + * disconnected-component packing (skippable via `packComponents`, including on + * warm starts). Layout iteration/solving is out of scope; it lives in + * majorization-layout.ts. + * + * Everything is budget-sliced: `tick({ maxWork, maxMs })` advances the phase machine + * by a bounded number of work units so a large graph never freezes a frame, and the + * whole pipeline is deterministic (seeded tie-breaking, index-ordered scans). + * + * See also: + * - Sparse/pivot stress idea for avoiding all-pairs stress terms: + * Mark Ortmann, Mirza Klimenta, Ulrik Brandes, + * "A Sparse Stress Model" (2017). + * https://jgaa.info/index.php/jgaa/article/view/paper440 + * + * - Landmark/Pivot-MDS-style use of distances from a small set of landmarks: + * Vin de Silva, Joshua B. Tenenbaum, + * "Sparse multidimensional scaling using landmark points" (2004), and + * + * Ulrik Brandes, Christian Pich, + * "Eigensolver Methods for Progressive Multidimensional Scaling of Large Data". + */ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-bitwise */ +/* eslint-disable id-length */ + +import { Column } from "../collections/column"; + +export const INF_DIST = 0xffff; +const MAX_STORED_DIST = 0xfffe; +const TAU = Math.PI * 2; + +export interface StressAnalysisInput { + readonly n: number; + readonly src: Uint32Array; + readonly dst: Uint32Array; + + /** Optional output/input coordinate buffers. If provided, they are mutated. */ + readonly x?: Float32Array; + readonly y?: Float32Array; +} + +export interface StressAnalysisOptions { + /** + * Landmark pivot count. Default follows {@link defaultPivotCount} (0 to 256 + * depending on graph size). Raising it improves distance fidelity at + * O(k·n) BFS and storage cost. + */ + readonly pivotCount?: number; + + /** Layout-space length for one graph hop (scales the PivotMDS init). Default 1. */ + readonly idealEdgeLength?: number; + + /** + * Initial deterministic jitter, in layout units. Default 0.01; raising it + * reduces the chance that pivot-derived coordinates place two nodes at the + * exact same position, at the cost of a noisier seed for the solver to + * unwind. + */ + readonly jitter?: number; + + /** Random/hash seed used only for deterministic jitter and tie breaking. Default 1. */ + readonly randomSeed?: number; + + /** Keep existing x/y (warm start) instead of running the PivotMDS init. Default false. */ + readonly keepInitialPositions?: boolean; + + /** Pack disconnected weak components after the init. Default true. */ + readonly packComponents?: boolean; + + /** Component packing padding in ideal-edge units. Default 4. */ + readonly componentPadding?: number; + + /** Validate node ids and buffer lengths. Default true. */ + readonly validate?: boolean; +} + +/** + * Weakly-connected component decomposition: per-node labels plus CSR-style + * node lists, sizes, and per-component seed nodes (highest degree, tie by + * index). + */ +export class WeakComponents { + readonly count: number; + readonly labels: Int32Array; + readonly offsets: Uint32Array; + readonly nodes: Uint32Array; + readonly sizes: Uint32Array; + readonly seeds: Uint32Array; + + constructor({ + count, + labels, + offsets, + nodes, + sizes, + seeds, + }: { + readonly count: number; + readonly labels: Int32Array; + readonly offsets: Uint32Array; + readonly nodes: Uint32Array; + readonly sizes: Uint32Array; + readonly seeds: Uint32Array; + }) { + this.count = count; + this.labels = labels; + this.offsets = offsets; + this.nodes = nodes; + this.sizes = sizes; + this.seeds = seeds; + } + + static empty(): WeakComponents { + return new WeakComponents({ + count: 0, + labels: new Int32Array(0), + offsets: new Uint32Array(0), + nodes: new Uint32Array(0), + sizes: new Uint32Array(0), + seeds: new Uint32Array(0), + }); + } +} + +/** + * Landmark pivot set: per-pivot BFS distance rows (k × n Uint16), component + * ids, and graph diameter estimate used by init and the term builder. + */ +export class Pivots { + readonly pivots: Uint32Array; + readonly components: Int32Array; + readonly distances: Uint16Array; + readonly diameter: number; + + constructor({ + pivots, + components, + distances, + diameter, + }: { + readonly pivots: Uint32Array; + readonly components: Int32Array; + readonly distances: Uint16Array; + readonly diameter: number; + }) { + this.pivots = pivots; + this.components = components; + this.distances = distances; + this.diameter = diameter; + } + + static unit(): Pivots { + return new Pivots({ + pivots: new Uint32Array(0), + components: new Int32Array(0), + distances: new Uint16Array(0), + diameter: 1, + }); + } +} + +export interface StressAnalysisResult { + readonly x: Float32Array; + readonly y: Float32Array; + + /** Pivot rows including the per-pivot BFS distance matrix (k × n, Uint16). */ + readonly pivots: Pivots; + + readonly components: WeakComponents; +} + +interface CsrGraph { + readonly offsets: Uint32Array; + readonly targets: Uint32Array; + readonly degree: Uint32Array; +} + +type StressAnalysisPhase = + | "setup" + | "weak-csr" + | "components" + | "pivots" + | "init" + | "done"; + +export interface StressAnalysisTickBudget { + /** Approximate unit budget. Edges, nodes, and BFS visits each cost ~1. */ + readonly maxWork?: number; + + /** Optional wall-clock budget for this tick, in milliseconds. */ + readonly maxMs?: number; +} + +export interface StressAnalysisTickResult { + readonly done: boolean; + readonly workDone: number; + readonly elapsedMs: number; + readonly result?: StressAnalysisResult; +} + +const assertNonNegative = (value: number, name: string) => { + if (value < 0) { + throw new Error(`Expected ${name} to be non-negative, got ${value}`); + } + + return value; +}; + +const assertPositive = (value: number, name: string) => { + if (value <= 0) { + throw new Error(`Expected ${name} to be positive, got ${value}`); + } + + return value; +}; + +const validateInput = ({ n, src, dst, x, y }: StressAnalysisInput): void => { + if (!Number.isInteger(n) || n < 0) { + throw new Error("n must be a non-negative integer."); + } + + if (src.length !== dst.length) { + throw new Error("src and dst must have the same length."); + } + + if (x && x.length < n) { + throw new Error("x must have length at least n."); + } + + if (y && y.length < n) { + throw new Error("y must have length at least n."); + } +}; + +/** + * Auto pivot count: 0 for N≤1; min(N,16) for N<128; otherwise + * min(N, max(32, min(256, ⌈2√N⌉))). More pivots improve stress fidelity; each + * adds an n-row BFS and init cost. + */ +const defaultPivotCount = (n: number): number => { + if (n <= 1) { + return 0; + } + + if (n < 128) { + return Math.min(n, 16); + } + + return Math.min(n, Math.max(32, Math.min(256, Math.ceil(Math.sqrt(n) * 2)))); +}; + +/** + * Pivots are only worth spending on components whose internal geometry is + * under-determined by edge terms alone. A component smaller than this places + * fine from its edges + collision floors (and PivotMDS falls back to a local + * scatter for components without pivot rows), while every slot it would + * consume is a BFS row NOT anchoring a big component's global shape. + * + * The failure mode this guards: a captured 20k-node production graph carried + * ~4.5k components (4.1k singleton "dust" nodes + fragment tail) around one + * 15k-node giant; the old at-least-one-per-component rule handed 63 of the 64 + * pivot slots to dust and left the giant component's entire global shape + * anchored by a single BFS row. + */ +const MIN_PIVOT_COMPONENT_SIZE = 8; + +/** + * Distributes requested pivot budget across components: at least one per + * eligible component (size ≥ {@link MIN_PIVOT_COMPONENT_SIZE}; when none + * qualify, every non-empty component is eligible so small graphs keep their + * pivots), then proportional to size, then round-robin the remainder. May + * return fewer than `total` when every eligible component is at capacity. + */ +const allocatePivots = ( + components: WeakComponents, + total: number, +): Uint32Array => { + const cN = components.count; + const alloc = new Uint32Array(cN); + if (total <= 0 || cN === 0) { + return alloc; + } + + const order = new Uint32Array(cN); + for (let c = 0; c < cN; c++) { + order[c] = c; + } + order.sort((a, b) => components.sizes[b]! - components.sizes[a]!); + + let anyEligible = false; + for (let c = 0; c < cN; c++) { + if (components.sizes[c]! >= MIN_PIVOT_COMPONENT_SIZE) { + anyEligible = true; + break; + } + } + const minSize = anyEligible ? MIN_PIVOT_COMPONENT_SIZE : 1; + + let remaining = total; + let active = 0; + + for (const c of order) { + const size = components.sizes[c]!; + if (size < minSize || remaining === 0) { + continue; + } + + alloc[c] = 1; + remaining -= 1; + active += 1; + } + + if (remaining === 0) { + return alloc; + } + + let totalActiveSize = 0; + for (const c of order) { + if (alloc[c]! > 0) { + totalActiveSize += components.sizes[c]!; + } + } + if (active === 0 || totalActiveSize === 0) { + return alloc; + } + + // Only components that received their guarantee participate below, so an + // ineligible fragment never siphons slots in the proportional/round-robin + // phases either. + for (const c of order) { + if (remaining === 0) { + break; + } + const size = components.sizes[c]!; + if (alloc[c]! === 0 || size <= alloc[c]!) { + continue; + } + + const proportional = Math.floor((total * size) / totalActiveSize); + const target = Math.max(alloc[c]!, proportional); + const add = Math.min( + remaining, + Math.max(0, target - alloc[c]!), + size - alloc[c]!, + ); + + alloc[c]! += add; + remaining -= add; + } + + let cursor = 0; + while (remaining > 0) { + const c = order[cursor % order.length]!; + + if (alloc[c]! > 0 && components.sizes[c]! > alloc[c]!) { + alloc[c]! += 1; + remaining -= 1; + } + + cursor += 1; + + if (cursor > order.length * 2 && remaining > 0) { + let changed = false; + + for (const cc of order) { + if (remaining === 0) { + break; + } + + if (alloc[cc]! > 0 && components.sizes[cc]! > alloc[cc]!) { + alloc[cc]! += 1; + remaining -= 1; + changed = true; + } + } + + if (!changed) { + break; + } + } + } + + return alloc; +}; + +const now = () => performance.now(); + +const clampInt = (value: number, lo: number, hi: number): number => { + const v = Math.trunc(value); + if (v < lo) { + return lo; + } + + if (v > hi) { + return hi; + } + return v; +}; + +const hashU32 = (x: number): number => { + x >>>= 0; + x ^= x >>> 16; + x = Math.imul(x, 0x7feb352d); + x ^= x >>> 15; + x = Math.imul(x, 0x846ca68b); + x ^= x >>> 16; + return x >>> 0; +}; + +const hash01 = (x: number): number => hashU32(x) / 0x100000000; + +const recenterComponents = ( + x: Float32Array, + y: Float32Array, + components: WeakComponents, +): void => { + for (let c = 0; c < components.count; c++) { + const start = components.offsets[c]!; + const end = components.offsets[c + 1]!; + const size = end - start; + if (size === 0) { + continue; + } + + let sx = 0; + let sy = 0; + for (let i = start; i < end; i++) { + const v = components.nodes[i]!; + + sx += x[v]!; + sy += y[v]!; + } + + const cx = sx / size; + const cy = sy / size; + for (let i = start; i < end; i++) { + const v = components.nodes[i]!; + x[v]! -= cx; + y[v]! -= cy; + } + } +}; + +const recenterAll = (x: Float32Array, y: Float32Array, n: number): void => { + if (n === 0) { + return; + } + + let sx = 0; + let sy = 0; + for (let i = 0; i < n; i++) { + sx += x[i]!; + sy += y[i]!; + } + const cx = sx / n; + const cy = sy / n; + for (let i = 0; i < n; i++) { + x[i]! -= cx; + y[i]! -= cy; + } +}; + +const packWeakComponents = ( + x: Float32Array, + y: Float32Array, + components: WeakComponents, + padding: number, +): void => { + const cN = components.count; + if (cN <= 1) { + recenterComponents(x, y, components); + return; + } + + const minX = new Float32Array(cN); + const maxX = new Float32Array(cN); + const minY = new Float32Array(cN); + const maxY = new Float32Array(cN); + + for (let c = 0; c < cN; c++) { + minX[c] = Infinity; + minY[c] = Infinity; + maxX[c] = -Infinity; + maxY[c] = -Infinity; + } + + for (let c = 0; c < cN; c++) { + for (let i = components.offsets[c]!; i < components.offsets[c + 1]!; i++) { + const v = components.nodes[i]!; + const xv = x[v]!; + const yv = y[v]!; + if (xv < minX[c]!) { + minX[c] = xv; + } + if (xv > maxX[c]!) { + maxX[c] = xv; + } + if (yv < minY[c]!) { + minY[c] = yv; + } + if (yv > maxY[c]!) { + maxY[c] = yv; + } + } + } + + const order = new Uint32Array(cN); + let totalArea = 0; + + for (let c = 0; c < cN; c++) { + order[c] = c; + const w = Math.max(padding, maxX[c]! - minX[c]! + 2 * padding); + const h = Math.max(padding, maxY[c]! - minY[c]! + 2 * padding); + totalArea += w * h; + } + + order.sort((a, b) => components.sizes[b]! - components.sizes[a]!); + + // Target row width ≈ 1.25·√(sum of component box areas) for a roughly + // square shelf packing. + const targetRowWidth = Math.max(padding, Math.sqrt(totalArea) * 1.25); + const shiftX = new Float32Array(cN); + const shiftY = new Float32Array(cN); + + let cursorX = 0; + let cursorY = 0; + let rowH = 0; + + for (const c of order) { + const w = Math.max(padding, maxX[c]! - minX[c]! + 2 * padding); + const h = Math.max(padding, maxY[c]! - minY[c]! + 2 * padding); + + if (cursorX > 0 && cursorX + w > targetRowWidth) { + cursorX = 0; + cursorY += rowH; + rowH = 0; + } + + shiftX[c] = cursorX + padding - minX[c]!; + shiftY[c] = cursorY + padding - minY[c]!; + + cursorX += w; + if (h > rowH) { + rowH = h; + } + } + + for (let c = 0; c < cN; c++) { + const sx = shiftX[c]!; + const sy = shiftY[c]!; + + for (let i = components.offsets[c]!; i < components.offsets[c + 1]!; i++) { + const v = components.nodes[i]!; + x[v]! += sx; + y[v]! += sy; + } + } + + recenterAll(x, y, components.nodes.length); +}; + +class CsrPhase { + #n: number; + #validate: boolean; + + #degree: Uint32Array; + #offsets: Uint32Array; + #targets: Uint32Array; + #cursor: Uint32Array; + + #edgeCursor = 0; + #nodeCursor = 0; + #prefixTotal = 0; + + #phase: "degree" | "prefix" | "fill" | "done" = "degree"; + + #result: CsrGraph | undefined; + + constructor(n: number, { validate }: { readonly validate: boolean }) { + this.#n = n; + + this.#degree = new Uint32Array(n); + this.#offsets = new Uint32Array(n + 1); + this.#targets = new Uint32Array(0); + this.#cursor = new Uint32Array(n); + + this.#validate = validate; + } + + #computeDegree(src: Uint32Array, dst: Uint32Array, budget: number) { + const m = src.length; + let work = 0; + + while (this.#edgeCursor < m && work < budget) { + const edge = this.#edgeCursor; + this.#edgeCursor += 1; + + const u = src[edge]!; + const v = dst[edge]!; + + if (this.#validate && (u >= this.#n || v >= this.#n)) { + throw new Error(`edge ${edge} has a node id outside [0, n).`); + } + + if (u !== v) { + this.#degree[u]! += 1; + this.#degree[v]! += 1; + } + + work += 1; + } + + if (this.#edgeCursor >= m) { + this.#nodeCursor = 0; + this.#prefixTotal = 0; + this.#phase = "prefix"; + } + + return work; + } + + #computePrefix(budget: number) { + let work = 0; + if (this.#nodeCursor === 0) { + this.#offsets[0] = 0; + } + + while (this.#nodeCursor < this.#n && work < budget) { + this.#prefixTotal += this.#degree[this.#nodeCursor]!; + this.#offsets[this.#nodeCursor + 1] = this.#prefixTotal; + + this.#nodeCursor += 1; + work += 1; + } + + if (this.#nodeCursor >= this.#n) { + this.#targets = new Uint32Array(this.#offsets[this.#n]!); + this.#cursor = this.#offsets.slice(0, this.#n); + + this.#edgeCursor = 0; + this.#phase = "fill"; + } + + return work; + } + + #computeFill(src: Uint32Array, dst: Uint32Array, budget: number) { + const m = src.length; + let work = 0; + + while (this.#edgeCursor < m && work < budget) { + const edge = this.#edgeCursor; + this.#edgeCursor += 1; + + const u = src[edge]!; + const v = dst[edge]!; + + if (u !== v) { + this.#targets[this.#cursor[u]!] = v; + this.#targets[this.#cursor[v]!] = u; + + this.#cursor[u]! += 1; + this.#cursor[v]! += 1; + } + + work += 1; + } + + if (this.#edgeCursor >= m) { + this.#phase = "done"; + + this.#result = { + offsets: this.#offsets, + targets: this.#targets, + degree: this.#degree, + }; + } + + return work; + } + + step(src: Uint32Array, dst: Uint32Array, budget: number) { + let work = 0; + + while (work < budget) { + const remaining = budget - work; + switch (this.#phase) { + case "degree": + work += this.#computeDegree(src, dst, remaining); + break; + case "prefix": + work += this.#computePrefix(remaining); + break; + case "fill": + work += this.#computeFill(src, dst, remaining); + break; + case "done": + return work; + } + } + + return work; + } + + get phase() { + return this.#phase; + } + + get result() { + return this.#result; + } +} + +class WeakComponentsPhase { + readonly #n: number; + + readonly #labels: Int32Array; + readonly #queue: Uint32Array; + readonly #nodes: Uint32Array; + + readonly #offsets: Column; + readonly #sizes: Column; + readonly #seeds: Column; + + #nodeCursor = 0; + + #scan = 0; + #count = 0; + #nodeWrite = 0; + #active = false; + #head = 0; + #tail = 0; + #currentU = -1; + #neighborP = 0; + #neighborEnd = 0; + #best = 0; + #bestDegree = 0; + #size = 0; + + #phase: "init" | "scan" | "done" = "init"; + #result: WeakComponents | undefined; + + constructor(n: number) { + this.#n = n; + + this.#labels = new Int32Array(n); + this.#queue = new Uint32Array(n); + this.#nodes = new Uint32Array(n); + + this.#offsets = new Column(Uint32Array, n); + this.#offsets.push(0); + + this.#sizes = new Column(Uint32Array, n); + this.#seeds = new Column(Uint32Array, n); + } + + #computeInit(budget: number) { + let work = 0; + + while (this.#nodeCursor < this.#n && work < budget) { + this.#labels[this.#nodeCursor] = -1; + this.#nodeCursor += 1; + + work += 1; + } + + if (this.#nodeCursor >= this.#n) { + this.#phase = "scan"; + } + + return work; + } + + #computeScan(csr: CsrGraph, budget: number) { + let work = 0; + + while (work < budget) { + if (!this.#active) { + while ( + this.#scan < this.#n && + this.#labels[this.#scan] !== -1 && + work < budget + ) { + this.#scan += 1; + work += 1; + } + + if (work >= budget) { + break; + } + + if (this.#scan >= this.#n) { + this.#result = new WeakComponents({ + count: this.#count, + labels: this.#labels, + offsets: this.#offsets.subarray().view, + nodes: this.#nodes, + sizes: this.#sizes.subarray().view, + seeds: this.#seeds.subarray().view, + }); + this.#phase = "done"; + + break; + } + + const start = this.#scan; + this.#labels[start] = this.#count; + this.#head = 0; + this.#tail = 1; + this.#queue[0] = start; + this.#best = start; + this.#bestDegree = csr.degree[start]!; + this.#size = 0; + this.#currentU = -1; + this.#active = true; + } + + if (this.#currentU < 0) { + if (this.#head >= this.#tail) { + this.#sizes.push(this.#size); + this.#seeds.push(this.#best); + this.#offsets.push(this.#nodeWrite); + + this.#count += 1; + this.#active = false; + continue; + } + + const u = this.#queue[this.#head]!; + this.#head += 1; + this.#size += 1; + this.#nodes[this.#nodeWrite] = u; + this.#nodeWrite += 1; + + const degree = csr.degree[u]!; + if ( + degree > this.#bestDegree || + (degree === this.#bestDegree && u < this.#best) + ) { + this.#best = u; + this.#bestDegree = degree; + } + + this.#currentU = u; + this.#neighborP = csr.offsets[u]!; + this.#neighborEnd = csr.offsets[u + 1]!; + } + + while (this.#neighborP < this.#neighborEnd && work < budget) { + const v = csr.targets[this.#neighborP]!; + this.#neighborP += 1; + + if (this.#labels[v] === -1) { + this.#labels[v] = this.#count; + + this.#queue[this.#tail] = v; + this.#tail += 1; + } + + work += 1; + } + + if (this.#neighborP >= this.#neighborEnd) { + this.#currentU = -1; + } + } + + return work; + } + + step(csr: CsrGraph, budget: number) { + let work = 0; + + while (work < budget) { + const remaining = budget - work; + switch (this.#phase) { + case "init": + work += this.#computeInit(remaining); + break; + case "scan": + work += this.#computeScan(csr, remaining); + break; + case "done": + return work; + } + } + + return work; + } + + get phase() { + return this.#phase; + } + + get result() { + return this.#result; + } +} + +/** + * Per-component farthest-point (max-min) pivot selection: for each new pivot, + * runs a BFS to fill one distance row, updates `minPivotDist` for every node + * in the component, then picks the next pivot as the node with the largest + * margin (ties broken by a seeded hash). Sliced across `min-fill` → + * `row-fill` → `bfs` → `select` sub-phases so `step` can be budgeted like the + * other phases. + */ +class PivotPhase { + readonly #n: number; + readonly #components: WeakComponents; + readonly #randomSeed: number; + + #requestedPivotCount = 0; + + #alloc: Uint32Array; + #pivotsOut: Uint32Array; + #componentsOut: Int32Array; + #distancesOut: Uint16Array; + #minPivotDist: Uint16Array; + #queue: Uint32Array; + + #k = 0; + #diameter = 1; + + #component = 0; + #local = 0; + #want = 0; + #componentStart = 0; + #componentEnd = 0; + #current = 0; + #tieSalt = 0; + #fillCursor = 0; + #rowFillCursor = 0; + #bfsHead = 0; + #bfsTail = 0; + #bfsCurrentU = -1; + #bfsNeighborP = 0; + #bfsNeighborEnd = 0; + #bfsMaxD = 0; + #selectCursor = 0; + #farthest = 0; + #farthestScore = -1; + + #phase: "min-fill" | "row-fill" | "bfs" | "select" | "done" = "min-fill"; + #result: Pivots | undefined; + + constructor( + n: number, + { + components, + count, + randomSeed, + }: { + readonly components: WeakComponents; + readonly count?: number; + readonly randomSeed: number; + }, + ) { + this.#n = n; + this.#components = components; + this.#randomSeed = randomSeed; + this.#requestedPivotCount = clampInt( + count ?? defaultPivotCount(n), + 0, + this.#n, + ); + + this.#alloc = allocatePivots(components, this.#requestedPivotCount); + this.#pivotsOut = new Uint32Array(this.#requestedPivotCount); + this.#componentsOut = new Int32Array(this.#requestedPivotCount); + this.#distancesOut = new Uint16Array(this.#requestedPivotCount * this.#n); + this.#minPivotDist = new Uint16Array(this.#n); + this.#queue = new Uint32Array(this.#n); + this.#k = 0; + this.#diameter = 1; + this.#component = 0; + + if (this.#requestedPivotCount === 0 || n === 0) { + this.#result = Pivots.unit(); + this.#phase = "done"; + } + + this.#prepareNextComponent(); + } + + #finish() { + this.#result = new Pivots({ + pivots: this.#pivotsOut.slice(0, this.#k), + components: this.#componentsOut.slice(0, this.#k), + distances: this.#distancesOut.slice(0, this.#k * this.#n), + diameter: this.#diameter, + }); + + this.#phase = "done"; + } + + #prepareNextComponent() { + while (this.#component < this.#components.count) { + const want = this.#alloc[this.#component]!; + const size = this.#components.sizes[this.#component]!; + + if (want > 0 && size > 0 && this.#k < this.#requestedPivotCount) { + this.#want = want; + this.#local = 0; + this.#componentStart = this.#components.offsets[this.#component]!; + this.#componentEnd = this.#components.offsets[this.#component + 1]!; + this.#current = this.#components.seeds[this.#component]!; + this.#tieSalt = hashU32( + (this.#randomSeed ^ (this.#component * 0x9e3779b9)) >>> 0, + ); + this.#fillCursor = this.#componentStart; + this.#phase = "min-fill"; + return; + } + + this.#component += 1; + } + + this.#finish(); + } + + #startBfsRow() { + this.#pivotsOut[this.#k] = this.#current; + this.#componentsOut[this.#k] = this.#component; + this.#rowFillCursor = 0; + this.#phase = "row-fill"; + } + + #computeMinFill(budget: number): number { + let work = 0; + + while (this.#fillCursor < this.#componentEnd && work < budget) { + const node = this.#components.nodes[this.#fillCursor]!; + this.#fillCursor += 1; + + this.#minPivotDist[node] = INF_DIST; + work += 1; + } + + if (this.#fillCursor >= this.#componentEnd) { + this.#startBfsRow(); + } + + return work; + } + + #computeRowFill(budget: number): number { + const rowBase = this.#k * this.#n; + let work = 0; + + while (this.#rowFillCursor < this.#n && work < budget) { + this.#distancesOut[rowBase + this.#rowFillCursor] = INF_DIST; + this.#rowFillCursor += 1; + + work += 1; + } + + if (this.#rowFillCursor >= this.#n) { + this.#distancesOut[rowBase + this.#current] = 0; + this.#bfsHead = 0; + this.#bfsTail = 1; + this.#queue[0] = this.#current; + this.#bfsCurrentU = -1; + this.#bfsMaxD = 0; + this.#phase = "bfs"; + } + + return work; + } + + #computeBfs(csr: CsrGraph, budget: number): number { + let work = 0; + const rowBase = this.#k * this.#n; + + while (work < budget) { + if (this.#bfsCurrentU < 0) { + if (this.#bfsHead >= this.#bfsTail) { + if (this.#bfsMaxD > this.#diameter) { + this.#diameter = this.#bfsMaxD; + } + + this.#selectCursor = this.#componentStart; + this.#farthest = this.#current; + this.#farthestScore = -1; + this.#tieSalt = hashU32((this.#tieSalt + this.#local + 1) >>> 0); + this.#phase = "select"; + break; + } + + const u = this.#queue[this.#bfsHead]!; + const du = this.#distancesOut[rowBase + u]!; + + this.#bfsHead += 1; + this.#bfsCurrentU = u; + + // Stop expanding past MAX_STORED_DIST: Uint16 rows use INF_DIST + // (0xffff) as "unreached", and distances at the cap are treated as + // unreachable in init (see `distance()`). + if (du >= MAX_STORED_DIST) { + this.#bfsNeighborP = 0; + this.#bfsNeighborEnd = 0; + } else { + this.#bfsNeighborP = csr.offsets[u]!; + this.#bfsNeighborEnd = csr.offsets[u + 1]!; + } + } + + const u = this.#bfsCurrentU; + const du = this.#distancesOut[rowBase + u]!; + const nd = du + 1; + + while (this.#bfsNeighborP < this.#bfsNeighborEnd && work < budget) { + const v = csr.targets[this.#bfsNeighborP]!; + this.#bfsNeighborP += 1; + + if (this.#distancesOut[rowBase + v] === INF_DIST) { + this.#distancesOut[rowBase + v] = nd; + if (nd > this.#bfsMaxD) { + this.#bfsMaxD = nd; + } + + this.#queue[this.#bfsTail] = v; + this.#bfsTail += 1; + } + + work += 1; + } + + if (this.#bfsNeighborP >= this.#bfsNeighborEnd) { + this.#bfsCurrentU = -1; + } + } + + return work; + } + + #computeSelect(budget: number): number { + const rowBase = this.#k * this.#n; + let work = 0; + + while (this.#selectCursor < this.#componentEnd && work < budget) { + const v = this.#components.nodes[this.#selectCursor]!; + this.#selectCursor += 1; + + const d = this.#distancesOut[rowBase + v]!; + if (d < this.#minPivotDist[v]!) { + this.#minPivotDist[v] = d; + } + + const md = this.#minPivotDist[v]!; + if (md !== INF_DIST) { + // Pack margin md and deterministic tie-break into one integer so + // lexicographic compare is a single scalar max. + const score = md * 1024 + (hashU32((v ^ this.#tieSalt) >>> 0) & 1023); + if (score > this.#farthestScore) { + this.#farthestScore = score; + this.#farthest = v; + } + } + + work += 1; + } + + if (this.#selectCursor >= this.#componentEnd) { + const previous = this.#current; + this.#k += 1; + this.#local += 1; + + if ( + this.#k >= this.#requestedPivotCount || + this.#local >= this.#want || + // Stop early when farthest-point selection stalls (repeats the last + // pivot, or finds no positive margin); the component simply gets + // fewer pivots than its allocation. + this.#farthest === previous || + this.#farthestScore <= 0 + ) { + this.#component += 1; + this.#prepareNextComponent(); + } else { + this.#current = this.#farthest; + this.#startBfsRow(); + } + } + + return work; + } + + step(csr: CsrGraph, budget: number) { + let work = 0; + + while (work < budget) { + const remaining = budget - work; + + switch (this.#phase) { + case "min-fill": + work += this.#computeMinFill(remaining); + break; + case "row-fill": + work += this.#computeRowFill(remaining); + break; + case "bfs": + work += this.#computeBfs(csr, remaining); + break; + case "select": + work += this.#computeSelect(remaining); + break; + case "done": + return work; + } + } + + return work; + } + + get phase() { + return this.#phase; + } + + get result() { + return this.#result; + } +} + +/** + * Assigns x/y from the first four pivot rows per component (x from rows 0-1, + * y from rows 2-3, with fallbacks when fewer pivots exist), then optionally + * shelf-packs weak components. + */ +class InitPhase { + readonly #n: number; + readonly #jitter: number; + readonly #idealEdgeLength: number; + readonly #randomSeed: number; + readonly #keepInitialPositions: boolean; + readonly #shouldPackComponents: boolean; + readonly #componentPadding: number; + + readonly #x: Float32Array; + readonly #y: Float32Array; + + // first4[component*4 + slot] = pivot row index used for PivotMDS axes + // (-1 = unused slot). + #initFirst4: Int32Array | undefined; + #initComponent = 0; + #initNodeCursor = 0; + + #phase: "prepare" | "init" | "pack" | "done" = "prepare"; + + constructor( + n: number, + x: Float32Array, + y: Float32Array, + { + jitter, + idealEdgeLength, + randomSeed, + keepInitialPositions, + shouldPackComponents, + componentPadding, + }: { + readonly jitter: number; + readonly idealEdgeLength: number; + readonly randomSeed: number; + readonly keepInitialPositions: boolean; + readonly shouldPackComponents: boolean; + readonly componentPadding: number; + }, + ) { + this.#n = n; + this.#x = x; + this.#y = y; + + this.#jitter = jitter; + this.#idealEdgeLength = idealEdgeLength; + this.#randomSeed = randomSeed; + this.#keepInitialPositions = keepInitialPositions; + this.#shouldPackComponents = shouldPackComponents; + this.#componentPadding = componentPadding; + } + + #prepareCoordinates(components: WeakComponents, pivots: Pivots): number { + const first4 = new Int32Array(components.count * 4); + first4.fill(-1); + + for (let p = 0; p < pivots.pivots.length; p++) { + const c = pivots.components[p]!; + const base = c * 4; + + for (let slot = 0; slot < 4; slot++) { + if (first4[base + slot] === -1) { + first4[base + slot] = p; + break; + } + } + } + + this.#initFirst4 = first4; + this.#initComponent = 0; + this.#initNodeCursor = components.count > 0 ? components.offsets[0]! : 0; + this.#phase = "init"; + + return 1; + } + + #computeCoordinates( + components: WeakComponents, + pivots: Pivots, + budget: number, + ) { + const jitterScale = this.#idealEdgeLength * this.#jitter; + let work = 0; + + // Treat unreachable / capped-out BFS distances (INF_DIST sentinel) as 0 + // layout offset so PivotMDS axes still place nodes when a pivot row is + // missing or truncated. + const distance = (d: number) => (d === INF_DIST ? 0 : d); + // #initFirst4 is assigned in #prepareCoordinates during the prepare→init + // transition; init phase never runs without it. + const first4 = this.#initFirst4!; + + while (this.#initComponent < components.count && work < budget) { + const end = components.offsets[this.#initComponent + 1]!; + const size = end - components.offsets[this.#initComponent]!; + const base = this.#initComponent * 4; + + const p0 = first4[base]!; + const p1 = first4[base + 1]!; + const p2 = first4[base + 2]!; + const p3 = first4[base + 3]!; + + while (this.#initNodeCursor < end && work < budget) { + const v = components.nodes[this.#initNodeCursor]!; + + if (this.#keepInitialPositions) { + if (jitterScale > 0) { + this.#x[v]! += + (hash01((v ^ (this.#randomSeed * 0x9e3779b1)) >>> 0) - 0.5) * + jitterScale; + this.#y[v]! += + (hash01(((v + 0x27d4eb2d) ^ this.#randomSeed) >>> 0) - 0.5) * + jitterScale; + } + + this.#initNodeCursor += 1; + work += 1; + continue; + } + + let px = 0; + let py = 0; + + if (p0 >= 0 && p1 >= 0) { + const d0 = pivots.distances[p0 * this.#n + v]!; + const d1 = pivots.distances[p1 * this.#n + v]!; + + px = (distance(d0) - distance(d1)) * this.#idealEdgeLength; + } else if (p0 >= 0) { + const d0 = distance(pivots.distances[p0 * this.#n + v]!); + const angle = hash01((v ^ this.#randomSeed) >>> 0) * TAU; + + px = d0 * this.#idealEdgeLength * Math.cos(angle); + py = d0 * this.#idealEdgeLength * Math.sin(angle); + } else { + const local = + this.#initNodeCursor - components.offsets[this.#initComponent]!; + const angle = hash01((v ^ this.#randomSeed) >>> 0) * TAU; + + const r = Math.sqrt(local + 1) * this.#idealEdgeLength; + + px = r * Math.cos(angle); + py = r * Math.sin(angle); + } + + if (p2 >= 0 && p3 >= 0) { + const d2 = distance(pivots.distances[p2 * this.#n + v]!); + const d3 = distance(pivots.distances[p3 * this.#n + v]!); + + py = (distance(d2) - distance(d3)) * this.#idealEdgeLength; + } else if (p2 >= 0 && p0 >= 0 && p1 >= 0) { + const d0 = distance(pivots.distances[p0 * this.#n + v]!); + const d1 = distance(pivots.distances[p1 * this.#n + v]!); + const d2 = distance(pivots.distances[p2 * this.#n + v]!); + + py = (d2 - 0.5 * (d0 + d1)) * this.#idealEdgeLength; + } else if (p1 >= 0) { + const angle = + hash01(((v + 0x85ebca6b) ^ this.#randomSeed) >>> 0) * TAU; + + py = + Math.sin(angle) * + Math.max( + this.#idealEdgeLength, + Math.sqrt(size) * 0.01 * this.#idealEdgeLength, + ); + } + + if (jitterScale > 0) { + px += + (hash01((v ^ (this.#randomSeed * 0x9e3779b1)) >>> 0) - 0.5) * + jitterScale; + py += + (hash01(((v + 0x27d4eb2d) ^ this.#randomSeed) >>> 0) - 0.5) * + jitterScale; + } + + this.#x[v] = px; + this.#y[v] = py; + this.#initNodeCursor += 1; + work += 1; + } + + if (this.#initNodeCursor >= end) { + this.#initComponent += 1; + this.#initNodeCursor = + this.#initComponent < components.count + ? components.offsets[this.#initComponent]! + : 0; + } + } + + if (this.#initComponent >= components.count) { + this.#phase = "pack"; + } + + return work; + } + + #computePack(components: WeakComponents) { + if (this.#shouldPackComponents) { + packWeakComponents( + this.#x, + this.#y, + components, + this.#idealEdgeLength * this.#componentPadding, + ); + } else { + recenterAll(this.#x, this.#y, this.#n); + } + + this.#phase = "done"; + return 1; + } + + step(components: WeakComponents, pivots: Pivots, budget: number): number { + let work = 0; + + while (work < budget) { + const remaining = budget - work; + switch (this.#phase) { + case "prepare": { + work += this.#prepareCoordinates(components, pivots); + break; + } + case "init": { + work += this.#computeCoordinates(components, pivots, remaining); + break; + } + case "pack": { + work += this.#computePack(components); + break; + } + case "done": { + return work; + } + } + } + + return work; + } + + get phase() { + return this.#phase; + } +} + +/** + * The budget-sliced analysis driver: CSR → weak components → pivot selection with + * per-pivot BFS rows → PivotMDS init (+ component packing). `tick` advances by a + * bounded amount of work; the `result` carries the pivot distance matrix the + * majorization term builder samples. + * + * @throws {Error} From the first `tick`/`run` call (not the constructor, since + * validation runs lazily in the `setup` phase) when `options.validate` + * (default `true`) is enabled and `input.n` is not a non-negative integer, + * `input.src`/`input.dst` differ in length, `input.x`/`input.y` are shorter + * than `input.n`, or an edge references a node id outside `[0, input.n)`. + */ +export class StressAnalysis { + readonly #n: number; + readonly #src: Uint32Array; + readonly #dst: Uint32Array; + readonly #validate: boolean; + readonly #pivotCount: number | undefined; + readonly #randomSeed: number; + + #phase: StressAnalysisPhase = "setup"; + #done = false; + #result: StressAnalysisResult | undefined; + + #x: Float32Array; + #y: Float32Array; + + #components: WeakComponents | undefined; + #csr: CsrGraph | undefined; + #pivots: Pivots | undefined; + + #csrPhase: CsrPhase | undefined; + #componentsPhase: WeakComponentsPhase | undefined; + #pivotPhase: PivotPhase | undefined; + + #init: InitPhase; + + constructor(input: StressAnalysisInput, options: StressAnalysisOptions = {}) { + this.#n = input.n; + this.#src = input.src; + this.#dst = input.dst; + this.#validate = options.validate ?? true; + this.#pivotCount = options.pivotCount; + + const idealEdgeLength = assertNonNegative( + options.idealEdgeLength ?? 1, + "idealEdgeLength", + ); + const randomSeed = options.randomSeed ?? 1; + const jitter = options.jitter ?? 0.01; + const shouldPackComponents = options.packComponents ?? true; + const componentPadding = assertNonNegative( + options.componentPadding ?? 4, + "componentPadding", + ); + + this.#x = input.x ?? new Float32Array(input.n); + this.#y = input.y ?? new Float32Array(input.n); + this.#randomSeed = randomSeed; + + this.#init = new InitPhase(this.#n, this.#x, this.#y, { + jitter, + idealEdgeLength, + randomSeed, + keepInitialPositions: options.keepInitialPositions ?? false, + shouldPackComponents, + componentPadding, + }); + } + + #finish(components: WeakComponents, pivots: Pivots): void { + this.#result = { + x: this.#x, + y: this.#y, + pivots, + components, + }; + + this.#phase = "done"; + this.#done = true; + } + + #setup(): number { + if (this.#validate) { + validateInput({ + n: this.#n, + src: this.#src, + dst: this.#dst, + x: this.#x, + y: this.#y, + }); + } + + if (this.#n === 0) { + this.#components = WeakComponents.empty(); + this.#pivots = Pivots.unit(); + + this.#finish(this.#components, this.#pivots); + return 0; + } + + this.#csrPhase = new CsrPhase(this.#n, { validate: this.#validate }); + this.#phase = "weak-csr"; + + return 0; + } + + #step(budget: number): number { + switch (this.#phase) { + case "setup": + return this.#setup(); + case "weak-csr": { + const phase = this.#csrPhase!; + const done = phase.step(this.#src, this.#dst, budget); + + if (phase.phase === "done") { + this.#csr = phase.result!; + this.#csrPhase = undefined; + this.#componentsPhase = new WeakComponentsPhase(this.#n); + this.#phase = "components"; + } + + return done; + } + + case "components": { + const phase = this.#componentsPhase!; + const done = phase.step(this.#csr!, budget); + + if (phase.phase === "done") { + this.#components = phase.result!; + this.#componentsPhase = undefined; + this.#pivotPhase = new PivotPhase(this.#n, { + randomSeed: this.#randomSeed, + components: this.#components, + count: this.#pivotCount, + }); + this.#phase = "pivots"; + } + + return done; + } + + case "pivots": { + const phase = this.#pivotPhase!; + const done = phase.step(this.#csr!, budget); + + if (phase.phase === "done") { + this.#pivots = phase.result!; + // Unmount early: pivoting owns relatively large scratch buffers that + // are best dropped as soon as the rows are extracted. + this.#pivotPhase = undefined; + this.#phase = "init"; + } + + return done; + } + + case "init": { + const previousPhase = this.#init.phase; + const done = this.#init.step(this.#components!, this.#pivots!, budget); + + if (this.#init.phase === "done" && previousPhase !== "done") { + this.#finish(this.#components!, this.#pivots!); + } + return done; + } + + case "done": + return 0; + } + } + + /** + * Advances the phase machine by up to `maxWork` units. Returns `done: + * false` with partial progress when the wall-clock budget (`maxMs`) is hit + * before `maxWork` is exhausted. If a phase makes more than 64 consecutive + * zero-work transitions without changing phase, this returns early with + * `done: false` instead of throwing; callers must keep ticking or treat a + * stuck phase as fatal themselves ({@link StressAnalysis.run} throws in + * that situation). + * + * @throws {Error} The validation errors documented on + * {@link StressAnalysis} (first call only). + */ + tick(budget: StressAnalysisTickBudget = {}): StressAnalysisTickResult { + const maxWork = assertPositive( + Math.floor(budget.maxWork ?? 50_000), + "maxWork", + ); + const maxMs = assertNonNegative(budget.maxMs ?? Infinity, "maxMs"); + + const start = now(); + let workDone = 0; + let zeroWorkTransitions = 0; + + while (!this.#done && workDone < maxWork) { + // Apply maxMs only after this tick has performed at least one work + // unit, so a single call always makes progress. + if (workDone > 0 && now() - start >= maxMs) { + break; + } + + const beforePhase = this.#phase; + const did = this.#step(maxWork - workDone); + + if (did > 0) { + workDone += did; + zeroWorkTransitions = 0; + } else { + zeroWorkTransitions++; + // Bail after 64 no-progress ticks to avoid spinning when a phase + // cannot make forward work under the current budget. + if (beforePhase === this.#phase || zeroWorkTransitions > 64) { + break; + } + } + } + + return { + done: this.#done, + workDone, + elapsedMs: now() - start, + result: this.#result, + }; + } + + /** + * Runs the analysis to completion synchronously, ignoring any work or time + * budget. + * + * @throws {Error} The validation errors documented on + * {@link StressAnalysis} (first call only), or when a phase makes no + * progress for more than 64 consecutive steps (a stalled phase is a bug, + * not a valid outcome for `run`). + */ + run(): StressAnalysisResult { + let zeroWorkTransitions = 0; + while (!this.#done) { + const beforePhase = this.#phase; + const did = this.#step(Infinity); + + if (did > 0) { + zeroWorkTransitions = 0; + } else { + zeroWorkTransitions += 1; + if (beforePhase === this.#phase || zeroWorkTransitions > 64) { + throw new Error(`StressAnalysis stalled in phase ${this.#phase}.`); + } + } + } + + return this.#result!; + } + + get result(): StressAnalysisResult | undefined { + return this.#result; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/top-level-layout.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/top-level-layout.test.ts new file mode 100644 index 00000000000..33571711b8a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/top-level-layout.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it } from "vitest"; + +import { + type Anchor, + type LayoutNode, + measureLayout, + optimizeTopLevel, + relaxOverlaps, +} from "./top-level-layout"; + +/** A regular polygon of `count` equal bubbles at radius `ring` from the origin. */ +function polygon(count: number, ring: number, radius: number): LayoutNode[] { + const nodes: LayoutNode[] = []; + for (let idx = 0; idx < count; idx++) { + const angle = (idx / count) * 2 * Math.PI; + nodes.push({ + x: Math.cos(angle) * ring, + y: Math.sin(angle) * ring, + radius, + }); + } + return nodes; +} + +/** Ring edges (0-1, 1-2, ..., n-1-0): a crossing-free cycle for the polygon. */ +function ringEdges(count: number): [number, number][] { + // Tuple cast: Array.from infers number[]; edge endpoints are always valid + // [number, number] pairs. + return Array.from( + { length: count }, + (_, idx) => [idx, (idx + 1) % count] as [number, number], + ); +} + +/** Mean-removed displacement of each node from its anchor (translation-invariant). */ +function relativeDisplacements( + nodes: readonly LayoutNode[], + anchors: readonly (Anchor | null)[], +): number[] { + let dxSum = 0; + let dySum = 0; + let anchored = 0; + for (let idx = 0; idx < nodes.length; idx++) { + const anchor = anchors[idx]; + if (!anchor) { + continue; + } + dxSum += nodes[idx]!.x - anchor.x; + dySum += nodes[idx]!.y - anchor.y; + anchored += 1; + } + const meanX = anchored > 0 ? dxSum / anchored : 0; + const meanY = anchored > 0 ? dySum / anchored : 0; + return nodes.map((node, idx) => { + const anchor = anchors[idx]; + if (!anchor) { + return Number.NaN; + } + return Math.hypot(node.x - anchor.x - meanX, node.y - anchor.y - meanY); + }); +} + +describe("optimizeTopLevel", () => { + it("clears an edge forced straight through a huge obstacle bubble", () => { + // A↔B connected, with a HUGE bubble C parked on the A-B line between them + // (C↔D keeps C placeable). This is the "Of Material wraps around Material + // Movement" case: the straight edge pierces the obstacle. + const nodes: LayoutNode[] = [ + { x: 0, y: 0, radius: 10 }, // A + { x: 160, y: 0, radius: 10 }, // B + { x: 80, y: 0, radius: 50 }, // C: huge, parked between A and B + { x: 80, y: 150, radius: 10 }, // D + ]; + const edges: ReadonlyArray = [ + [0, 1], // A-B + [2, 3], // C-D + ]; + + const before = measureLayout(nodes, edges); + optimizeTopLevel(nodes, edges, 12345); + const after = measureLayout(nodes, edges); + + // eslint-disable-next-line no-console + console.log( + `[toplevel] detour ${before.detour.toFixed(2)} → ${after.detour.toFixed(2)}, energy ${before.energy.toFixed(1)} → ${after.energy.toFixed(1)}`, + ); + expect(before.detour).toBeGreaterThan(0.8); // edge pierced the obstacle + expect(after.detour).toBeLessThan(0.2); // optimiser cleared it + expect(after.energy).toBeLessThan(before.energy); + for (const node of nodes) { + expect(Number.isFinite(node.x)).toBe(true); + expect(Number.isFinite(node.y)).toBe(true); + } + }); + + it("uncrosses two crossing edges", () => { + // Square with the two diagonals as edges → they cross. A reposition/swap + // makes them parallel. + const nodes: LayoutNode[] = [ + { x: 0, y: 0, radius: 10 }, // A + { x: 100, y: 100, radius: 10 }, // B + { x: 100, y: 0, radius: 10 }, // C + { x: 0, y: 100, radius: 10 }, // D + ]; + const edges: ReadonlyArray = [ + [0, 1], // A-B (diagonal) + [2, 3], // C-D (diagonal) + ]; + + const before = measureLayout(nodes, edges); + optimizeTopLevel(nodes, edges, 777); + const after = measureLayout(nodes, edges); + + // eslint-disable-next-line no-console + console.log( + `[toplevel] crossings ${before.crossings} → ${after.crossings}`, + ); + expect(before.crossings).toBe(1); + expect(after.crossings).toBe(0); + expect(after.overlap).toBeLessThan(0.1); // didn't introduce overlap + }); + + it("leaves a tiny graph (n < 3) untouched", () => { + const nodes: LayoutNode[] = [ + { x: 0, y: 0, radius: 10 }, + { x: 50, y: 0, radius: 10 }, + ]; + optimizeTopLevel(nodes, [[0, 1]], 1); + expect(nodes[0]!.x).toBe(0); + expect(nodes[1]!.x).toBe(50); + }); +}); + +describe("optimizeTopLevel anchored refine (incremental stability)", () => { + it("keeps an already-good layout in place instead of re-deriving it", () => { + const ring = 120; + const radius = 12; + const nodes = polygon(6, ring, radius); + const edges = ringEdges(6); + const anchors: (Anchor | null)[] = nodes.map((node) => ({ + x: node.x, + y: node.y, + })); + + optimizeTopLevel(nodes, edges, 4242, { anchors }); + + // Every existing bubble stays within a fraction of its own radius of where + // it was: the refine doesn't reshuffle a layout that's already crossing-free. + const displacements = relativeDisplacements(nodes, anchors); + for (const displacement of displacements) { + expect(displacement).toBeLessThan(radius); + } + expect(measureLayout(nodes, edges).crossings).toBe(0); + }); + + it("places a new bubble without disturbing the existing arrangement", () => { + const ring = 120; + const radius = 12; + const existing = polygon(6, ring, radius); + const anchors: (Anchor | null)[] = [ + ...existing.map((node) => ({ x: node.x, y: node.y })), + null, // the 7th bubble is new, placed freely + ]; + + // New bubble seeded near the centre, connected to one existing bubble. + const nodes: LayoutNode[] = [...existing, { x: 5, y: 5, radius }]; + const edges: [number, number][] = [...ringEdges(6), [6, 0]]; + + optimizeTopLevel(nodes, edges, 99, { anchors }); + + // The 6 existing bubbles barely move (mental map preserved)... + const displacements = relativeDisplacements(nodes, anchors); + for (let idx = 0; idx < 6; idx++) { + expect(displacements[idx]!).toBeLessThan(2 * radius); + } + // ...while the new bubble leaves its poor central seed to find open space. + const newNode = nodes[6]!; + expect(Math.hypot(newNode.x - 5, newNode.y - 5)).toBeGreaterThan(radius); + for (const node of nodes) { + expect(Number.isFinite(node.x)).toBe(true); + expect(Number.isFinite(node.y)).toBe(true); + } + }); + + it("re-optimises far less aggressively than a cold search (stability contract)", () => { + // Anchored refine limits displacement relative to a cold global search on + // the same seed. + const radius = 12; + const seed = (): LayoutNode[] => + Array.from({ length: 6 }, (_, idx) => ({ + x: (idx - 2.5) * 44, + y: idx % 2 === 0 ? 9 : -9, + radius, + })); + const edges = ringEdges(6); + const anchors: (Anchor | null)[] = seed().map((node) => ({ + x: node.x, + y: node.y, + })); + const totalMovement = (nodes: readonly LayoutNode[]): number => + relativeDisplacements(nodes, anchors).reduce( + (sum, dist) => sum + dist, + 0, + ); + + const cold = seed(); + optimizeTopLevel(cold, edges, 555); + + const refined = seed(); + optimizeTopLevel(refined, edges, 555, { anchors }); + + const coldMovement = totalMovement(cold); + const refinedMovement = totalMovement(refined); + + // eslint-disable-next-line no-console + console.log( + `[toplevel] movement cold ${coldMovement.toFixed(1)} vs anchored ${refinedMovement.toFixed(1)}`, + ); + // The anchored refine keeps bubbles close to their previous positions... + expect(refinedMovement).toBeLessThan(coldMovement); + // ...and the geometry it produces is no worse than the seed it started from. + expect(measureLayout(refined, edges).energy).toBeLessThanOrEqual( + measureLayout(seed(), edges).energy + 1e-6, + ); + }); + + it("lets a low-weight (off-screen) bubble move while a high-weight one stays", () => { + // Two connected bubbles seeded far apart, so stress pulls them together. + // One is pinned (weight 1, "on screen"), the other nearly free (weight 0.02, + // "off screen"); the free one should yield while the pinned one holds. The + // third bubble is an isolated, fully-anchored frame reference. + const radius = 10; + const nodes: LayoutNode[] = [ + { x: -40, y: 0, radius }, + { x: 40, y: 0, radius }, + { x: 0, y: 120, radius }, + ]; + const anchors: (Anchor | null)[] = [ + { x: -40, y: 0, weight: 1 }, + { x: 40, y: 0, weight: 0.02 }, + { x: 0, y: 120, weight: 1 }, + ]; + const edges: [number, number][] = [[0, 1]]; + + optimizeTopLevel(nodes, edges, 31, { anchors }); + + const pinnedMove = Math.hypot(nodes[0]!.x + 40, nodes[0]!.y); + const freeMove = Math.hypot(nodes[1]!.x - 40, nodes[1]!.y); + + // eslint-disable-next-line no-console + console.log( + `[toplevel] weighted move pinned ${pinnedMove.toFixed(1)} vs free ${freeMove.toFixed(1)}`, + ); + expect(freeMove).toBeGreaterThan(pinnedMove); + expect(pinnedMove).toBeLessThan(radius); + }); + + it("guarantees zero overlap on growth, where the search leaves a sliver", () => { + // A central bubble grew huge (radius 200) and is pinned, and so are its two + // neighbours (all weight 1, all on screen) at positions now buried inside + // it. The anchored search resolves most of this on its own, but anchored + // to infeasible (overlapping) positions. It leaves a residual sliver it + // won't close. The final relaxation must turn that sliver into exactly zero. + const seedNodes = (): LayoutNode[] => [ + { x: 0, y: 0, radius: 200 }, + { x: 40, y: 0, radius: 12 }, + { x: -40, y: 0, radius: 12 }, + ]; + const anchors: (Anchor | null)[] = [ + { x: 0, y: 0, weight: 1 }, + { x: 40, y: 0, weight: 1 }, + { x: -40, y: 0, weight: 1 }, + ]; + const edges: [number, number][] = [ + [0, 1], + [0, 2], + ]; + + // The search alone (relaxation suppressed) leaves a measurable overlap. + const searchOnly = seedNodes(); + optimizeTopLevel(searchOnly, edges, 23, { + anchors, + skipOverlapRelaxation: true, + }); + const searchOnlyOverlap = measureLayout(searchOnly, edges).overlap; + expect(searchOnlyOverlap).toBeGreaterThan(0.01); + + // The full pass drives it to zero. The relaxation is what closes the gap. + const nodes = seedNodes(); + optimizeTopLevel(nodes, edges, 23, { anchors }); + const fullOverlap = measureLayout(nodes, edges).overlap; + expect(fullOverlap).toBeLessThan(1e-9); + expect(fullOverlap).toBeLessThan(searchOnlyOverlap); + }); + + it("relaxOverlaps separates overlaps and moves the heavier bubble less", () => { + // Two overlapping bubbles, B four times as heavy (pinned) as A. The push + // apart must (a) actually separate them and (b) move the heavier one less, + // i.e. distribute by weight rather than 50/50. No SA involved; this is the + // relaxation in isolation. + const meanRadius = 10; + const nodes: LayoutNode[] = [ + { x: 0, y: 0, radius: 10 }, // A: light + { x: 8, y: 0, radius: 10 }, // B: heavy, overlapping A + ]; + const anchors: (Anchor | null)[] = [ + { x: 0, y: 0, weight: 1 }, + { x: 8, y: 0, weight: 4 }, + ]; + + relaxOverlaps(nodes, anchors, meanRadius); + + // (a) No overlap left: centre distance >= radii + the objective's pad. + const pad = 0.3 * meanRadius; + const separation = Math.hypot(nodes[1]!.x - nodes[0]!.x, nodes[1]!.y); + expect(separation).toBeGreaterThanOrEqual(20 + pad - 1e-6); + // (b) The heavier bubble moved less than the lighter one. + const moveA = Math.abs(nodes[0]!.x - 0); + const moveB = Math.abs(nodes[1]!.x - 8); + expect(moveB).toBeLessThan(moveA); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/top-level-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/top-level-layout.ts new file mode 100644 index 00000000000..b99233ef522 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/top-level-layout.ts @@ -0,0 +1,757 @@ +/* eslint-disable no-param-reassign, id-length -- numeric optimiser: in-place position mutation and short math identifiers (x/y/dx) read best here. */ +/** + * Principled top-level layout optimiser. + * + * Why the top level is special. Inside a cluster, children are confined to the + * parent circle, similarly sized, and their external pull is handled by port + * anchors; WebCola's stress + non-overlap (plus the anchors) is the right tool + * there. The outside is harder: top-level clusters are unconfined, vary + * enormously in size (a 5 500-node bubble next to a 76-node one), and the edges + * between them have to get past those huge obstacles. Stress alone does not + * penalize crossings or detours; staging layout through separate centre, + * untangle, port, and routing passes optimizes proxies rather than the drawn + * rim-to-rim geometry. The top level is also the overview every deeper + * decision inherits, so it's worth solving directly. + * + * This optimiser minimises one objective over the small top-level layout, + * evaluated on the geometry that gets drawn: edges leave each bubble at the rim + * facing their neighbour (a port), and we score crossings + detours of those + * rim-to-rim segments, plus edge length (connected-near), non-overlap, and + * neighbour spread, so a cluster's connections fan out instead of bunching. + * Neighbour spread is an explicit objective term (the "mitosis" intuition) + * rather than a separate seeding pass. The N is tiny, so a simulated-annealing + * search with position swaps and restarts gets a near-optimal layout cheaply. + * + * Stability (mental-map preservation). A from-scratch global search is the right + * tool for the first build, but re-running it on every ingest makes the top + * level jump around: the search is a near-optimal but discontinuous function of + * its input, so adding one bubble re-derives a completely different (if equally + * good) arrangement. So on an incremental update the caller passes the previous + * positions as {@link Anchor}s and the search becomes a local refine: an inertia + * term penalises moving an existing bubble away from its anchor, and the search + * takes small steps with few restarts and rare swaps. New bubbles (null anchor) + * are still placed freely, and the layout keeps self-healing crossings. It just + * does so by small, legible adjustments instead of wholesale reshuffles. + */ + +import { mulberry32 } from "../../math/random"; + +import type { Position } from "../../geometry"; + +/** A node being laid out; `x`/`y` are mutated in place. */ +export interface LayoutNode { + x: number; + y: number; + readonly radius: number; +} + +/** A node's previous position, used to anchor it during an incremental refine. */ +export interface Anchor extends Position { + /** + * Per-node multiplier on the inertia weight, in [0, 1]: 1 pins the node to its + * previous position, 0 lets it move freely (defaults to 1). The caller sets it + * from how central the bubble is on screen, so what the user is looking at + * stays put while off-screen bubbles are free to reflow. + */ + readonly weight?: number; +} + +export interface OptimizeTopLevelOptions { + /** + * Previous positions, parallel to `nodes`. A non-null entry anchors that node + * (it existed in the prior layout and should keep its place); a null entry is + * a new node, placed freely. Supplying any anchor switches the search to a + * local, stability-preserving refine; omit entirely for a cold global build. + */ + readonly anchors?: readonly (Anchor | null)[]; + /** + * Skip the final non-overlap relaxation. Exists so tests can assert that the + * anchored search alone leaves a grown-bubble overlap unresolved (proving the + * relaxation is what clears it); production never sets it. + */ + readonly skipOverlapRelaxation?: boolean; + /** + * Tuning overrides; unset fields fall back to + * {@link defaultTopLevelPolishConfig}. + */ + readonly tuning?: Partial; +} + +/** + * Tuning for the top-level (hierarchy overview) polish: this module's + * annealing search plus the gates its callers apply + * ({@link "../entity-graph/hierarchical/settle-polish"} skips the optimiser above + * `maxNodes`; {@link "../entity-graph/hierarchical/viewport-anchor"} floors its anchor + * weight at `viewportAnchorFloor`). + */ +export interface TopLevelPolishConfig { + /** + * Above this top-level cluster count, skip the optimiser (keep WebCola's + * result). + * + * @defaultValue 32. The objective is O(n²)-ish per evaluation, so raising it + * trades settle-time CPU for polish quality on big overviews. + */ + readonly maxNodes: number; + /** + * Objective weight per edge crossing. Crossings, detours, and overlap + * dominate legibility; stress and spread are gentle shaping terms. + * + * @defaultValue 30. + */ + readonly crossingWeight: number; + /** Objective weight per unit of edge-through-bubble intrusion. @defaultValue 24. */ + readonly detourWeight: number; + /** Objective weight per unit of bubble overlap. @defaultValue 40. */ + readonly overlapWeight: number; + /** Objective weight on normalised (scale-free) edge-length stress. @defaultValue 3. */ + readonly stressWeight: number; + /** Objective weight on neighbour angular-spread pinching. @defaultValue 2. */ + readonly spreadWeight: number; + /** + * Ideal rim-to-rim gap between linked bubbles, as a fraction of the mean + * radius. Additive, not multiplicative: a multiplicative ideal makes any edge + * touching a huge bubble "want" to be hundreds of px long, flinging connected + * small nodes far away. + * + * @defaultValue 0.5. Must stay >= {@link TopLevelPolishConfig.overlapPadFraction} + * so stress and non-overlap don't fight. + */ + readonly idealGapFraction: number; + /** Non-overlap gap as a fraction of the mean radius. @defaultValue 0.3. */ + readonly overlapPadFraction: number; + /** Initial annealing temperature for cold (unanchored) builds. @defaultValue 25. */ + readonly startTemperature: number; + /** Per-step temperature multiplier. @defaultValue 0.9975. */ + readonly cooling: number; + /** Annealing steps per restart. @defaultValue 1600. */ + readonly steps: number; + /** Independent annealing restarts for cold builds; best result wins. @defaultValue 8. */ + readonly restarts: number; + /** Fraction of cold-build moves that are position swaps (the rest jitter). @defaultValue 0.25. */ + readonly swapProbability: number; + /** + * Inertia weight for the anchored (incremental-refine) search: each anchored + * node adds weight·(displacement / meanRadius)² to the objective, so existing + * bubbles keep their place. + * + * @defaultValue 12, calibrated against {@link TopLevelPolishConfig.crossingWeight}: + * a node won't travel ~2 mean-radii from its anchor unless doing so removes + * more than ~1.5 crossings. + */ + readonly anchorWeight: number; + /** Annealing start temperature for anchored refines (local search). @defaultValue 6. */ + readonly anchoredStartTemperature: number; + /** Restarts for anchored refines (one local pass, no global re-search). @defaultValue 1. */ + readonly anchoredRestarts: number; + /** + * Swap probability during anchored refines. Rare: a swap relocates a node + * across the whole layout, the opposite of staying put. + * + * @defaultValue 0.05. + */ + readonly anchoredSwapProbability: number; + /** Anchored move scale, as a multiple of the mean radius. @defaultValue 1.2. */ + readonly anchoredMoveScale: number; + /** + * Iteration cap for the post-refine deterministic overlap relaxation (see + * {@link relaxOverlaps}); a pathological all-pinned growth case may retain a + * residual sliver when exhausted. + * + * @defaultValue 64. + */ + readonly overlapRelaxIterations: number; + /** + * Relaxation mass of an un-anchored (new) bubble: very light, so it yields + * freely rather than shoving an anchored neighbour. Below the viewport floor. + * + * @defaultValue 0.01. + */ + readonly freeNodeMass: number; + /** + * Anchor weight kept by off-screen bubbles during viewport-weighted refines: + * they reflow but don't teleport while the user isn't looking (see + * {@link "../entity-graph/hierarchical/viewport-anchor"}). + * + * @defaultValue 0.05. + */ + readonly viewportAnchorFloor: number; +} + +export const defaultTopLevelPolishConfig: TopLevelPolishConfig = { + maxNodes: 32, + crossingWeight: 30, + detourWeight: 24, + overlapWeight: 40, + stressWeight: 3, + spreadWeight: 2, + idealGapFraction: 0.5, + overlapPadFraction: 0.3, + startTemperature: 25, + cooling: 0.9975, + steps: 1600, + restarts: 8, + swapProbability: 0.25, + anchorWeight: 12, + anchoredStartTemperature: 6, + anchoredRestarts: 1, + anchoredSwapProbability: 0.05, + anchoredMoveScale: 1.2, + overlapRelaxIterations: 64, + freeNodeMass: 0.01, + viewportAnchorFloor: 0.05, +}; + +/** Resolve per-call overrides against the module defaults. */ +function resolveTuning( + tuning: Partial | undefined, +): TopLevelPolishConfig { + return tuning + ? { ...defaultTopLevelPolishConfig, ...tuning } + : defaultTopLevelPolishConfig; +} + +/** The rim point of `node` in the direction of `(tx,ty)`, where an edge to that + * neighbour attaches (its port, before any min-separation nudge). */ +function rim(node: LayoutNode, tx: number, ty: number): [number, number] { + const dx = tx - node.x; + const dy = ty - node.y; + const dist = Math.hypot(dx, dy) || 1; + return [ + node.x + (node.radius * dx) / dist, + node.y + (node.radius * dy) / dist, + ]; +} + +/** Standard segment-intersection test (proper crossings; shared endpoints handled by the caller). */ +function segmentsCross( + ax: number, + ay: number, + bx: number, + by: number, + cx: number, + cy: number, + dx: number, + dy: number, +): boolean { + const d1 = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); + const d2 = (bx - ax) * (dy - ay) - (by - ay) * (dx - ax); + const d3 = (dx - cx) * (ay - cy) - (dy - cy) * (ax - cx); + const d4 = (dx - cx) * (by - cy) - (dy - cy) * (bx - cx); + return d1 * d2 < 0 && d3 * d4 < 0; +} + +/** How deep a segment intrudes into a circle (0 if it stays clear). */ +function circleIntrusion( + ax: number, + ay: number, + bx: number, + by: number, + cx: number, + cy: number, + radius: number, +): number { + const dx = bx - ax; + const dy = by - ay; + const len2 = dx * dx + dy * dy; + let t = len2 > 0 ? ((cx - ax) * dx + (cy - ay) * dy) / len2 : 0; + t = Math.max(0, Math.min(1, t)); + const px = ax + t * dx; + const py = ay + t * dy; + const dist = Math.hypot(cx - px, cy - py); + return dist < radius ? radius - dist : 0; +} + +interface Problem { + readonly edges: readonly (readonly [number, number])[]; + readonly adjacency: readonly (readonly number[])[]; + readonly ideals: readonly number[]; + readonly meanRadius: number; +} + +function buildProblem( + nodes: readonly LayoutNode[], + edges: readonly (readonly [number, number])[], + tuning: TopLevelPolishConfig, +): Problem { + let meanRadius = 0; + for (const node of nodes) { + meanRadius += node.radius; + } + meanRadius = nodes.length > 0 ? meanRadius / nodes.length : 1; + + const adjacency: number[][] = nodes.map(() => []); + const ideals: number[] = []; + const gap = meanRadius * tuning.idealGapFraction; + // Edge endpoints are valid node indices: buildProblem is only called from + // measureLayout/optimizeTopLevel on the same nodes array that defines + // adjacency's length. + for (const [a, b] of edges) { + adjacency[a]!.push(b); + adjacency[b]!.push(a); + ideals.push(nodes[a]!.radius + nodes[b]!.radius + gap); + } + return { edges, adjacency, ideals, meanRadius }; +} + +interface LayoutTerms { + crossings: number; + detour: number; + overlap: number; + stress: number; + spread: number; +} + +/** + * Computes crossings, detour, overlap, stress, and spread for the current node + * positions into `terms` (unweighted components). + */ +function computeTerms( + nodes: readonly LayoutNode[], + problem: Problem, + terms: LayoutTerms, + tuning: TopLevelPolishConfig, +): void { + const { edges, adjacency, ideals, meanRadius } = problem; + const edgeCount = edges.length; + + // Score crossings and detours on rim-to-rim segments, not centre-to-centre lines. + const ax = new Float64Array(edgeCount); + const ay = new Float64Array(edgeCount); + const bx = new Float64Array(edgeCount); + const by = new Float64Array(edgeCount); + for (let e = 0; e < edgeCount; e++) { + const [a, b] = edges[e]!; + const na = nodes[a]!; + const nb = nodes[b]!; + const pa = rim(na, nb.x, nb.y); + const pb = rim(nb, na.x, na.y); + ax[e] = pa[0]; + ay[e] = pa[1]; + bx[e] = pb[0]; + by[e] = pb[1]; + } + + let crossings = 0; + for (let e = 0; e < edgeCount; e++) { + const [a1, b1] = edges[e]!; + for (let f = e + 1; f < edgeCount; f++) { + const [a2, b2] = edges[f]!; + if (a1 === a2 || a1 === b2 || b1 === a2 || b1 === b2) { + continue; // share a node, not a crossing + } + if ( + segmentsCross( + ax[e]!, + ay[e]!, + bx[e]!, + by[e]!, + ax[f]!, + ay[f]!, + bx[f]!, + by[f]!, + ) + ) { + crossings += 1; + } + } + } + + let detour = 0; + for (let e = 0; e < edgeCount; e++) { + const [a, b] = edges[e]!; + for (let w = 0; w < nodes.length; w++) { + if (w === a || w === b) { + continue; + } + const node = nodes[w]!; + const intr = circleIntrusion( + ax[e]!, + ay[e]!, + bx[e]!, + by[e]!, + node.x, + node.y, + node.radius, + ); + if (intr > 0) { + detour += intr / node.radius; // fraction of the bubble pierced + } + } + } + + let stress = 0; + for (let e = 0; e < edgeCount; e++) { + const [a, b] = edges[e]!; + const dist = Math.hypot( + nodes[a]!.x - nodes[b]!.x, + nodes[a]!.y - nodes[b]!.y, + ); + const ratio = dist / ideals[e]! - 1; + stress += ratio * ratio; + } + + let overlap = 0; + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const ni = nodes[i]!; + const nj = nodes[j]!; + const dist = Math.hypot(ni.x - nj.x, ni.y - nj.y); + const minDist = + ni.radius + nj.radius + tuning.overlapPadFraction * meanRadius; + if (dist < minDist) { + overlap += (minDist - dist) / meanRadius; + } + } + } + + let spread = 0; + for (let u = 0; u < nodes.length; u++) { + const neighbours = adjacency[u]!; + if (neighbours.length < 2) { + continue; + } + const node = nodes[u]!; + const angles = neighbours + .map((v) => Math.atan2(nodes[v]!.y - node.y, nodes[v]!.x - node.x)) + .sort((lhs, rhs) => lhs - rhs); + const idealGap = (2 * Math.PI) / angles.length; + for (let k = 0; k < angles.length; k++) { + const next = angles[(k + 1) % angles.length]!; + let gap = next - angles[k]!; + if (gap <= 0) { + gap += 2 * Math.PI; + } + if (gap < idealGap) { + spread += (idealGap - gap) / idealGap; + } + } + } + + terms.crossings = crossings; + terms.detour = detour; + terms.overlap = overlap; + terms.stress = stress; + terms.spread = spread; +} + +function weightedTotal( + terms: LayoutTerms, + tuning: TopLevelPolishConfig, +): number { + return ( + tuning.crossingWeight * terms.crossings + + tuning.detourWeight * terms.detour + + tuning.overlapWeight * terms.overlap + + tuning.stressWeight * terms.stress + + tuning.spreadWeight * terms.spread + ); +} + +/** Per-term breakdown of the drawn-geometry objective plus total weighted energy. */ +export interface LayoutMeasure extends LayoutTerms { + readonly energy: number; +} + +/** + * Returns the drawn-geometry objective terms and total weighted energy for + * `nodes`/`edges` without mutating positions. + */ +export function measureLayout( + nodes: readonly LayoutNode[], + edges: readonly (readonly [number, number])[], + tuning?: Partial, +): LayoutMeasure { + const resolved = resolveTuning(tuning); + const problem = buildProblem(nodes, edges, resolved); + const terms: LayoutTerms = { + crossings: 0, + detour: 0, + overlap: 0, + stress: 0, + spread: 0, + }; + computeTerms(nodes, problem, terms, resolved); + return { ...terms, energy: weightedTotal(terms, resolved) }; +} + +/** + * Push overlapping nodes apart in place, stopping when no pair overlaps or + * after {@link TopLevelPolishConfig.overlapRelaxIterations} passes, distributing each pair's + * separation by anchor weight as a mass: a node moves proportionally to the + * other's mass, so a heavy (pinned, high-weight) bubble barely moves and a + * light (off-screen or new) one yields. Uses the same minimum separation as the + * overlap objective term. When the budget is exhausted with residual overlap, + * positions are left as-is; callers must not assume zero overlap without + * verifying. + */ +export function relaxOverlaps( + nodes: LayoutNode[], + anchors: readonly (Anchor | null)[], + meanRadius: number, + tuning?: Partial, +): void { + const resolved = resolveTuning(tuning); + const pad = resolved.overlapPadFraction * meanRadius; + const count = nodes.length; + for (let iter = 0; iter < resolved.overlapRelaxIterations; iter++) { + let moved = false; + for (let i = 0; i < count; i++) { + const ni = nodes[i]!; + for (let j = i + 1; j < count; j++) { + const nj = nodes[j]!; + let dx = nj.x - ni.x; + let dy = nj.y - ni.y; + let dist = Math.hypot(dx, dy); + const minDist = ni.radius + nj.radius + pad; + if (dist >= minDist) { + continue; + } + if (dist < 1e-6) { + // Coincident: pick a deterministic direction from the indices. + const angle = (i * 1.3 + j * 0.7) % (2 * Math.PI); + dx = Math.cos(angle); + dy = Math.sin(angle); + dist = 1; + } + const penetration = minDist - dist; + const massI = anchors[i]?.weight ?? resolved.freeNodeMass; + const massJ = anchors[j]?.weight ?? resolved.freeNodeMass; + const total = massI + massJ; + // Mass-weighted split so low-weight (off-screen) neighbours yield + // before pinned, high-weight anchors. + const shareI = total > 0 ? massJ / total : 0.5; + const shareJ = total > 0 ? massI / total : 0.5; + const ux = dx / dist; + const uy = dy / dist; + ni.x -= ux * penetration * shareI; + ni.y -= uy * penetration * shareI; + nj.x += ux * penetration * shareJ; + nj.y += uy * penetration * shareJ; + moved = true; + } + } + if (!moved) { + break; + } + } +} + +/** + * Optimise the top-level node positions in place, minimising the drawn-geometry + * objective. `nodes` should already hold a reasonable seed (e.g. WebCola's + * stress result). `seed` makes the search deterministic. Pass + * {@link OptimizeTopLevelOptions.anchors} to switch from a cold global search to + * an incremental local refine that keeps anchored nodes near their previous + * positions (see the stability note in the module header). + */ +export function optimizeTopLevel( + nodes: LayoutNode[], + edges: readonly (readonly [number, number])[], + seed: number, + options?: OptimizeTopLevelOptions, +): void { + const count = nodes.length; + if (count < 3 || edges.length === 0) { + return; + } + const tuning = resolveTuning(options?.tuning); + const problem = buildProblem(nodes, edges, tuning); + const rng = mulberry32(seed); + const terms: LayoutTerms = { + crossings: 0, + detour: 0, + overlap: 0, + stress: 0, + spread: 0, + }; + + const rawAnchors = options?.anchors; + const anchored = + rawAnchors !== undefined && rawAnchors.some((anchor) => anchor !== null); + + // Align the anchor cloud to the seed, so inertia penalises relative + // rearrangement only. The layout is re-centred on its centroid (which shifts + // when a node is added/removed), so the raw anchors and the seed differ by a + // uniform translation we must not fight. The alignment is weighted, pinning + // the frame to the heavily-anchored (central) nodes, so they keep their + // on-screen position while low-weight nodes drift. + const anchors = Array.from({ + length: count, + }).fill(null); + + if (anchored) { + let anchorMeanX = 0; + let anchorMeanY = 0; + let seedMeanX = 0; + let seedMeanY = 0; + let totalWeight = 0; + for (let i = 0; i < count; i++) { + const anchor = rawAnchors[i]; + if (!anchor) { + continue; + } + const weight = anchor.weight ?? 1; + anchorMeanX += anchor.x * weight; + anchorMeanY += anchor.y * weight; + seedMeanX += nodes[i]!.x * weight; + seedMeanY += nodes[i]!.y * weight; + totalWeight += weight; + } + const offsetX = + totalWeight > 0 ? (seedMeanX - anchorMeanX) / totalWeight : 0; + const offsetY = + totalWeight > 0 ? (seedMeanY - anchorMeanY) / totalWeight : 0; + for (let i = 0; i < count; i++) { + const anchor = rawAnchors[i]; + if (anchor) { + anchors[i] = { + x: anchor.x + offsetX, + y: anchor.y + offsetY, + weight: anchor.weight, + }; + } + } + } + + const anchorScale = Math.max(problem.meanRadius, 1); + const anchorEnergy = (): number => { + if (!anchored) { + return 0; + } + let sum = 0; + for (let i = 0; i < count; i++) { + const anchor = anchors[i]; + if (!anchor) { + continue; + } + const dx = nodes[i]!.x - anchor.x; + const dy = nodes[i]!.y - anchor.y; + const weight = anchor.weight ?? 1; + sum += (weight * (dx * dx + dy * dy)) / (anchorScale * anchorScale); + } + return tuning.anchorWeight * sum; + }; + + const evalEnergy = (): number => { + computeTerms(nodes, problem, terms, tuning); + return weightedTotal(terms, tuning) + anchorEnergy(); + }; + + // Cold-search jitter span scales with layout extent so moves stay + // proportional to bubble spread. + let extent = 0; + for (const node of nodes) { + extent = Math.max(extent, Math.hypot(node.x, node.y) + node.radius); + } + extent = Math.max(extent, problem.meanRadius); + + // Anchored: a local refine (small steps, one pass, rare swaps). Cold: the + // full global search. `jitterBasis` also scales the restart kick, so an + // anchored pass never throws a node across the layout. + const restarts = anchored ? tuning.anchoredRestarts : tuning.restarts; + const startTemp = anchored + ? tuning.anchoredStartTemperature + : tuning.startTemperature; + const swapProb = anchored + ? tuning.anchoredSwapProbability + : tuning.swapProbability; + const jitterBasis = anchored + ? anchorScale * tuning.anchoredMoveScale + : extent; + + const bestX = nodes.map((node) => node.x); + const bestY = nodes.map((node) => node.y); + let bestEnergy = evalEnergy(); + + for (let restart = 0; restart < restarts; restart++) { + // Restart 0 keeps the seed; later restarts jitter it to escape minima. + if (restart > 0) { + for (let i = 0; i < count; i++) { + nodes[i]!.x = bestX[i]! + (rng() - 0.5) * jitterBasis; + nodes[i]!.y = bestY[i]! + (rng() - 0.5) * jitterBasis; + } + } else { + for (let i = 0; i < count; i++) { + nodes[i]!.x = bestX[i]!; + nodes[i]!.y = bestY[i]!; + } + } + + let current = evalEnergy(); + let temp = startTemp; + + for (let step = 0; step < tuning.steps; step++) { + if (rng() < swapProb) { + // Swap two nodes' positions: the move that un-crosses a layout. + const i = Math.floor(rng() * count); + let j = Math.floor(rng() * count); + if (j === i) { + j = (j + 1) % count; + } + const tx = nodes[i]!.x; + const ty = nodes[i]!.y; + nodes[i]!.x = nodes[j]!.x; + nodes[i]!.y = nodes[j]!.y; + nodes[j]!.x = tx; + nodes[j]!.y = ty; + const candidate = evalEnergy(); + if ( + candidate <= current || + rng() < Math.exp((current - candidate) / temp) + ) { + current = candidate; + } else { + nodes[j]!.x = nodes[i]!.x; + nodes[j]!.y = nodes[i]!.y; + nodes[i]!.x = tx; + nodes[i]!.y = ty; + } + } else { + // Annealing shrinks jitter with temperature so late steps are local + // refinements. + const i = Math.floor(rng() * count); + const ox = nodes[i]!.x; + const oy = nodes[i]!.y; + const scale = (jitterBasis * temp) / startTemp; + nodes[i]!.x = ox + (rng() - 0.5) * scale; + nodes[i]!.y = oy + (rng() - 0.5) * scale; + const candidate = evalEnergy(); + if ( + candidate <= current || + rng() < Math.exp((current - candidate) / temp) + ) { + current = candidate; + } else { + nodes[i]!.x = ox; + nodes[i]!.y = oy; + } + } + temp *= tuning.cooling; + } + + if (current < bestEnergy) { + bestEnergy = current; + for (let i = 0; i < count; i++) { + bestX[i] = nodes[i]!.x; + bestY[i] = nodes[i]!.y; + } + } + } + + for (let i = 0; i < count; i++) { + nodes[i]!.x = bestX[i]!; + nodes[i]!.y = bestY[i]!; + } + + // Anchoring pins bubbles to their previous positions, which become infeasible + // when one grows. The search clears most of the resulting overlap, but anchored + // to overlapping positions it can leave a residual sliver it won't close (the + // quadratic inertia holds bubbles short of fully separating). Run post-search + // overlap relaxation to clear that sliver; separation is mass-weighted toward + // lighter/off-screen bubbles (see {@link relaxOverlaps} iteration budget). + // (Cold builds resolve overlap during the global search.) + if (anchored && options?.skipOverlapRelaxation !== true) { + relaxOverlaps(nodes, anchors, problem.meanRadius, tuning); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/untangle.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/untangle.ts new file mode 100644 index 00000000000..d2262c00102 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/layout/untangle.ts @@ -0,0 +1,595 @@ +/* eslint-disable id-length, no-param-reassign */ +/** + * D1: small-N layout solver (crossing + readability minimisation). + * + * The objective is self-contained: it does not reference the force layout. We + * minimise what actually makes a small node-link layout bad: + * + * energy = crossings + edge-through-node + node overlap + * + linked-pairs-too-far (the link "meaning": connected -> near) + * + compactness (stay near the centre of the allotted disc) + * + * and search the full configuration space with simulated annealing from several + * restarts, keeping the lowest-energy result. A warm-start init (e.g. the force + * layout or a SMACOF embedding) can be passed in via the node positions, but it + * has no privileged hold: there is no anchor term tethering us to it; it just + * seeds restart 0 and competes on equal terms with jittered/random restarts. + * The committed layout is the optimum of the real objective, not a polish of + * whatever local minimum the seed fell into. + * + * Two correctness notes: + * - Temperature is on the energy scale (a crossing costs `crossingWeight`), so + * `exp(-Δ/T)` actually accepts uphill moves early and this is real annealing, + * not greedy descent. + * - Crossing minimisation is NP-hard, so "directly" means full-space search + * with the true objective + restarts (near-optimal for the dozens of nodes we + * have at the cluster level), not a provable global optimum. + * + * A seeded PRNG makes every run deterministic. + * + * Per-move cost is O(degree*E + N): only the moved node's incident edges and its + * own overlaps change, so we evaluate the delta incrementally. The full-layout + * `totalEnergy` (O(E² + E*N + N²)) is computed only once per restart, to pick + * the winner. + */ +import { mulberry32 } from "../../math/random"; + +/** Mutated in place. Positions are in the layout's local frame (origin-centred). */ +export interface UntangleNode { + x: number; + y: number; + readonly radius: number; +} + +export interface UntangleOptions { + /** Index pairs into `nodes`: the edges whose crossings we minimise. */ + readonly edges: readonly (readonly [number, number])[]; + /** Confine nodes within this radius of the origin (Infinity = free). */ + readonly confinementRadius: number; + /** Deterministic seed so re-runs reproduce the layout. */ + readonly seed: number; + /** + * Anneal iterations per restart. Default `min(4000, N·120)`; more + * iterations improve search quality at linear time cost. + */ + readonly iterations?: number; + /** + * Independent annealing runs; the lowest-energy one wins. Defaults to + * {@link UntangleConfig.restarts}. + */ + readonly restarts?: number; + /** Tuning overrides; unset fields fall back to {@link defaultUntangleConfig}. */ + readonly tuning?: Partial; +} + +/** + * Tuning for the small-N sub-cluster untangle: this module's annealing/2-opt + * search plus the gate its caller applies + * ({@link "../entity-graph/hierarchical/settle-polish"} skips the untangle above + * `maxNodes`). + * + * Crossings (and edges through bubbles) dominate the soft link-length / + * compactness terms, so the 2-opt swaps and annealing prioritise removing them + * over a slightly longer edge; crossing reduction is the goal here. + */ +export interface UntangleConfig { + /** + * Above this node count, the caller skips the untangle entirely (the force + * result stands). + * + * @defaultValue 48. + */ + readonly maxNodes: number; + /** Objective weight per edge crossing. @defaultValue 20. */ + readonly crossingWeight: number; + /** Objective weight per edge-through-bubble violation. @defaultValue 28. */ + readonly throughWeight: number; + /** Objective weight per unit of bubble overlap. @defaultValue 6. */ + readonly overlapWeight: number; + /** Pull linked clusters together: weight per unit an edge exceeds its ideal. @defaultValue 0.02. */ + readonly linkWeight: number; + /** Ideal edge length as a multiple of the endpoints' combined radii. @defaultValue 1.5. */ + readonly linkIdealMultiplier: number; + /** Keep the layout compact: weight per unit a node sits from the origin. @defaultValue 0.012. */ + readonly compactWeight: number; + /** Keep an edge this far (× node radius) clear of a non-incident node. @defaultValue 1.15. */ + readonly throughClearance: number; + /** + * Initial annealing temperature, on the energy scale (~a couple of + * crossings), so uphill moves are genuinely accepted early. Cools to ~0 over + * the run. + * + * @defaultValue 25. + */ + readonly startTemperature: number; + /** Independent annealing runs; the lowest-energy one wins. @defaultValue 6. */ + readonly restarts: number; + /** + * Skip the 2-opt polish pass above this node count. Below the cap, 2-opt + * removes crossings annealing cannot; above it, only annealing runs, so + * crossings may remain. Tradeoff: 2-opt is O(passes·N²·|energy|) and + * dominates runtime for larger N. + * + * @defaultValue 24. + */ + readonly twoOptMaxNodes: number; + /** Cap on 2-opt passes; it converges in a few full pairwise sweeps. @defaultValue 4. */ + readonly twoOptPasses: number; +} + +export const defaultUntangleConfig: UntangleConfig = { + maxNodes: 48, + crossingWeight: 20, + throughWeight: 28, + overlapWeight: 6, + linkWeight: 0.02, + linkIdealMultiplier: 1.5, + compactWeight: 0.012, + throughClearance: 1.15, + startTemperature: 25, + restarts: 6, + twoOptMaxNodes: 24, + twoOptPasses: 4, +}; + +/** Returns the signed area of triangle (a,b,c); sign indicates clockwise vs counter-clockwise turn at b. */ +function orient( + ax: number, + ay: number, + bx: number, + by: number, + cx: number, + cy: number, +): number { + return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); +} + +/** Do open segments (p1,p2) and (p3,p4) properly cross? Shared endpoints don't. */ +function segmentsCross( + p1x: number, + p1y: number, + p2x: number, + p2y: number, + p3x: number, + p3y: number, + p4x: number, + p4y: number, +): boolean { + const d1 = orient(p3x, p3y, p4x, p4y, p1x, p1y); + const d2 = orient(p3x, p3y, p4x, p4y, p2x, p2y); + const d3 = orient(p1x, p1y, p2x, p2y, p3x, p3y); + const d4 = orient(p1x, p1y, p2x, p2y, p4x, p4y); + return d1 * d2 < 0 && d3 * d4 < 0; +} + +/** Returns squared Euclidean distance from p to the closed segment ab (clamped projection). */ +function pointSegmentDist2( + px: number, + py: number, + ax: number, + ay: number, + bx: number, + by: number, +): number { + const dx = bx - ax; + const dy = by - ay; + const len2 = dx * dx + dy * dy; + const t = + len2 < 1e-9 + ? 0 + : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)); + const qx = ax + t * dx; + const qy = ay + t * dy; + return (px - qx) ** 2 + (py - qy) ** 2; +} + +/** Target centre distance for a linked pair: (r_a + r_b) × linkIdealMultiplier. */ +function idealLinkDist( + a: UntangleNode, + b: UntangleNode, + tuning: UntangleConfig, +): number { + return (a.radius + b.radius) * tuning.linkIdealMultiplier; +} + +/** + * Energy of every term that involves node `i`. Moving only `i` changes exactly + * these terms, so `Δtotal = nodeEnergy(after) - nodeEnergy(before)`. + */ +function nodeEnergy( + i: number, + nodes: readonly UntangleNode[], + edges: readonly (readonly [number, number])[], + incident: readonly number[], + tuning: UntangleConfig, +): number { + const node = nodes[i]!; + let energy = 0; + + for (const ie of incident) { + const [a, b] = edges[ie]!; + const ax = nodes[a]!.x; + const ay = nodes[a]!.y; + const bx = nodes[b]!.x; + const by = nodes[b]!.y; + for (let je = 0; je < edges.length; je++) { + if (je === ie) { + continue; + } + const [c, d] = edges[je]!; + // Skip edges sharing an endpoint (they meet, not cross). + if (c === a || c === b || d === a || d === b) { + continue; + } + if ( + segmentsCross( + ax, + ay, + bx, + by, + nodes[c]!.x, + nodes[c]!.y, + nodes[d]!.x, + nodes[d]!.y, + ) + ) { + energy += tuning.crossingWeight; + } + } + } + + for (const ie of incident) { + const [a, b] = edges[ie]!; + const ax = nodes[a]!.x; + const ay = nodes[a]!.y; + const bx = nodes[b]!.x; + const by = nodes[b]!.y; + for (let k = 0; k < nodes.length; k++) { + if (k === a || k === b) { + continue; + } + const clear = nodes[k]!.radius * tuning.throughClearance; + if ( + pointSegmentDist2(nodes[k]!.x, nodes[k]!.y, ax, ay, bx, by) < + clear * clear + ) { + energy += tuning.throughWeight; + } + } + } + + // Through-node penalty is symmetric: count edges piercing i's disk whether + // or not i is an endpoint. + const clearI = node.radius * tuning.throughClearance; + for (let je = 0; je < edges.length; je++) { + const [c, d] = edges[je]!; + if (c === i || d === i) { + continue; + } + if ( + pointSegmentDist2( + node.x, + node.y, + nodes[c]!.x, + nodes[c]!.y, + nodes[d]!.x, + nodes[d]!.y, + ) < + clearI * clearI + ) { + energy += tuning.throughWeight; + } + } + + for (let k = 0; k < nodes.length; k++) { + if (k === i) { + continue; + } + const minDist = node.radius + nodes[k]!.radius; + const dist = Math.hypot(node.x - nodes[k]!.x, node.y - nodes[k]!.y); + if (dist < minDist) { + energy += tuning.overlapWeight * (minDist - dist); + } + } + + for (const ie of incident) { + const [a, b] = edges[ie]!; + const other = nodes[a === i ? b : a]!; + const len = Math.hypot(node.x - other.x, node.y - other.y); + const ideal = idealLinkDist(node, other, tuning); + if (len > ideal) { + energy += tuning.linkWeight * (len - ideal); + } + } + + // Compactness: a fixed geometric pull toward the disc centre (not an anchor to + // any prior layout, it references the origin, not the seed positions). + energy += tuning.compactWeight * Math.hypot(node.x, node.y); + + return energy; +} + +/** Absolute objective for the whole layout, used to pick the best restart. */ +function totalEnergy( + nodes: readonly UntangleNode[], + edges: readonly (readonly [number, number])[], + tuning: UntangleConfig, +): number { + let energy = 0; + + for (let ie = 0; ie < edges.length; ie++) { + const [a, b] = edges[ie]!; + const ax = nodes[a]!.x; + const ay = nodes[a]!.y; + const bx = nodes[b]!.x; + const by = nodes[b]!.y; + // je starts at ie+1 so each unordered pair is charged once. + for (let je = ie + 1; je < edges.length; je++) { + const [c, d] = edges[je]!; + if (c === a || c === b || d === a || d === b) { + continue; + } + if ( + segmentsCross( + ax, + ay, + bx, + by, + nodes[c]!.x, + nodes[c]!.y, + nodes[d]!.x, + nodes[d]!.y, + ) + ) { + energy += tuning.crossingWeight; + } + } + } + + for (let ie = 0; ie < edges.length; ie++) { + const [a, b] = edges[ie]!; + const ax = nodes[a]!.x; + const ay = nodes[a]!.y; + const bx = nodes[b]!.x; + const by = nodes[b]!.y; + for (let k = 0; k < nodes.length; k++) { + if (k === a || k === b) { + continue; + } + const clear = nodes[k]!.radius * tuning.throughClearance; + if ( + pointSegmentDist2(nodes[k]!.x, nodes[k]!.y, ax, ay, bx, by) < + clear * clear + ) { + energy += tuning.throughWeight; + } + } + } + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]!; + for (let k = i + 1; k < nodes.length; k++) { + const minDist = node.radius + nodes[k]!.radius; + const dist = Math.hypot(node.x - nodes[k]!.x, node.y - nodes[k]!.y); + if (dist < minDist) { + energy += tuning.overlapWeight * (minDist - dist); + } + } + energy += tuning.compactWeight * Math.hypot(node.x, node.y); + } + for (let ie = 0; ie < edges.length; ie++) { + const [a, b] = edges[ie]!; + const len = Math.hypot( + nodes[a]!.x - nodes[b]!.x, + nodes[a]!.y - nodes[b]!.y, + ); + const ideal = idealLinkDist(nodes[a]!, nodes[b]!, tuning); + if (len > ideal) { + energy += tuning.linkWeight * (len - ideal); + } + } + + return energy; +} + +/** Clamp a node inside the confinement circle (no-op if radius is Infinity). */ +function confine(node: UntangleNode, confinementRadius: number): void { + if (!Number.isFinite(confinementRadius)) { + return; + } + const limit = Math.max(0, confinementRadius - node.radius); + const dist = Math.hypot(node.x, node.y); + if (dist > limit && dist > 0) { + node.x = (node.x / dist) * limit; + node.y = (node.y / dist) * limit; + } +} + +/** Runs one annealing pass with cooling temperature and step scale; mutates positions in place. */ +function annealOnce( + nodes: UntangleNode[], + edges: readonly (readonly [number, number])[], + incident: readonly (readonly number[])[], + confinementRadius: number, + rng: () => number, + iterations: number, + tuning: UntangleConfig, +): void { + const count = nodes.length; + + let extent = 0; + for (const node of nodes) { + extent = Math.max(extent, Math.hypot(node.x, node.y) + node.radius); + } + const startScale = Math.max(1, extent * 0.4); + + for (let iter = 0; iter < iterations; iter++) { + const progress = iter / iterations; + const temperature = tuning.startTemperature * (1 - progress) ** 2; + const scale = startScale * (1 - progress); + const i = Math.floor(rng() * count); + const node = nodes[i]!; + + const before = nodeEnergy(i, nodes, edges, incident[i]!, tuning); + const oldX = node.x; + const oldY = node.y; + + const angle = rng() * 2 * Math.PI; + const step = scale * rng(); + node.x += Math.cos(angle) * step; + node.y += Math.sin(angle) * step; + confine(node, confinementRadius); + + const after = nodeEnergy(i, nodes, edges, incident[i]!, tuning); + const delta = after - before; + + // Metropolis: reject uphill moves whose acceptance draw exceeds exp(-delta / temperature). + if (delta > 0 && rng() >= Math.exp(-delta / Math.max(1e-3, temperature))) { + node.x = oldX; + node.y = oldY; + } + } +} + +/** + * 2-opt position swaps: greedily exchange pairs of node positions, keeping any + * swap that lowers total energy, until none does (or the pass cap is hit). A + * single swap can un-cross many edges at once (the move single-node annealing + * nudges can not reach), so this is what actually minimises crossings for the + * small node counts at the cluster level. `totalEnergy` includes overlap, so a + * swap that would collide is rejected. O(passes * N² * |totalEnergy|), hence + * gated to small N by the caller. + */ +function twoOptSwaps( + nodes: UntangleNode[], + edges: readonly (readonly [number, number])[], + tuning: UntangleConfig, +): void { + const count = nodes.length; + let base = totalEnergy(nodes, edges, tuning); + for (let pass = 0; pass < tuning.twoOptPasses; pass++) { + let improved = false; + for (let i = 0; i < count; i++) { + for (let j = i + 1; j < count; j++) { + const ix = nodes[i]!.x; + const iy = nodes[i]!.y; + const jx = nodes[j]!.x; + const jy = nodes[j]!.y; + nodes[i]!.x = jx; + nodes[i]!.y = jy; + nodes[j]!.x = ix; + nodes[j]!.y = iy; + const energy = totalEnergy(nodes, edges, tuning); + if (energy < base - 1e-6) { + base = energy; + improved = true; + } else { + nodes[i]!.x = ix; + nodes[i]!.y = iy; + nodes[j]!.x = jx; + nodes[j]!.y = jy; + } + } + } + if (!improved) { + break; + } + } +} + +/** + * Minimises crossings, edge-through-node, overlap, and stretch for small + * graphs via multi-restart simulated annealing plus optional 2-opt polish. + * Mutates node positions in place; no-op when N < 3 or there are no edges. + * Warm-start positions seed restart 0 only. 2-opt runs only when + * N ≤ {@link UntangleConfig.twoOptMaxNodes} (default 24). + */ +export function untangleLayout( + nodes: UntangleNode[], + options: UntangleOptions, +): void { + const { edges, confinementRadius, seed } = options; + const count = nodes.length; + if (count < 3 || edges.length === 0) { + return; + } + + const tuning: UntangleConfig = options.tuning + ? { ...defaultUntangleConfig, ...options.tuning } + : defaultUntangleConfig; + + const incident: number[][] = Array.from({ length: count }, () => []); + for (let e = 0; e < edges.length; e++) { + const [a, b] = edges[e]!; + incident[a]!.push(e); + incident[b]!.push(e); + } + + const restarts = Math.max(1, options.restarts ?? tuning.restarts); + const iterations = options.iterations ?? Math.min(4000, count * 120); + const rng = mulberry32(seed); + + // Snapshot incoming positions as restart-0 seed (force layout, SMACOF, or + // any prior placement). + const init = nodes.map((node) => ({ x: node.x, y: node.y })); + let extent = 0; + for (const node of init) { + extent = Math.max(extent, Math.hypot(node.x, node.y)); + } + const jitterScale = Math.max(1, extent * 0.6); + const randomRadius = Number.isFinite(confinementRadius) + ? confinementRadius + : Math.max(1, extent); + + let best = init.map((p) => ({ ...p })); + let bestEnergy = Infinity; + + for (let r = 0; r < restarts; r++) { + // Restart 0 starts from the given seed; later restarts perturb it (and the + // last starts fully random) so the search is not captured by the seed. + for (let i = 0; i < count; i++) { + const node = nodes[i]!; + if (r === 0) { + node.x = init[i]!.x; + node.y = init[i]!.y; + } else if (r === restarts - 1 && restarts > 2) { + const angle = rng() * 2 * Math.PI; + const rad = Math.sqrt(rng()) * randomRadius; + node.x = Math.cos(angle) * rad; + node.y = Math.sin(angle) * rad; + } else { + node.x = init[i]!.x + (rng() * 2 - 1) * jitterScale; + node.y = init[i]!.y + (rng() * 2 - 1) * jitterScale; + } + confine(node, confinementRadius); + } + + annealOnce( + nodes, + edges, + incident, + confinementRadius, + rng, + iterations, + tuning, + ); + + const energy = totalEnergy(nodes, edges, tuning); + if (energy < bestEnergy) { + bestEnergy = energy; + best = nodes.map((node) => ({ x: node.x, y: node.y })); + } + } + + for (let i = 0; i < count; i++) { + nodes[i]!.x = best[i]!.x; + nodes[i]!.y = best[i]!.y; + } + + // 2-opt is the only step that can eliminate multi-edge crossing patterns; + // gated to N ≤ twoOptMaxNodes, so above that cap the returned layout may + // still cross. + if (count <= tuning.twoOptMaxNodes) { + twoOptSwaps(nodes, edges, tuning); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/protocol.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/protocol.ts new file mode 100644 index 00000000000..5cad22e176f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/protocol.ts @@ -0,0 +1,389 @@ +/** + * Message protocol for the worker <-> main thread boundary. + * + * Three kinds of traffic share this boundary: a versioned frame stream + * ({@link StructureFrameMessage} on topology changes, followed by + * {@link PositionsFrameMessage} ticks that are valid only against the latest + * structure version), a side channel for shared-buffer lifecycle and the + * entity-id lookup table ({@link LayoutSideChannelMessage}), and + * request/response pairs correlated by `requestId` (e.g. + * {@link QueryEgoMessage} / {@link EgoResultMessage}). + */ + +import type { VizConfig } from "../config"; +import type { PositionsFrame, StructureFrame } from "../frames"; +import type { ClusterId, EntityIndex, VizMode } from "../ids"; +import type { + EntityId, + LinkData, + PropertyObject, + VersionedUrl, +} from "@blockprotocol/type-system"; + +export interface TypeSchemaEntry { + readonly url: VersionedUrl; + readonly title: string; + /** For a link type, the inverse (target -> source) title, e.g. "Member Of" for "Has Member". */ + readonly inverseTitle?: string; + readonly icon?: string; + readonly allOfRefs: readonly VersionedUrl[]; +} + +/** A property type's human-readable title, keyed by base URL. */ +export interface PropertySchemaEntry { + readonly baseUrl: string; + readonly title: string; +} + +export interface IngestEntity { + readonly entityId: EntityId; + readonly entityTypeIds: readonly VersionedUrl[]; + readonly label?: string; + readonly isLink: boolean; + /** + * Whether this entity is a query root. Non-root nodes are frontier nodes + * (fetched link endpoints, rendered greyed-out until expanded). + * + * Always false for links. + */ + readonly isRoot: boolean; + readonly linkData?: LinkData; + /** Property values for node entities. Absent for links. */ + readonly properties?: PropertyObject; +} + +/** Boot the entity-graph lifecycle ({@link EntityGraphWorker}) in this worker. */ +export interface InitEntityMessage { + readonly type: "INIT_ENTITY"; + readonly config: VizConfig; + readonly typeSchemas: readonly TypeSchemaEntry[]; + readonly propertySchemas: readonly PropertySchemaEntry[]; +} + +export interface RegisterTypesMessage { + readonly type: "REGISTER_TYPES"; + readonly typeSchemas: readonly TypeSchemaEntry[]; + readonly propertySchemas: readonly PropertySchemaEntry[]; +} + +/** + * Replace the live {@link VizConfig} without recreating the worker: stores + * and ingest state are kept, layouts are rebuilt under the new tuning + * (warm-seeded from current positions), and the regime thresholds are + * re-evaluated. This is what lets config knobs (dev harness today, + * user-facing settings later) apply without a full visualizer reset. + */ +export interface UpdateConfigMessage { + readonly type: "UPDATE_CONFIG"; + readonly config: VizConfig; +} + +export interface IngestBatchMessage { + readonly type: "INGEST_BATCH"; + readonly batchId: string; + readonly entities: readonly IngestEntity[]; +} + +export interface ViewportChangedMessage { + readonly type: "VIEWPORT_CHANGED"; + readonly frameId: string; + readonly zoom: number; + readonly center: readonly [number, number]; + readonly width: number; + readonly height: number; +} + +export interface EmbeddingClusteringResultMessage { + readonly type: "EMBEDDING_CLUSTERING_RESULT"; + readonly clusterId: ClusterId; + readonly clusters: readonly { + readonly clusterId: number; + readonly entityIds: readonly string[]; + }[]; +} + +/** + * The currently visible representative of a selected node's neighbor: the entity + * itself when individually rendered, or the cluster bubble it is collapsed into. + */ +export type EgoTarget = + | { readonly kind: "entity"; readonly entityIdx: EntityIndex } + | { readonly kind: "cluster"; readonly clusterId: ClusterId }; + +/** + * Request a selected node's ego: its neighbors' visible representatives. + * + * Reply: {@link EgoResultMessage}, correlated by {@link requestId}. + */ +export interface QueryEgoMessage { + readonly type: "QUERY_EGO"; + readonly requestId: number; + /** EntityIdx (join key) of the selected node. */ + readonly entityIdx: EntityIndex; +} + +/** + * Pin a hierarchical leaf cluster open (and its ancestors) regardless of zoom, + * or `null` to clear. + */ +export interface SetPinnedMessage { + readonly type: "SET_PINNED"; + readonly clusterId: ClusterId | null; +} + +/** + * Set the highlighted entities. All others are dimmed. + * + * Empty restores full colour. + */ +export interface SetHighlightMessage { + readonly type: "SET_HIGHLIGHT"; + readonly entityIdxs: readonly EntityIndex[]; +} + +/** + * Request the link entities a highway lane aggregates. + * + * Reply: {@link HighwayLinksResultMessage}, correlated by {@link requestId}. + */ +export interface QueryHighwayLinksMessage { + readonly type: "QUERY_HIGHWAY_LINKS"; + readonly requestId: number; + readonly laneId: number; +} + +/** + * Freeze or resume the layout simulation. While paused the worker stops + * ticking (no CPU spent settling, no positions frames emitted); ingest, + * commits, and queries keep working, and layouts resume exactly where they + * stopped. Sent when the visualizer is not visible (covered by a slide, or + * the tab is hidden) so background instances cost nothing. + */ +export interface SetSimulationPausedMessage { + readonly type: "SET_SIMULATION_PAUSED"; + readonly paused: boolean; +} + +/** + * Debug hook: serializes the live flat-tier layout (positions, radii, + * deduped edges, Louvain labels) for deterministic bench replay via + * {@link CapturedLayoutFixture}. + * + * Reply: {@link LayoutFixtureResultMessage}, correlated by {@link requestId}. + */ +export interface CaptureLayoutFixtureMessage { + readonly type: "CAPTURE_LAYOUT_FIXTURE"; + readonly requestId: number; +} + +export type MainToWorkerMessage = + | InitEntityMessage + | RegisterTypesMessage + | UpdateConfigMessage + | IngestBatchMessage + | ViewportChangedMessage + | EmbeddingClusteringResultMessage + | QueryEgoMessage + | SetPinnedMessage + | SetHighlightMessage + | QueryHighwayLinksMessage + | CaptureLayoutFixtureMessage + | SetSimulationPausedMessage; + +export interface ReadyMessage { + readonly type: "READY"; +} + +/** + * Topology changed (cut or ingest). Carries identities, styles, and edge topology. + * + * Sent infrequently relative to {@link PositionsFrameMessage}. + */ +export interface StructureFrameMessage { + readonly type: "STRUCTURE_FRAME"; + readonly frame: StructureFrame; +} + +/** + * Positions changed (force layout tick). Carries bounded cluster positions and + * freshly-computed edge geometry. Valid only against the latest + * {@link StructureFrameMessage}. + * + * The main thread must drop ticks whose structure version is stale; the + * worker never interleaves a structure commit and a position tick for the + * same version. + */ +export interface PositionsFrameMessage { + readonly type: "POSITIONS_FRAME"; + readonly frame: PositionsFrame; +} + +export interface ModeChangedMessage { + readonly type: "MODE_CHANGED"; + readonly oldMode: VizMode; + readonly newMode: VizMode; +} + +export interface EmbeddingClusteringNeededMessage { + readonly type: "EMBEDDING_CLUSTERING_NEEDED"; + readonly clusterId: ClusterId; + readonly entityIds: readonly string[]; + readonly clusterCount: number; +} + +/** + * An open leaf's entity positions live in this shared buffer. + * + * Positions are local to the leaf center: `[version_i32, x0, y0, x1, y1, ...]`. + * The worker is the sole writer of both the version and the positions; the + * main thread reads only after observing the version change, via Atomics or + * message ordering. + */ +export interface LayoutCreatedMessage { + readonly type: "LAYOUT_CREATED"; + readonly clusterId: ClusterId; + readonly buffer: SharedArrayBuffer | ArrayBuffer; + readonly nodeIds: readonly string[]; + /** + * Present when `buffer` is a flat-tier `FlatGraphBuffer` (positions + radii + + * colours in regions sized by this capacity). Absent for a positions-only buffer. + */ + readonly flatCapacity?: number; +} + +export interface LayoutPositionsMessage { + readonly type: "LAYOUT_POSITIONS"; + readonly clusterId: ClusterId; + /** Copied position data. Only sent when SharedArrayBuffer is unavailable. */ + readonly positions: Float32Array; +} + +export interface LayoutDestroyedMessage { + readonly type: "LAYOUT_DESTROYED"; + readonly clusterId: ClusterId; +} + +/** Identifies which shared buffer a {@link BufferRepublishedMessage} replaces. */ +export type RepublishTarget = { + readonly kind: "layout"; + readonly clusterId: ClusterId; +}; + +/** + * A shared buffer was re-allocated (outgrew its `maxByteLength` ceiling, or the + * platform cannot grow shared buffers in place). All bytes are preserved. + * + * Only sent on re-allocation; in-place growth requires no message. + */ +export interface BufferRepublishedMessage { + readonly type: "BUFFER_REPUBLISHED"; + readonly target: RepublishTarget; + readonly buffer: SharedArrayBuffer | ArrayBuffer; + /** Records the new buffer can hold. */ + readonly capacity: number; +} + +/** + * EntityIdx to EntityId lookup table in a shared buffer. + * + * The worker is the sole writer. Sent on first publish and re-sent on + * re-allocation; in-place growth needs no message. + */ +export interface EntityIdMapMessage { + readonly type: "ENTITY_ID_MAP"; + readonly buffer: SharedArrayBuffer | ArrayBuffer; + /** Entities the buffer can hold. */ + readonly capacity: number; +} + +/** + * Out-of-band worker-to-main messages for shared-buffer lifecycle and + * entity-id lookup, not tied to frame versions. + */ +export type LayoutSideChannelMessage = + | LayoutCreatedMessage + | LayoutDestroyedMessage + | LayoutPositionsMessage + | BufferRepublishedMessage + | EntityIdMapMessage; + +/** + * Posted when worker construction, type registration, or a handler throws; + * the worker stays alive but may be partially initialized. + */ +export interface ErrorMessage { + readonly type: "ERROR"; + readonly message: string; + readonly context?: string; +} + +/** + * Reply to {@link QueryEgoMessage}. + * + * Neighbors not in the current view are omitted. + */ +export interface EgoResultMessage { + readonly type: "EGO_RESULT"; + readonly requestId: number; + readonly targets: readonly EgoTarget[]; +} + +/** + * Reply to {@link QueryHighwayLinksMessage}. + * + * Empty when the lane has no aggregate identity (individual edge or out-of-range id). + */ +export interface HighwayLinksResultMessage { + readonly type: "HIGHWAY_LINKS_RESULT"; + readonly requestId: number; + readonly linkEntityIdxs: readonly EntityIndex[]; +} + +/** + * A live layout graph serialized for replay as a bench/test fixture (see + * {@link CaptureLayoutFixtureMessage}). Plain JSON: node ids are the layout's + * opaque node ids, edges reference them, `communities[i]` labels `nodes[i]`. + */ +export interface CapturedLayoutFixture { + /** ISO capture timestamp (metadata only; replay ignores it). */ + readonly capturedAt: string; + readonly nodes: readonly { + readonly id: string; + readonly x: number; + readonly y: number; + readonly radius: number; + }[]; + readonly edges: readonly { + readonly source: string; + readonly target: string; + readonly weight: number; + }[]; + /** Louvain community per node, parallel to {@link nodes} (-1 = unassigned). */ + readonly communities: readonly number[]; +} + +/** + * Reply to {@link CaptureLayoutFixtureMessage}. `fixture` is null when no + * flat-tier layout is live (hierarchical mode or an empty graph). + */ +export interface LayoutFixtureResultMessage { + readonly type: "LAYOUT_FIXTURE_RESULT"; + readonly requestId: number; + readonly fixture: CapturedLayoutFixture | null; +} + +export type WorkerToMainMessage = + | ReadyMessage + | StructureFrameMessage + | PositionsFrameMessage + | ModeChangedMessage + | EmbeddingClusteringNeededMessage + | LayoutCreatedMessage + | LayoutPositionsMessage + | LayoutDestroyedMessage + | BufferRepublishedMessage + | EntityIdMapMessage + | ErrorMessage + | EgoResultMessage + | HighwayLinksResultMessage + | LayoutFixtureResultMessage; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/store/type-registry.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/store/type-registry.test.ts new file mode 100644 index 00000000000..eee335c2e32 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/store/type-registry.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; + +import { TypeRegistry } from "./type-registry"; + +import type { TypeSchemaEntry } from "../protocol"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +const url = (slug: string): VersionedUrl => + `https://example.com/types/entity-type/${slug}/v/1` as VersionedUrl; + +describe("TypeRegistry root resolution", () => { + it("resolves a child's root when the child is interned BEFORE its parent", () => { + const customer = url("customer"); + const company = url("company"); + + const schemas: TypeSchemaEntry[] = [ + { url: customer, title: "Customer", allOfRefs: [company] }, + { url: company, title: "Company", allOfRefs: [] }, + ]; + + const registry = new TypeRegistry(); + registry.registerAll(schemas); + + const customerId = registry.intern(customer); + const companyId = registry.intern(company); + + expect(customerId).toBeLessThan(companyId); + expect(registry.get(companyId)?.rootIds).toEqual([companyId]); + expect(registry.get(customerId)?.rootIds).toEqual([companyId]); + }); + + it("resolves the SAME root for siblings so they bucket together", () => { + const customer = url("customer"); + const supplier = url("supplier"); + const company = url("company"); + + const schemas: TypeSchemaEntry[] = [ + { url: customer, title: "Customer", allOfRefs: [company] }, + { url: supplier, title: "Supplier", allOfRefs: [company] }, + { url: company, title: "Company", allOfRefs: [] }, + ]; + + const registry = new TypeRegistry(); + registry.registerAll(schemas); + + const companyId = registry.intern(company); + expect(registry.get(registry.intern(customer))?.rootIds).toEqual([ + companyId, + ]); + expect(registry.get(registry.intern(supplier))?.rootIds).toEqual([ + companyId, + ]); + }); + + it("resolves a multi-level chain to the topmost ancestor", () => { + const customer = url("customer"); + const company = url("company"); + const actor = url("actor"); + + const schemas: TypeSchemaEntry[] = [ + { url: customer, title: "Customer", allOfRefs: [company, actor] }, + { url: company, title: "Company", allOfRefs: [actor] }, + { url: actor, title: "Actor", allOfRefs: [] }, + ]; + + const registry = new TypeRegistry(); + registry.registerAll(schemas); + + const actorId = registry.intern(actor); + expect(registry.get(registry.intern(customer))?.rootIds).toEqual([actorId]); + }); +}); + +describe("TypeRegistry colour slots", () => { + it("assigns slots sorted by base URL within a batch", () => { + const customer = url("customer"); + const supplier = url("supplier"); + const company = url("company"); + + const registry = new TypeRegistry(); + registry.registerAll([ + { url: customer, title: "Customer", allOfRefs: [company] }, + { url: supplier, title: "Supplier", allOfRefs: [company] }, + { url: company, title: "Company", allOfRefs: [] }, + ]); + + expect(registry.colorSlot(registry.intern(company))).toBe(0); + expect(registry.colorSlot(registry.intern(customer))).toBe(1); + expect(registry.colorSlot(registry.intern(supplier))).toBe(2); + }); + + it("appends new batches without re-slotting existing types", () => { + const company = url("company"); + const person = url("person"); + const actor = url("actor"); + + const registry = new TypeRegistry(); + registry.registerAll([{ url: company, title: "Company", allOfRefs: [] }]); + const companySlot = registry.colorSlot(registry.intern(company)); + + registry.registerAll([ + { url: person, title: "Person", allOfRefs: [] }, + { url: actor, title: "Actor", allOfRefs: [] }, + ]); + + expect(registry.colorSlot(registry.intern(company))).toBe(companySlot); + expect(registry.colorSlot(registry.intern(actor))).toBe(1); + expect(registry.colorSlot(registry.intern(person))).toBe(2); + }); + + it("gives identical slots regardless of arrival order (reload stability)", () => { + const customer = url("customer"); + const supplier = url("supplier"); + const company = url("company"); + + const forward = new TypeRegistry(); + forward.registerAll([ + { url: customer, title: "Customer", allOfRefs: [company] }, + { url: supplier, title: "Supplier", allOfRefs: [company] }, + { url: company, title: "Company", allOfRefs: [] }, + ]); + + const reversed = new TypeRegistry(); + reversed.registerAll([ + { url: company, title: "Company", allOfRefs: [] }, + { url: supplier, title: "Supplier", allOfRefs: [company] }, + { url: customer, title: "Customer", allOfRefs: [company] }, + ]); + + for (const typeUrl of [company, customer, supplier]) { + expect(forward.colorSlot(forward.intern(typeUrl))).toBe( + reversed.colorSlot(reversed.intern(typeUrl)), + ); + } + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/store/type-registry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/store/type-registry.ts new file mode 100644 index 00000000000..9cb2c7ebbf6 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/store/type-registry.ts @@ -0,0 +1,237 @@ +/** + * Type registry: VersionedUrl interning, per-type metadata, ancestor + * closures, and stable colour slot assignment. + */ +import { extractBaseUrl } from "@blockprotocol/type-system"; + +import { BitSet } from "../collections/bitset"; +import { Interner } from "../collections/interner"; + +import type { TypeId } from "../../ids"; +import type { TypeSchemaEntry } from "../protocol"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +export interface TypeInfo { + readonly id: TypeId; + readonly url: VersionedUrl; + readonly title: string; + /** For a link type, the inverse (target to source) title. */ + readonly inverseTitle?: string; + readonly icon?: string; + readonly parentIds: readonly TypeId[]; + readonly ancestorClosure: BitSet; + readonly depth: number; + readonly rootIds: readonly TypeId[]; +} + +export class TypeRegistry { + readonly #interner: Interner; + readonly #types: (TypeInfo | undefined)[]; + /** + * Stable colour slot per type. Sorted by base URL within each batch + * and append-only, so a type's colour is deterministic across reloads. + */ + readonly #colorSlots: Map; + #nextColorSlot: number; + + constructor() { + this.#interner = new Interner(); + this.#types = []; + this.#colorSlots = new Map(); + this.#nextColorSlot = 0; + } + + get size(): number { + return this.#interner.size; + } + + intern(url: VersionedUrl): TypeId { + return this.#interner.intern(url); + } + + get(id: TypeId): TypeInfo | undefined { + return this.#types[id]; + } + + getUrl(id: TypeId): VersionedUrl | undefined { + return id < this.#interner.size ? this.#interner.getValue(id) : undefined; + } + + /** Stable colour slot for a type, or `undefined` if not yet registered. */ + colorSlot(id: TypeId): number | undefined { + return this.#colorSlots.get(id); + } + + /** + * One line per registered type. An interned parent without a schema + * shows as `#(unreg)`. + */ + debugDump(): string { + const name = (id: TypeId): string => + this.#types[id]?.title ?? `#${id}(unreg)`; + const lines: string[] = []; + for (const info of this.#types) { + if (!info) { + continue; + } + const parents = info.parentIds.map(name).join(", "); + const roots = info.rootIds.map(name).join(", "); + lines.push( + `#${info.id} "${info.title}" parents=[${parents}] roots=[${roots}]`, + ); + } + return lines.join("\n"); + } + + /** + * Register type schemas. Two passes: first intern everything (so parent + * refs resolve), then build ancestor closures. Returns whether any + * schema was newly registered. + */ + registerAll(schemas: readonly TypeSchemaEntry[]): boolean { + const newlyRegistered: TypeId[] = []; + + for (const schema of schemas) { + const id = this.#interner.intern(schema.url); + + if (this.#types[id]) { + continue; + } + + newlyRegistered.push(id); + const parentIds = schema.allOfRefs.map((ref) => + this.#interner.intern(ref), + ); + + this.#types[id] = { + id, + url: schema.url, + title: schema.title, + inverseTitle: schema.inverseTitle, + icon: schema.icon, + parentIds, + ancestorClosure: BitSet.empty(this.#interner.size), + depth: 0, + rootIds: [], + }; + } + + if (newlyRegistered.length > 0) { + this.#assignColorSlots(newlyRegistered); + this.#computeClosures(); + } + + return newlyRegistered.length > 0; + } + + #assignColorSlots(newlyRegistered: readonly TypeId[]): void { + // newlyRegistered ids were just pushed to #types in registerAll, so + // entries exist. + const sorted = [...newlyRegistered].sort((left, right) => { + const leftUrl = extractBaseUrl(this.#types[left]!.url); + const rightUrl = extractBaseUrl(this.#types[right]!.url); + if (leftUrl < rightUrl) { + return -1; + } + return leftUrl > rightUrl ? 1 : 0; + }); + for (const id of sorted) { + this.#colorSlots.set(id, this.#nextColorSlot); + this.#nextColorSlot += 1; + } + } + + #computeClosures(): void { + const universeSize = this.#interner.size; + + const closures = new Map>(); + const depths = new Map(); + const roots = new Map(); + + for (const info of this.#types) { + if (!info) { + continue; + } + closures.set(info.id, BitSet.fromBit(universeSize, info.id)); + depths.set(info.id, 0); + roots.set(info.id, []); + } + + // Fixed-point: closures, depths, and roots propagate up the DAG until stable. + // Typically converges in 2 to 3 passes for shallow hierarchies. + let changed = true; + while (changed) { + changed = false; + + for (const info of this.#types) { + if (!info) { + continue; + } + + const current = closures.get(info.id)!; + let merged = current; + + for (const parentId of info.parentIds) { + const parentClosure = closures.get(parentId); + if (parentClosure) { + const next = merged.or(parentClosure); + if (next.cardinality > merged.cardinality) { + merged = next; + changed = true; + } + } + } + + closures.set(info.id, merged); + + if (info.parentIds.length > 0) { + const parentDepth = Math.max( + ...info.parentIds.map((parentId) => depths.get(parentId) ?? 0), + ); + const newDepth = parentDepth + 1; + if (newDepth > (depths.get(info.id) ?? 0)) { + depths.set(info.id, newDepth); + changed = true; + } + } + + // Roots: a parentless type is its own root; otherwise inherit the + // union of parent roots. Must be inside the fixed-point because + // parents are interned lazily from allOfRefs (child-before-parent + // is common), so a forward pass would read uncomputed parent roots. + if (info.parentIds.length === 0) { + if (roots.get(info.id)!.length === 0) { + roots.set(info.id, [info.id]); + changed = true; + } + } else { + const rootSet = new Set(roots.get(info.id)); + const before = rootSet.size; + for (const parentId of info.parentIds) { + for (const rootId of roots.get(parentId) ?? []) { + rootSet.add(rootId); + } + } + if (rootSet.size > before) { + roots.set(info.id, [...rootSet]); + changed = true; + } + } + } + } + + for (let i = 0; i < this.#types.length; i++) { + const info = this.#types[i]; + if (!info) { + continue; + } + + this.#types[i] = { + ...info, + ancestorClosure: closures.get(info.id) ?? info.ancestorClosure, + depth: depths.get(info.id) ?? 0, + rootIds: roots.get(info.id) ?? [], + }; + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/type-graph/protocol.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/type-graph/protocol.ts new file mode 100644 index 00000000000..1a05f7c938a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/type-graph/protocol.ts @@ -0,0 +1,132 @@ +/** + * Messages specific to the type-graph lifecycle ({@link "./worker"}). The + * worker entry point accepts these alongside the lifecycle-neutral messages + * from {@link "../protocol"} (`UPDATE_CONFIG`, `SET_SIMULATION_PAUSED`); + * frames and shared-buffer side messages reuse the shared shapes, so the + * render pipeline consumes both lifecycles identically. + * + * Node identity: type nodes are keyed by {@link TypeId} (the worker's + * VersionedUrl interning, shared with type registration). The worker owns + * the interner and publishes the id -> url table via + * {@link TypeIdTableMessage}; urls appear in messages only where the main + * thread genuinely speaks urls (ingest). + */ +import type { VizConfig } from "../../config"; +import type { TypeId } from "../../ids"; +import type { + SetSimulationPausedMessage, + TypeSchemaEntry, + UpdateConfigMessage, +} from "../protocol"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +/** + * The slice of {@link VizConfig} the type-graph lifecycle reads: the two + * flat layout engines and their switch-over thresholds, node/edge styling, + * seeding geometry, and diagnostics. A full VizConfig is assignable, so + * main-thread callers can pass their config through unchanged. + */ +export type TypeGraphConfig = Pick< + VizConfig, + | "flatLayoutMaxNodes" + | "flatLayoutExitNodes" + | "flatForce" + | "majorization" + | "entityStyle" + | "stability" + | "debug" +>; + +/** + * One type node. Extends the schema shape so the same object registers into + * the worker's {@link "../store/type-registry"} (colour families from + * `allOfRefs` inheritance roots). + * + * `isLoaded: false` marks a frontier node: a type referenced by an edge but + * not itself fetched yet (e.g. a remote type). Frontier nodes render grey; + * re-ingesting the node with `isLoaded: true` (and its real `allOfRefs`) + * upgrades it in place. + */ +export interface IngestTypeNode extends TypeSchemaEntry { + readonly isLoaded: boolean; +} + +/** One directed type edge: a link type connecting a source to a target type. */ +export interface IngestTypeEdge { + readonly sourceUrl: VersionedUrl; + readonly targetUrl: VersionedUrl; + readonly linkTypeUrl: VersionedUrl; +} + +/** Boot the type-graph lifecycle ({@link "./worker"}) in this worker. */ +export interface InitTypeMessage { + readonly type: "INIT_TYPE"; + readonly config: TypeGraphConfig; +} + +/** + * Add type nodes and edges. Both are idempotent (per url, per + * source/target/link-type triple), so re-sending overlapping batches is + * safe; an edge endpoint never ingested as a node is auto-added as a + * frontier node. + * + * `linkTypeSchemas` registers the link types the edges reference (they are + * edges here, never nodes) so each gets a stable colour slot; without a + * registered schema an edge falls back to neutral grey. + */ +export interface IngestTypesMessage { + readonly type: "INGEST_TYPES"; + readonly nodes: readonly IngestTypeNode[]; + readonly edges: readonly IngestTypeEdge[]; + readonly linkTypeSchemas: readonly TypeSchemaEntry[]; +} + +/** + * Set the highlighted type nodes; all others (and edges not fully inside the + * set) dim. Empty restores full colour. + */ +export interface SetTypeHighlightMessage { + readonly type: "SET_TYPE_HIGHLIGHT"; + readonly typeIds: readonly TypeId[]; +} + +/** + * Request a selected type node's neighbours. + * + * Reply: {@link TypeEgoResultMessage}, correlated by {@link requestId}. + */ +export interface QueryTypeEgoMessage { + readonly type: "QUERY_TYPE_EGO"; + readonly requestId: number; + readonly typeId: TypeId; +} + +export type MainToTypeWorkerMessage = + | InitTypeMessage + | IngestTypesMessage + | SetTypeHighlightMessage + | QueryTypeEgoMessage + | UpdateConfigMessage + | SetSimulationPausedMessage; + +/** + * The `TypeId -> VersionedUrl` join table: `urls[i]` is the url interned as + * `TypeId(startId + i)`. Interning is append-only, so each message carries + * only the new tail; the main thread appends into its own array and resolves + * the u32 join keys in the flat node buffer (and {@link TypeEgoResultMessage} + * ids) against it. Strings are variable-length, so unlike the entity-id map + * this table travels by message, not a shared buffer -- at type-graph scale + * (hundreds) that is negligible. + */ +export interface TypeIdTableMessage { + readonly type: "TYPE_ID_TABLE"; + readonly startId: TypeId; + readonly urls: readonly VersionedUrl[]; +} + +/** Reply to {@link QueryTypeEgoMessage}: the neighbours' TypeIds. */ +export interface TypeEgoResultMessage { + readonly type: "TYPE_EGO_RESULT"; + readonly requestId: number; + readonly typeIds: readonly TypeId[]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/type-graph/store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/type-graph/store.ts new file mode 100644 index 00000000000..5468fd35f2b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/type-graph/store.ts @@ -0,0 +1,121 @@ +/** + * The type graph's node/edge store: which interned {@link TypeId}s are graph + * nodes (vs. types interned only as `allOf` parents or edge link types), + * their loaded/frontier state, and the deduplicated directed edge list with + * per-node adjacency. + * + * Add-only, like the entity stores: nodes and edges are never removed, and a + * frontier node only ever flips to loaded. That keeps change detection O(1) + * (count comparisons) in the worker's commit path. + */ +import type { TypeId } from "../../ids"; + +/** One directed edge: a link type connecting a source to a target type node. */ +export interface TypeGraphEdge { + readonly source: TypeId; + readonly target: TypeId; + readonly linkTypeId: TypeId; +} + +const EMPTY_NEIGHBOURS: ReadonlySet = new Set(); + +export class TypeGraphStore { + /** Graph nodes in insertion order (layout builds iterate this). */ + readonly #nodes: TypeId[] = []; + readonly #nodeSet = new Set(); + readonly #loaded = new Set(); + + readonly #edges: TypeGraphEdge[] = []; + /** `source|target|linkTypeId` triples already inserted. */ + readonly #edgeKeys = new Set(); + + /** Distinct neighbours per node (both directions, self excluded). */ + readonly #adjacency = new Map>(); + /** Edge-incidence count per node (a self-loop counts once). */ + readonly #degree = new Map(); + + get nodeCount(): number { + return this.#nodes.length; + } + + get edgeCount(): number { + return this.#edges.length; + } + + /** Graph nodes in insertion order. Do not mutate. */ + get nodes(): readonly TypeId[] { + return this.#nodes; + } + + /** Deduplicated edges in insertion order. Do not mutate. */ + get edges(): readonly TypeGraphEdge[] { + return this.#edges; + } + + hasNode(id: TypeId): boolean { + return this.#nodeSet.has(id); + } + + isLoaded(id: TypeId): boolean { + return this.#loaded.has(id); + } + + /** + * Add a node (or upgrade an existing frontier node to loaded). Returns + * whether anything changed, so the caller can skip no-op commits. + */ + addNode(id: TypeId, isLoaded: boolean): boolean { + if (!this.#nodeSet.has(id)) { + this.#nodeSet.add(id); + this.#nodes.push(id); + if (isLoaded) { + this.#loaded.add(id); + } + return true; + } + + if (isLoaded && !this.#loaded.has(id)) { + this.#loaded.add(id); + return true; + } + + return false; + } + + /** Add an edge unless the exact triple exists. Returns whether it was new. */ + addEdge(edge: TypeGraphEdge): boolean { + const key = `${edge.source}|${edge.target}|${edge.linkTypeId}`; + if (this.#edgeKeys.has(key)) { + return false; + } + this.#edgeKeys.add(key); + this.#edges.push(edge); + + this.#degree.set(edge.source, (this.#degree.get(edge.source) ?? 0) + 1); + if (edge.target !== edge.source) { + this.#degree.set(edge.target, (this.#degree.get(edge.target) ?? 0) + 1); + this.#neighbourSet(edge.source).add(edge.target); + this.#neighbourSet(edge.target).add(edge.source); + } + + return true; + } + + /** Distinct neighbours of a node (both directions, self excluded). */ + neighboursOf(id: TypeId): ReadonlySet { + return this.#adjacency.get(id) ?? EMPTY_NEIGHBOURS; + } + + degreeOf(id: TypeId): number { + return this.#degree.get(id) ?? 0; + } + + #neighbourSet(id: TypeId): Set { + let set = this.#adjacency.get(id); + if (!set) { + set = new Set(); + this.#adjacency.set(id, set); + } + return set; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/type-graph/worker.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/type-graph/worker.test.ts new file mode 100644 index 00000000000..3f9f4a291cd --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/type-graph/worker.test.ts @@ -0,0 +1,280 @@ +/** + * Guards for the {@link TypeGraphWorker} lifecycle: ingest idempotency, the + * id-table-before-buffer publish order, frontier upgrade without a layout + * rebuild, highlight dimming, self-loop rendering, and the engine switch + * above the flat-force threshold. + */ +import { describe, expect, it, vi } from "vitest"; + +import { defaultVizConfig } from "../../config"; +import { + FLAT_COLOR_BYTE_OFFSET, + FLAT_HEADER_BYTES, + FLAT_RECORD_BYTES, +} from "../buffers/position-buffer"; +import { FRONTIER_COLOR } from "../entity-style"; +import { TypeGraphWorker } from "./worker"; + +import type { PositionsFrame, StructureFrame } from "../../frames"; +import type { TypeId } from "../../ids"; +import type { LayoutCreatedMessage, TypeSchemaEntry } from "../protocol"; +import type { IngestTypeEdge, IngestTypeNode } from "./protocol"; +import type { TypeGraphSideMessage } from "./worker"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +const typeUrl = (slug: string): VersionedUrl => + `https://example.com/types/entity-type/${slug}/v/1` as VersionedUrl; + +const node = ( + slug: string, + opts?: { readonly loaded?: boolean; readonly parents?: readonly string[] }, +): IngestTypeNode => ({ + url: typeUrl(slug), + title: slug, + allOfRefs: (opts?.parents ?? []).map(typeUrl), + isLoaded: opts?.loaded ?? true, +}); + +const edge = ( + source: string, + target: string, + linkType: string, +): IngestTypeEdge => ({ + sourceUrl: typeUrl(source), + targetUrl: typeUrl(target), + linkTypeUrl: typeUrl(linkType), +}); + +const linkSchema = (slug: string): TypeSchemaEntry => ({ + url: typeUrl(slug), + title: slug, + allOfRefs: [], +}); + +interface Harness { + readonly worker: TypeGraphWorker; + readonly structure: ReturnType; + readonly positions: ReturnType; + readonly side: ReturnType; + sideMessages(): TypeGraphSideMessage[]; + /** TypeId for a url, resolved from the published TYPE_ID_TABLE messages. */ + idOf(url: VersionedUrl): TypeId; + lastStructure(): StructureFrame | undefined; + lastPositions(): PositionsFrame | undefined; + /** rgba of a node record in the latest published layout buffer. */ + colorOf(url: VersionedUrl): readonly [number, number, number, number]; +} + +function newHarness(): Harness { + const worker = new TypeGraphWorker(defaultVizConfig); + const structure = vi.fn(); + const positions = vi.fn(); + const side = vi.fn(); + worker.onStructureFrame = structure; + worker.onPositionsFrame = positions; + worker.onSideMessage = side; + + const sideMessages = (): TypeGraphSideMessage[] => + side.mock.calls.map(([msg]) => msg as TypeGraphSideMessage); + + const idOf = (url: VersionedUrl): TypeId => { + for (const msg of sideMessages()) { + if (msg.type !== "TYPE_ID_TABLE") { + continue; + } + const index = msg.urls.indexOf(url); + if (index >= 0) { + return (msg.startId + index) as TypeId; + } + } + throw new Error(`url never published: ${url}`); + }; + + return { + worker, + structure, + positions, + side, + sideMessages, + idOf, + lastStructure() { + const calls = structure.mock.calls; + return calls[calls.length - 1]?.[0] as StructureFrame | undefined; + }, + lastPositions() { + const calls = positions.mock.calls; + return calls[calls.length - 1]?.[0] as PositionsFrame | undefined; + }, + colorOf(url) { + const layouts = sideMessages().filter( + (msg): msg is LayoutCreatedMessage => msg.type === "LAYOUT_CREATED", + ); + const layout = layouts[layouts.length - 1]; + if (!layout) { + throw new Error("no layout published"); + } + const recordIdx = layout.nodeIds.indexOf(String(idOf(url))); + expect(recordIdx).toBeGreaterThanOrEqual(0); + const bytes = new Uint8Array(layout.buffer); + const offset = + FLAT_HEADER_BYTES + + recordIdx * FLAT_RECORD_BYTES + + FLAT_COLOR_BYTE_OFFSET; + return [ + bytes[offset]!, + bytes[offset + 1]!, + bytes[offset + 2]!, + bytes[offset + 3]!, + ]; + }, + }; +} + +describe("TypeGraphWorker", () => { + it("publishes the id table before the layout, then structure + positions", () => { + const harness = newHarness(); + harness.worker.ingest( + [node("person"), node("org")], + [edge("person", "org", "member-of")], + [linkSchema("member-of")], + ); + + const kinds = harness.sideMessages().map((msg) => msg.type); + expect(kinds).toContain("TYPE_ID_TABLE"); + expect(kinds).toContain("LAYOUT_CREATED"); + expect(kinds.indexOf("TYPE_ID_TABLE")).toBeLessThan( + kinds.indexOf("LAYOUT_CREATED"), + ); + + const structure = harness.lastStructure(); + expect(structure?.mode).toBe("flat-force"); + expect(structure?.flatGraph?.count).toBe(2); + expect(structure?.typeEdges).toEqual([ + { + source: harness.idOf(typeUrl("person")), + target: harness.idOf(typeUrl("org")), + linkTypeId: harness.idOf(typeUrl("member-of")), + }, + ]); + + const positions = harness.lastPositions(); + expect(positions?.beziers.segmentCount).toBe(1); + expect(positions?.beziers.ids[0]).toBe(0); + expect(positions?.flatArrows?.count).toBe(1); + }); + + it("treats a re-ingest of the same batch as a no-op", () => { + const harness = newHarness(); + const nodes = [node("person"), node("org")]; + const edges = [edge("person", "org", "member-of")]; + harness.worker.ingest(nodes, edges, [linkSchema("member-of")]); + + const structureCalls = harness.structure.mock.calls.length; + const sideCalls = harness.side.mock.calls.length; + + harness.worker.ingest(nodes, edges, [linkSchema("member-of")]); + + expect(harness.structure.mock.calls.length).toBe(structureCalls); + expect(harness.side.mock.calls.length).toBe(sideCalls); + }); + + it("auto-adds unknown edge endpoints as frontier and upgrades them in place", () => { + const harness = newHarness(); + harness.worker.ingest( + [node("person")], + [edge("person", "remote", "references")], + [linkSchema("references")], + ); + + expect(harness.colorOf(typeUrl("remote"))).toEqual([...FRONTIER_COLOR]); + const layoutCreations = harness + .sideMessages() + .filter((msg) => msg.type === "LAYOUT_CREATED").length; + + // The loaded re-ingest recolours without rebuilding the layout + // (topology is unchanged). + harness.worker.ingest([node("remote", { loaded: true })], [], []); + + expect( + harness.sideMessages().filter((msg) => msg.type === "LAYOUT_CREATED") + .length, + ).toBe(layoutCreations); + expect(harness.colorOf(typeUrl("remote"))).not.toEqual([...FRONTIER_COLOR]); + }); + + it("answers ego with distinct neighbours across both directions", () => { + const harness = newHarness(); + harness.worker.ingest( + [node("person"), node("org"), node("doc")], + [ + edge("person", "org", "member-of"), + edge("doc", "person", "authored-by"), + // A parallel link type must not duplicate the neighbour. + edge("person", "org", "admin-of"), + ], + [ + linkSchema("member-of"), + linkSchema("authored-by"), + linkSchema("admin-of"), + ], + ); + + const ego = harness.worker.ego(harness.idOf(typeUrl("person"))); + expect(new Set(ego)).toEqual( + new Set([harness.idOf(typeUrl("org")), harness.idOf(typeUrl("doc"))]), + ); + }); + + it("dims non-highlighted nodes and restores full colour on clear", () => { + const harness = newHarness(); + harness.worker.ingest( + [node("person"), node("org")], + [edge("person", "org", "member-of")], + [linkSchema("member-of")], + ); + + const fullOrg = harness.colorOf(typeUrl("org")); + harness.worker.setHighlight([harness.idOf(typeUrl("person"))]); + + const dimmedOrg = harness.colorOf(typeUrl("org")); + expect(dimmedOrg[3]).toBeLessThan(fullOrg[3]); + // Highlighted node keeps its full colour. + expect(harness.colorOf(typeUrl("person"))[3]).toBeGreaterThan(dimmedOrg[3]); + + harness.worker.setHighlight([]); + expect(harness.colorOf(typeUrl("org"))).toEqual(fullOrg); + }); + + it("renders a self-referential link type as a loop segment", () => { + const harness = newHarness(); + harness.worker.ingest( + [node("person")], + [edge("person", "person", "knows")], + [linkSchema("knows")], + ); + + expect(harness.lastStructure()?.flatGraph?.count).toBe(1); + const positions = harness.lastPositions(); + expect(positions?.beziers.segmentCount).toBe(1); + expect(positions?.flatArrows?.count).toBe(1); + }); + + it("switches to the majorization engine above the flat-force exit threshold", () => { + const harness = newHarness(); + const count = defaultVizConfig.flatLayoutExitNodes + 10; + const nodes: IngestTypeNode[] = []; + const edges: IngestTypeEdge[] = []; + for (let index = 0; index < count; index++) { + nodes.push(node(`type-${index}`)); + if (index > 0) { + edges.push(edge(`type-${index - 1}`, `type-${index}`, "linked-to")); + } + } + + harness.worker.ingest(nodes, edges, [linkSchema("linked-to")]); + + expect(harness.worker.engine).toBe("community-force"); + expect(harness.lastStructure()?.mode).toBe("community-force"); + expect(harness.lastStructure()?.flatGraph?.count).toBe(count); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/type-graph/worker.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/type-graph/worker.ts new file mode 100644 index 00000000000..c928e75d0d0 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/worker/type-graph/worker.ts @@ -0,0 +1,723 @@ +/** + * Worker-side orchestrator for the type-graph lifecycle: the ontology as one + * whole-graph flat layout, nodes = types, edges = link types. The second of + * the two worker lifecycles (see {@link "../entry"}); boots on `INIT_TYPE`. + * + * Deliberately a fraction of the entity lifecycle: no hierarchical regime, + * no viewport-driven LOD, no commit coalescing (type graphs arrive in a few + * hundred-node batches, not streamed tens of thousands). What it shares with + * the entity flat tier it shares by construction: the same layout engines + * (cola below the threshold, stress-majorization above, hysteretic), the + * same interleaved {@link FlatGraphBuffer} SAB the renderer reads, the same + * straight-edge writer, and the same frame shapes -- so the entire render + * pipeline works unchanged. + * + * Every structural change rebuilds the layout warm-seeded from current + * positions rather than absorbing in place: at ontology scale (hundreds of + * nodes) a rebuild is cheap, and it keeps this file free of the entity + * tier's absorb bookkeeping. + */ +import { dimColor } from "../../dim-color"; +import { nodeIdForTypeId, TypeId, typeIdFromNodeId } from "../../ids"; +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { PositionScratch } from "../collections/position-scratch"; +import { writeStraightFlatEdge } from "../core/flat-edge-writer"; +import { placeFlatSeeds } from "../core/flat-seed"; +import { FLAT_LAYOUT_ID, LayoutRegistry } from "../core/layout-registry"; +import { TickScheduler } from "../core/schedulers"; +import { + colorForType, + configureEntityStyle, + edgeColorForType, + entityStyle, + FRONTIER_COLOR, +} from "../entity-style"; +import { + BezierSegmentSink, + EndpointArrowSink, +} from "../geometry/edge-geometry"; +import { createFlatLayout } from "../layout/flat-layout"; +import { createMajorizationLayout } from "../layout/majorization-layout"; +import { TypeRegistry } from "../store/type-registry"; +import { TypeGraphStore } from "./store"; + +import type { + Color, + PositionsFrame, + RenderTypeEdge, + StructureFrame, +} from "../../frames"; +import type { RepublishHandler } from "../buffers/growable-buffer"; +import type { + ForceEdge, + ForceNode, + LayoutSimulation, +} from "../layout/force-simulation"; +import type { LayoutSideChannelMessage, TypeSchemaEntry } from "../protocol"; +import type { + IngestTypeEdge, + IngestTypeNode, + TypeGraphConfig, + TypeIdTableMessage, +} from "./protocol"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +/** The two engines the type graph runs (a subset of the entity VizModes). */ +type TypeGraphEngine = "flat-force" | "community-force"; + +/** Side-channel traffic this lifecycle posts (buffer lifecycle + id table). */ +export type TypeGraphSideMessage = + | LayoutSideChannelMessage + | TypeIdTableMessage; + +/** Over-allocate capacity so later ingests can append without reallocation. */ +function typeCapacityFor(count: number): number { + return Math.max(count + 64, Math.ceil(count * 1.5)); +} + +/** One rendered edge: local layout indices + commit-time colour. */ +interface TypeRenderEdge { + readonly sourceIdx: number; + readonly targetIdx: number; + readonly color: Color; + /** Index into the store's edge table (the segment's pick identity). */ + readonly edgeIdx: number; +} + +/** Self-loop anchor angles: rim points ±60° around "up", loop bows outward. */ +const SELF_LOOP_START_ANGLE = -Math.PI / 3; +const SELF_LOOP_END_ANGLE = (-2 * Math.PI) / 3; +/** How far (in node radii) a self-loop's control points reach outward. */ +const SELF_LOOP_REACH = 2.6; + +/** + * By-degree type-node radius. The entity formula + * ({@link "../entity-style".radiusForDegree}) with a gentler slope: an + * ontology hub (Person, Organization) can touch a third of the graph, and + * the full entity slope would turn it into a disk that dwarfs everything. + */ +function radiusForTypeDegree(degree: number): number { + const style = entityStyle(); + return ( + style.dotBaseRadius * + (1 + Math.log(1 + degree) * style.dotDegreeScale * 0.6) + ); +} + +/** + * Own a copy of the caller's config (the groups of a main-thread VizConfig + * are shared objects; a later UPDATE_CONFIG must not mutate what we read). + */ +function cloneTypeGraphConfig(config: TypeGraphConfig): TypeGraphConfig { + return { + flatLayoutMaxNodes: config.flatLayoutMaxNodes, + flatLayoutExitNodes: config.flatLayoutExitNodes, + flatForce: { ...config.flatForce }, + majorization: { ...config.majorization }, + entityStyle: { ...config.entityStyle }, + stability: { ...config.stability }, + debug: config.debug, + }; +} + +export class TypeGraphWorker { + #config: TypeGraphConfig; + + readonly #registry = new TypeRegistry(); + readonly #store = new TypeGraphStore(); + readonly #layouts = new LayoutRegistry(); + readonly #ticker = new TickScheduler(() => this.#tick()); + + /** Interleaved SharedArrayBuffer backing the layout (positions + radii + colours). */ + #buffer: FlatGraphBuffer | undefined; + /** Re-publishes the layout buffer on reallocation. */ + readonly #republishBuffer: RepublishHandler = (raw, capacity) => { + this.#onSideMessage?.({ + type: "BUFFER_REPUBLISHED", + target: { kind: "layout", clusterId: FLAT_LAYOUT_ID }, + buffer: raw, + capacity, + }); + }; + + #engine: TypeGraphEngine = "flat-force"; + /** Engine and edge count at the last layout build; a change forces a rebuild. */ + #builtEngine: TypeGraphEngine | undefined; + #builtEdgeCount = -1; + + #renderEdges: TypeRenderEdge[] = []; + + /** Type nodes kept at full colour while a highlight is active; empty = none. */ + #highlighted = new Set(); + + /** How many interned ids the main thread already has (see TYPE_ID_TABLE). */ + #publishedIdCount = 0; + + #structureVersion = 0; + #positionsVersion = 0; + readonly #bezierSink = new BezierSegmentSink(); + readonly #arrowSink = new EndpointArrowSink(); + /** Reusable prior-position buffer for warm-seeded rebuilds. */ + readonly #seedScratch = new PositionScratch(); + + #onSideMessage: ((msg: TypeGraphSideMessage) => void) | undefined; + #onStructureFrame: ((frame: StructureFrame) => void) | undefined; + #onPositionsFrame: ((frame: PositionsFrame) => void) | undefined; + + constructor(config: TypeGraphConfig) { + this.#config = cloneTypeGraphConfig(config); + // Colour/size style is module state (hot loops); install it before any + // commit pass runs. + configureEntityStyle(this.#config.entityStyle); + } + + get debug(): boolean { + return this.#config.debug; + } + + get nodeCount(): number { + return this.#store.nodeCount; + } + + get edgeCount(): number { + return this.#store.edgeCount; + } + + get engine(): TypeGraphEngine { + return this.#engine; + } + + set onSideMessage( + handler: ((msg: TypeGraphSideMessage) => void) | undefined, + ) { + this.#onSideMessage = handler; + } + + set onStructureFrame(handler: ((frame: StructureFrame) => void) | undefined) { + this.#onStructureFrame = handler; + } + + set onPositionsFrame(handler: ((frame: PositionsFrame) => void) | undefined) { + this.#onPositionsFrame = handler; + } + + /** + * Ingest a batch of nodes, edges, and the link-type schemas the edges + * reference, then commit if anything changed. Idempotent (see the message + * contract in {@link "./protocol"}). + */ + ingest( + nodes: readonly IngestTypeNode[], + edges: readonly IngestTypeEdge[], + linkTypeSchemas: readonly TypeSchemaEntry[], + ): void { + // Register schemas for loaded nodes only: a frontier node's allOfRefs are + // unknown, and registration is first-wins -- registering a placeholder + // would freeze the type into a wrong (parentless) colour family once the + // real schema arrives. Frontier nodes are interned below and render grey + // until their loaded re-ingest registers them. + this.#registry.registerAll(nodes.filter((node) => node.isLoaded)); + this.#registry.registerAll(linkTypeSchemas); + + let changed = false; + + for (const node of nodes) { + const id = this.#registry.intern(node.url); + if (this.#store.addNode(id, node.isLoaded)) { + changed = true; + } + } + + for (const edge of edges) { + const source = this.#registry.intern(edge.sourceUrl); + const target = this.#registry.intern(edge.targetUrl); + // An endpoint the caller never declared becomes a frontier node, so + // every edge is always drawable. + if (this.#store.addNode(source, false)) { + changed = true; + } + if (this.#store.addNode(target, false)) { + changed = true; + } + if ( + this.#store.addEdge({ + source, + target, + linkTypeId: this.#registry.intern(edge.linkTypeUrl), + }) + ) { + changed = true; + } + } + + if (changed) { + this.commit(); + } + } + + /** + * Commit the current graph: re-evaluate the engine, rebuild the layout + * warm-seeded when topology or engine changed, restyle the shared buffer, + * and emit fresh frames. + */ + commit(): void { + const count = this.#store.nodeCount; + + if (count === 0) { + this.#emitStructure(undefined); + this.#emitPositions(); + return; + } + + this.#engine = this.#nextEngine(count); + + const existing = this.#layouts.get(FLAT_LAYOUT_ID); + const structureChanged = + !existing || + this.#builtEngine !== this.#engine || + existing.nodes.length !== count || + this.#builtEdgeCount !== this.#store.edgeCount; + + let layoutRebuilt = false; + if (structureChanged) { + this.#rebuildLayout(); + layoutRebuilt = true; + } + + const layout = this.#layouts.get(FLAT_LAYOUT_ID); + const buffer = this.#buffer; + if (!layout || !buffer) { + return; + } + + // Runs on every commit (not only structural ones): a loaded flip or a + // registration recolours nodes without changing topology. + this.#writeStyle(layout, buffer); + this.#rebuildRenderEdges(layout); + + // The id table must cover every id the frames below reference, so the + // tail publish precedes LAYOUT_CREATED and the structure frame. + this.#publishIdTableTail(); + + // Announce a rebuilt layout only after the style pass has filled the + // buffer: the main thread starts reading the SAB on LAYOUT_CREATED, and + // an earlier post would let it render colourless zero-radius records. + if (layoutRebuilt) { + this.#onSideMessage?.({ + type: "LAYOUT_CREATED", + clusterId: FLAT_LAYOUT_ID, + buffer: buffer.raw, + nodeIds: layout.nodeIds, + flatCapacity: buffer.capacity, + }); + } + + this.#emitStructure(layout); + this.#emitPositions(); + } + + /** + * Set the highlighted type nodes (empty clears). Colour-only: restyles the + * shared buffer in place and re-emits positions so edge dimming follows. + */ + setHighlight(typeIds: readonly TypeId[]): void { + this.#highlighted = new Set(typeIds); + + const layout = this.#layouts.get(FLAT_LAYOUT_ID); + if (layout && this.#buffer) { + this.#writeStyle(layout, this.#buffer); + } + this.#emitPositions(); + } + + /** A selected type node's distinct neighbours. */ + ego(typeId: TypeId): TypeId[] { + return [...this.#store.neighboursOf(typeId)]; + } + + /** + * Replace the live config: the layout engines copy their tuning at + * construction, so the next commit force-rebuilds (warm-seeded). + */ + updateConfig(next: TypeGraphConfig): void { + this.#config = cloneTypeGraphConfig(next); + configureEntityStyle(this.#config.entityStyle); + this.#builtEngine = undefined; + this.commit(); + } + + /** Freeze or resume the simulation (backgrounded visualizers cost nothing). */ + setSimulationPaused(paused: boolean): void { + if (paused) { + this.#ticker.pause(); + } else { + this.#ticker.resume(); + } + } + + /** + * Engine selection with the entity tier's hysteresis (exit above + * `flatLayoutExitNodes`, re-enter below `flatLayoutMaxNodes`), so a graph + * hovering around the boundary doesn't flip engines on every ingest. + */ + #nextEngine(count: number): TypeGraphEngine { + if ( + this.#engine === "flat-force" && + count > this.#config.flatLayoutExitNodes + ) { + return "community-force"; + } + if ( + this.#engine === "community-force" && + count < this.#config.flatLayoutMaxNodes + ) { + return "flat-force"; + } + return this.#engine; + } + + /** (Re)build the layout over the full node set, warm-seeded from current positions. */ + #rebuildLayout(): void { + const { stability } = this.#config; + const previous = this.#layouts.get(FLAT_LAYOUT_ID); + + // Prior positions keep placed nodes in place; new nodes seed beside a + // placed neighbour or fall back to a phyllotaxis disk. + const placed = this.#seedScratch; + placed.reset(this.#registry.size); + if (previous) { + for (const node of previous.nodes) { + placed.set(typeIdFromNodeId(node.id), node.x ?? 0, node.y ?? 0); + } + } + + const typeIds = this.#store.nodes; + placeFlatSeeds(typeIds, placed, (id) => this.#store.neighboursOf(id), { + neighbourOffset: stability.flatSeedNeighbourOffset, + diskScale: stability.flatSeedDiskScale, + }); + + const nodes: ForceNode[] = typeIds.map((id) => ({ + id: nodeIdForTypeId(id), + x: placed.x(id), + y: placed.y(id), + radius: radiusForTypeDegree(this.#store.degreeOf(id)), + })); + const edges = this.#buildForceEdges(); + + const buffer = new FlatGraphBuffer( + typeCapacityFor(nodes.length), + this.#republishBuffer, + ); + buffer.setCount(nodes.length); + + const layout = + this.#engine === "community-force" + ? createMajorizationLayout( + nodes, + edges, + buffer, + this.#config.majorization, + ) + : createFlatLayout(nodes, edges, buffer, this.#config.flatForce); + + if (previous) { + this.#onSideMessage?.({ + type: "LAYOUT_DESTROYED", + clusterId: FLAT_LAYOUT_ID, + }); + } + + this.#buffer = buffer; + this.#builtEngine = this.#engine; + this.#builtEdgeCount = this.#store.edgeCount; + this.#layouts.set(FLAT_LAYOUT_ID, "entities", layout); + this.#ticker.ensureRunning(); + + // LAYOUT_CREATED is deferred until commit()'s style pass has filled the buffer. + } + + /** + * Force edges for the layout engines: one edge per connected unordered + * pair (parallel link types collapse; a doubly-linked pair should not be + * pulled twice as hard), self-loops excluded (no attraction to model; + * they render as loops but do not move anything). + */ + #buildForceEdges(): ForceEdge[] { + const edges: ForceEdge[] = []; + const seen = new Set(); + + for (const edge of this.#store.edges) { + if (edge.source === edge.target) { + continue; + } + const key = + edge.source < edge.target + ? `${edge.source}|${edge.target}` + : `${edge.target}|${edge.source}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + edges.push({ + source: nodeIdForTypeId(edge.source), + target: nodeIdForTypeId(edge.target), + weight: 1, + }); + } + + return edges; + } + + /** + * Write per-node radius + colour into the interleaved shared buffer. + * Loaded nodes colour by their inheritance root (family hue, depth + * shading); frontier nodes render the shared frontier grey; a live + * highlight dims everyone outside it. + */ + #writeStyle(layout: LayoutSimulation, buffer: FlatGraphBuffer): void { + const highlighted = this.#highlighted; + + for (let idx = 0; idx < layout.nodes.length; idx++) { + const node = layout.nodes[idx]!; + const typeId = typeIdFromNodeId(node.id); + + buffer.setRadius(idx, node.radius); + + const base = this.#store.isLoaded(typeId) + ? colorForType(typeId, this.#registry) + : FRONTIER_COLOR; + const dimmed = highlighted.size > 0 && !highlighted.has(typeId); + + buffer.setColor(idx, dimmed ? dimColor(base) : base); + // The join key: which type this record is (the main thread resolves + // urls / labels / picking via the TYPE_ID_TABLE). + buffer.setEntityIdx(idx, typeId); + } + + buffer.setCount(layout.nodes.length); + buffer.commit(); + } + + /** + * Rebuild the render edges: local node indices + link-type colour, one per + * store edge (parallel link types draw as separate coincident strokes, + * exactly as parallel links do in the entity flat tier). + */ + #rebuildRenderEdges(layout: LayoutSimulation): void { + const localOf = new Map(); + for (let idx = 0; idx < layout.nodeIds.length; idx++) { + localOf.set(typeIdFromNodeId(layout.nodeIds[idx]!), idx); + } + + const renderEdges: TypeRenderEdge[] = []; + const storeEdges = this.#store.edges; + for (let edgeIdx = 0; edgeIdx < storeEdges.length; edgeIdx++) { + const edge = storeEdges[edgeIdx]!; + const sourceIdx = localOf.get(edge.source); + const targetIdx = localOf.get(edge.target); + if (sourceIdx === undefined || targetIdx === undefined) { + continue; + } + renderEdges.push({ + sourceIdx, + targetIdx, + color: edgeColorForType(edge.linkTypeId, this.#registry), + edgeIdx, + }); + } + + this.#renderEdges = renderEdges; + } + + /** Publish the interned-url table tail the main thread is missing. */ + #publishIdTableTail(): void { + const size = this.#registry.size; + if (size <= this.#publishedIdCount) { + return; + } + + const urls: VersionedUrl[] = []; + for (let id = this.#publishedIdCount; id < size; id++) { + // Every id below the interner's size has a url by construction. + urls.push(this.#registry.getUrl(TypeId(id))!); + } + + this.#onSideMessage?.({ + type: "TYPE_ID_TABLE", + startId: TypeId(this.#publishedIdCount), + urls, + }); + this.#publishedIdCount = size; + } + + #emitStructure(layout: LayoutSimulation | undefined): void { + this.#structureVersion++; + + const typeEdges: RenderTypeEdge[] = this.#store.edges.map((edge) => ({ + source: edge.source, + target: edge.target, + linkTypeId: edge.linkTypeId, + })); + + this.#onStructureFrame?.({ + version: this.#structureVersion, + mode: this.#engine, + clusters: [], + entityLayers: [], + flatGraph: layout + ? { layoutId: FLAT_LAYOUT_ID, count: layout.nodes.length } + : undefined, + highwayLanes: [], + typeEdges, + }); + } + + /** Emit one positions tick: edge geometry from current node positions. */ + #emitPositions(): void { + this.#bezierSink.reset(); + this.#arrowSink.reset(); + this.#buildEdgeBeziers(); + + this.#positionsVersion++; + this.#onPositionsFrame?.({ + version: this.#positionsVersion, + settled: !this.#layouts.anyLayoutRunning(), + clusterPositions: new Float32Array(0), + beziers: this.#bezierSink.snapshot(), + edgeLabels: [], + edgeArrows: [], + flatArrows: this.#arrowSink.snapshot(), + entityFanOut: [], + }); + } + + /** + * Emit one segment per render edge from current node positions: straight + * clipped cubics via the shared flat writer, self-loops as a small bowed + * loop anchored on the node's rim. Segment ids are store edge-table + * indices (resolved against {@link StructureFrame.typeEdges}). + */ + #buildEdgeBeziers(): void { + const layout = this.#layouts.get(FLAT_LAYOUT_ID); + if (!layout) { + return; + } + + const { nodes } = layout; + const highlighted = this.#highlighted; + const edgeWidth = entityStyle().flatEdgeWidth; + + for (const edge of this.#renderEdges) { + const source = nodes[edge.sourceIdx]; + const target = nodes[edge.targetIdx]; + if (!source || !target) { + continue; + } + + // An edge stays full only when both endpoints are highlighted. + const full = + highlighted.size === 0 || + (highlighted.has(typeIdFromNodeId(source.id)) && + highlighted.has(typeIdFromNodeId(target.id))); + const color = full ? edge.color : dimColor(edge.color); + + if (edge.sourceIdx === edge.targetIdx) { + this.#writeSelfLoop(source, color, edgeWidth, edge.edgeIdx); + continue; + } + + writeStraightFlatEdge( + this.#bezierSink, + this.#arrowSink, + source.x ?? 0, + source.y ?? 0, + source.radius, + target.x ?? 0, + target.y ?? 0, + target.radius, + color, + edgeWidth, + edge.edgeIdx, + ); + } + } + + /** + * A self-referential link type (e.g. Person -[knows]-> Person) as a loop: + * a cubic between two rim points whose control points bow outward, plus an + * arrowhead where the loop re-enters the node. + */ + #writeSelfLoop( + node: ForceNode, + color: Color, + edgeWidth: number, + edgeIdx: number, + ): void { + const x = node.x ?? 0; + const y = node.y ?? 0; + const rim = node.radius + edgeWidth; + const reach = rim * SELF_LOOP_REACH; + + const cosStart = Math.cos(SELF_LOOP_START_ANGLE); + const sinStart = Math.sin(SELF_LOOP_START_ANGLE); + const cosEnd = Math.cos(SELF_LOOP_END_ANGLE); + const sinEnd = Math.sin(SELF_LOOP_END_ANGLE); + + const p0x = x + cosStart * rim; + const p0y = y + sinStart * rim; + const p3x = x + cosEnd * rim; + const p3y = y + sinEnd * rim; + const p1x = x + cosStart * (rim + reach); + const p1y = y + sinStart * (rim + reach); + const p2x = x + cosEnd * (rim + reach); + const p2y = y + sinEnd * (rim + reach); + + this.#bezierSink.pushUnclipped( + p0x, + p0y, + p1x, + p1y, + p2x, + p2y, + p3x, + p3y, + color, + edgeWidth, + edgeIdx, + ); + + this.#arrowSink.push( + p3x, + p3y, + Math.atan2(p3y - p2y, p3x - p2x), + edgeWidth, + reach, + color, + ); + } + + /** One simulation step: advance the layout, emit positions while it moves. */ + #tick(): void { + const layout = this.#layouts.get(FLAT_LAYOUT_ID); + if (!layout) { + this.#ticker.stop(); + return; + } + + const wasRunning = this.#layouts.anyLayoutRunning(); + const changed = layout.status === "running" ? layout.tick(1) : false; + const justSettled = wasRunning && !this.#layouts.anyLayoutRunning(); + + // Edges are worker-built beziers; emit a frame so they track the moved + // dots. The settle edge also emits once so the final frame carries + // settled: true. + if (changed || justSettled) { + this.#emitPositions(); + } + + if (!this.#layouts.anyLayoutRunning()) { + this.#ticker.stop(); + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/slide-stack.tsx b/apps/hash-frontend/src/pages/shared/slide-stack.tsx index bc7eb4225f1..ccbd27dd446 100644 --- a/apps/hash-frontend/src/pages/shared/slide-stack.tsx +++ b/apps/hash-frontend/src/pages/shared/slide-stack.tsx @@ -2,10 +2,14 @@ import { Backdrop, Box, Portal, Slide } from "@mui/material"; import { createRef, useCallback, useMemo, useRef, useState } from "react"; import { useScrollLock } from "../../shared/use-scroll-lock"; -import { SlideStackContext } from "./slide-stack/context"; +import { + SlideOcclusionContext, + SlideStackContext, +} from "./slide-stack/context"; import { DataTypeSlide } from "./slide-stack/data-type-slide"; import { EntitySlide } from "./slide-stack/entity-slide"; import { EntityTypeSlide } from "./slide-stack/entity-type-slide"; +import { LinkTableSlide } from "./slide-stack/link-table-slide"; import { SlideBackForwardCloseBar } from "./slide-stack/slide-back-forward-close-bar"; import type { SlideItem } from "./slide-stack/types"; @@ -16,11 +20,12 @@ import type { SetStateAction, } from "react"; -export { useSlideStack } from "./slide-stack/context"; +export { useSlideStack, useSlideStackOcclusion } from "./slide-stack/context"; const SLIDE_WIDTH = 1_000; const StackSlide = ({ + covered, item, open, onBack, @@ -32,6 +37,8 @@ const StackSlide = ({ slideContainerRef, stackPosition, }: { + /** True while a later slide is stacked on top of this one. */ + covered: boolean; item: SlideItem; open: boolean; onBack?: () => void; @@ -53,6 +60,8 @@ const StackSlide = ({ }, 300); }, [setAnimateOut, onBack]); + const occlusion = useMemo(() => ({ inSlide: true, covered }), [covered]); + return ( palette.common.white }}> - {item.kind === "dataType" && ( - - )} - {item.kind === "entityType" && ( - - )} - {item.kind === "entity" && ( - - )} + + {item.kind === "dataType" && ( + + )} + {item.kind === "entityType" && ( + + )} + {item.kind === "entity" && ( + + )} + {item.kind === "linkTable" && ( + + )} + @@ -166,6 +186,7 @@ export const SlideStack: FunctionComponent<{ 0 ? handleBack : undefined} @@ -253,6 +274,7 @@ export const SlideStackProvider = ({ () => ({ closeSlideStack, currentSlideRef: items[currentIndex]?.ref, + hasOpenSlides: items.length > 0, pushToSlideStack, setSlideContainerRef, slideContainerRef, diff --git a/apps/hash-frontend/src/pages/shared/slide-stack/context.tsx b/apps/hash-frontend/src/pages/shared/slide-stack/context.tsx index 5ae7bf344c9..adfc21df0bb 100644 --- a/apps/hash-frontend/src/pages/shared/slide-stack/context.tsx +++ b/apps/hash-frontend/src/pages/shared/slide-stack/context.tsx @@ -5,6 +5,8 @@ import type { PushToStackFn } from "./types"; type SlideStackContextData = { closeSlideStack: () => void; currentSlideRef?: RefObject; + /** Whether any slide is currently open (covering the page content below). */ + hasOpenSlides: boolean; pushToSlideStack: PushToStackFn; setSlideContainerRef: (ref: RefObject | null) => void; slideContainerRef?: RefObject | null; @@ -23,3 +25,32 @@ export const useSlideStack = () => { return context; }; + +/** + * Where a subtree sits relative to the slide stack: on the page (`inSlide` + * false), or inside a specific slide, which is `covered` while a later slide + * is stacked on top of it. Provided per slide by the stack; the default is + * the page level. + */ +interface SlideOcclusion { + readonly inSlide: boolean; + readonly covered: boolean; +} + +export const SlideOcclusionContext = createContext({ + inSlide: false, + covered: false, +}); + +/** + * Whether this subtree is visually occluded by the slide stack: covered by a + * higher slide when inside one, or behind any open slide when on the page. + * Lets expensive surfaces (a graph simulation, for example) idle while the + * user cannot see them. + */ +export const useSlideStackOcclusion = (): boolean => { + const { hasOpenSlides } = useSlideStack(); + const { inSlide, covered } = useContext(SlideOcclusionContext); + + return inSlide ? covered : hasOpenSlides; +}; diff --git a/apps/hash-frontend/src/pages/shared/slide-stack/link-table-slide.tsx b/apps/hash-frontend/src/pages/shared/slide-stack/link-table-slide.tsx new file mode 100644 index 00000000000..38c9930094f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/slide-stack/link-table-slide.tsx @@ -0,0 +1,303 @@ +/** + * A slide that lists the underlying link entities of an aggregated "highway" edge + * from the graph visualizer. + * + * It is given only the link entity ids and, like the entity slide, fetches its own + * data: a subgraph containing each link entity plus its source and target endpoints. + * It then feeds that into the same {@link EntitiesTable} used by the entities + * visualizer, so the table looks and behaves exactly like the entities table users + * already know -- including the Source and Target columns that link entities + * populate. + */ +import { useQuery } from "@apollo/client"; +import { Box, Container, Stack, Typography, useTheme } from "@mui/material"; +import { useCallback, useMemo, useState } from "react"; + +import { getRoots } from "@blockprotocol/graph/stdlib"; +import { + type BaseUrl, + type EntityId, + splitEntityId, + type VersionedUrl, +} from "@blockprotocol/type-system"; +import { LoadingSpinner } from "@hashintel/design-system"; +import { deserializeQueryEntitySubgraphResponse } from "@local/hash-graph-sdk/entity"; +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; +import { queryEntitySubgraphQuery } from "@local/hash-isomorphic-utils/graphql/queries/entity.queries"; + +import { EntitiesTable } from "../entities-visualizer/entities-table"; +import { generateTableDataFromRows } from "../entities-visualizer/shared/generate-table-data-from-rows"; +import { inSlideContainerStyles } from "../shared/slide-styles"; +import { useSlideStack } from "../slide-stack"; + +import type { ColumnSort } from "../../../components/grid/utils/sorting"; +import type { + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables, +} from "../../../graphql/api-types.gen"; +import type { + EntitiesTableRow, + SortableEntitiesTableColumnKey, +} from "../entities-visualizer/entities-table-data"; +import type { Filter } from "@local/hash-graph-client"; + +const buildLinkEntityFilter = (linkEntityIds: EntityId[]): Filter => { + if (linkEntityIds.length === 0) { + // A filter that matches nothing, so an empty selection returns no rows. + return { all: [] }; + } + + return { + any: linkEntityIds.map((entityId) => { + const [webId, entityUuid, draftId] = splitEntityId(entityId); + + return { + all: [ + { equal: [{ path: ["webId"] }, { parameter: webId }] }, + { equal: [{ path: ["uuid"] }, { parameter: entityUuid }] }, + ...(draftId + ? [{ equal: [{ path: ["draftId"] }, { parameter: draftId }] }] + : []), + ], + }; + }), + }; +}; + +export const LinkTableSlide = ({ + linkEntityIds, +}: { + linkEntityIds: EntityId[]; +}) => { + const theme = useTheme(); + + const { pushToSlideStack } = useSlideStack(); + + const includeDrafts = useMemo( + () => + linkEntityIds.some( + (entityId) => splitEntityId(entityId)[2] !== undefined, + ), + [linkEntityIds], + ); + + const variables = useMemo( + () => ({ + request: { + filter: buildLinkEntityFilter(linkEntityIds), + // The roots of this query are the link entities themselves, so we resolve + // their own source (left) and target (right) endpoints. These MUST be two + // separate single-edge paths: edges within one path are traversed as a + // chain (link -> left, then left -> its own right), which would never reach + // the link's target. Two paths each take one hop from the link, pulling in + // both endpoints so the Source / Target columns resolve. + traversalPaths: [ + { edges: [{ kind: "has-left-entity", direction: "outgoing" }] }, + { edges: [{ kind: "has-right-entity", direction: "outgoing" }] }, + ], + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts, + includeEntityTypes: "resolvedWithDataTypeChildren", + includePermissions: false, + }, + }), + [includeDrafts, linkEntityIds], + ); + + const { data, error, loading } = useQuery< + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables + >(queryEntitySubgraphQuery, { + fetchPolicy: "cache-and-network", + variables, + }); + + // The roots are exactly the requested link entities (the filter matches them); + // their source/target endpoints come in as non-root vertices via the traversal + // paths, so they resolve the Source/Target columns without becoming rows. + const tableData = useMemo(() => { + const response = data?.queryEntitySubgraph; + + if (!response?.definitions) { + return null; + } + + const { subgraph } = deserializeQueryEntitySubgraphResponse(response); + + return generateTableDataFromRows({ + closedMultiEntityTypesRootMap: response.closedMultiEntityTypes ?? {}, + definitions: response.definitions, + entities: getRoots(subgraph).map((entity) => entity.toJSON()), + subgraph: response.subgraph, + }); + }, [data?.queryEntitySubgraph]); + + const handleEntityClick = useCallback( + (entityId: EntityId) => { + pushToSlideStack({ kind: "entity", itemId: entityId }); + }, + [pushToSlideStack], + ); + + const handleEntityTypeClick = useCallback( + ({ entityTypeId }: { entityTypeId: VersionedUrl }) => { + pushToSlideStack({ kind: "entityType", itemId: entityTypeId }); + }, + [pushToSlideStack], + ); + + // No data-type conversions apply to a fixed set of links; EntitiesTable still + // requires the setter, so provide a stable no-op. + const noopSetActiveConversions = useCallback(() => {}, []); + + // EntitiesTable drives sorting and selection from caller-owned state. This + // slide shows a fixed set of links, so it simply owns that state locally. + const [sort, setSort] = useState< + ColumnSort & { convertTo?: BaseUrl } + >({ + columnKey: "entityLabel", + direction: "asc", + }); + + const [selectedRows, setSelectedRows] = useState([]); + + const isEmpty = !loading && tableData !== null && tableData.rows.length === 0; + const formattedLinkCount = linkEntityIds.length.toLocaleString(); + + return ( + + + + palette.gray[90], mb: 0.75 }} + > + Bundled links + + palette.gray[70], maxWidth: 640 }} + > + Links represented by the selected graph lane. Open any row to + inspect the link entity and its endpoints. + + + ({ + alignItems: "flex-end", + border: `1px solid ${palette.gray[20]}`, + borderRadius: 1.5, + bgcolor: palette.gray[10], + minWidth: 132, + px: 2, + py: 1.25, + })} + > + palette.gray[60] }} + > + Represented + + palette.gray[90], lineHeight: 1.1 }} + > + {formattedLinkCount} + + + + {error ? ( + ({ + border: `1px solid ${palette.red[30]}`, + borderRadius: 1.5, + bgcolor: palette.red[10], + color: palette.red[90], + p: 2, + })} + > + + Could not load links + + + {error.message} + + + ) : !tableData ? ( + ({ + alignItems: "center", + justifyContent: "center", + border: `1px solid ${palette.gray[20]}`, + borderRadius: 1.5, + bgcolor: palette.common.white, + minHeight: 360, + width: "100%", + })} + > + + + ) : isEmpty ? ( + ({ + border: `1px solid ${palette.gray[20]}`, + borderRadius: 1.5, + bgcolor: palette.gray[10], + color: palette.gray[80], + p: 2, + })} + > + + No links found + + + The lane did not resolve to any link entities. + + + ) : ( + ({ + border: `1px solid ${palette.gray[20]}`, + borderRadius: 1.5, + bgcolor: palette.common.white, + boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)", + overflow: "hidden", + })} + > + + + )} + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/slide-stack/types.ts b/apps/hash-frontend/src/pages/shared/slide-stack/types.ts index 4b43c0cd40e..467b37940f6 100644 --- a/apps/hash-frontend/src/pages/shared/slide-stack/types.ts +++ b/apps/hash-frontend/src/pages/shared/slide-stack/types.ts @@ -22,9 +22,21 @@ export type SlideDataTypeItem = { onUpdate?: (dataTypeId: VersionedUrl) => void; }; +export type SlideLinkTableItem = { + kind: "linkTable"; + /** + * Synthetic stack key. The link entities are identified by `linkEntityIds`; + * `itemId` only needs to be stable for this stack entry, so it is derived from + * the ids (see the producer in entities-visualizer.tsx). + */ + itemId: string; + linkEntityIds: EntityId[]; +}; + export type SlideItem = | SlideEntityItem | SlideEntityTypeItem - | SlideDataTypeItem; + | SlideDataTypeItem + | SlideLinkTableItem; export type PushToStackFn = (item: SlideItem) => void; diff --git a/apps/hash-frontend/src/pages/shared/type-graph-visualizer.tsx b/apps/hash-frontend/src/pages/shared/type-graph-visualizer.tsx deleted file mode 100644 index 397463aa29d..00000000000 --- a/apps/hash-frontend/src/pages/shared/type-graph-visualizer.tsx +++ /dev/null @@ -1,346 +0,0 @@ -import { useTheme } from "@mui/material"; -import { useCallback, useMemo } from "react"; - -import { extractBaseUrl } from "@blockprotocol/type-system"; -import { typedEntries, typedValues } from "@local/advanced-types/typed-entries"; - -import { useEntityTypesContextRequired } from "../../shared/entity-types-context/hooks/use-entity-types-context-required"; -import { GraphVisualizer } from "./graph-visualizer"; - -import type { - GraphVisualizerProps, - GraphVizConfig, - GraphVizEdge, - GraphVizNode, - StaticNodeSizing, -} from "./graph-visualizer"; -import type { - DataTypeWithMetadata, - EntityType, - EntityTypeWithMetadata, - PropertyTypeWithMetadata, - VersionedUrl, -} from "@blockprotocol/type-system"; - -const anythingNodeId = "anything"; - -const getSelfAndAncestorEntityTypes = ( - entityType: EntityType, - entityTypesById: Record, -): EntityType[] => { - const selfAndAncestors: EntityType[] = []; - const visited = new Set(); - const queue = [entityType]; - - for (const currentEntityType of queue) { - const entityTypeId = currentEntityType.$id; - - if (visited.has(entityTypeId)) { - continue; - } - - visited.add(entityTypeId); - selfAndAncestors.push(currentEntityType); - - for (const { $ref } of currentEntityType.allOf ?? []) { - const parentEntityType = entityTypesById[$ref]; - - if (parentEntityType) { - queue.push(parentEntityType); - } - } - } - - return selfAndAncestors; -}; - -const getInheritedLinks = ( - selfAndAncestors: EntityType[], -): NonNullable => { - const inheritedLinks: NonNullable = {}; - const linkTypeBaseUrlsSeen = new Set(); - - for (const entityType of selfAndAncestors) { - for (const [linkTypeId, linkSchema] of typedEntries( - entityType.links ?? {}, - )) { - const linkTypeBaseUrl = extractBaseUrl(linkTypeId); - - if (linkTypeBaseUrlsSeen.has(linkTypeBaseUrl)) { - continue; - } - - inheritedLinks[linkTypeId] = linkSchema; - linkTypeBaseUrlsSeen.add(linkTypeBaseUrl); - } - } - - return inheritedLinks; -}; - -const getInheritedIcon = (selfAndAncestors: EntityType[]) => - selfAndAncestors.find(({ icon }) => !!icon)?.icon; - -const defaultConfig = { - graphKey: "type-graph", - nodeHighlighting: { - direction: "All", - depth: 1, - }, - nodeSizing: { mode: "static" }, - edgeSizing: { - min: 3, - max: 3, - nonHighlightedVisibleSizeThreshold: 3, - scale: "Linear", - }, -} as const satisfies GraphVizConfig; - -export const TypeGraphVisualizer = ({ - onTypeClick, - types, -}: { - onTypeClick: (typeId: VersionedUrl) => void; - types: ( - | DataTypeWithMetadata - | EntityTypeWithMetadata - | PropertyTypeWithMetadata - )[]; -}) => { - const { palette } = useTheme(); - - const { entityTypes, isSpecialEntityTypeLookup } = - useEntityTypesContextRequired(); - - const { edges, nodes } = useMemo(() => { - const edgesToAdd: GraphVizEdge[] = []; - const nodesToAdd: GraphVizNode[] = []; - - const addedNodeIds = new Set(); - const addedEdgeIds = new Set(); - - const anythingNode: GraphVizNode = { - color: palette.gray[30], - nodeId: anythingNodeId, - label: "Anything", - size: 18, - }; - - /** - * Link types can appear multiple times in the visualization (one per each destination combination). - * We need to track all the (a) occurrences of a link type, and (b) nodes which link to it, - * so that we can link from all nodes that to all the occurrences of a link type (if any nodes link to a link). - * - * @todo this doesn't yet handle the case where a _link_ links to a link (rather than another node linking to a - * link) this would involve checking the outgoing 'links' for each link type. - */ - const linkNodesByEntityTypeId: Record< - VersionedUrl, - { - instanceIds: string[]; - sourceIds: string[]; - } - > = {}; - - const entityTypesById: Record = {}; - - for (const type of entityTypes ?? []) { - entityTypesById[type.schema.$id] = type.schema; - } - - for (const type of types) { - if (type.schema.kind === "entityType") { - entityTypesById[type.schema.$id] = type.schema; - } - } - - for (const { schema } of types) { - if (schema.kind !== "entityType") { - /** - * We don't yet support visualizing property or data types to the graph. - */ - continue; - } - - const entityTypeId = schema.$id; - const selfAndAncestors = getSelfAndAncestorEntityTypes( - schema, - entityTypesById, - ); - - const isLink = isSpecialEntityTypeLookup?.[entityTypeId]?.isLink; - if (isLink) { - /** - * We'll add the links as we process each entity type – this means that any link types which are unused won't - * appear in the graph. - */ - continue; - } - - nodesToAdd.push({ - nodeId: entityTypeId, - color: palette.blue[70], - icon: getInheritedIcon(selfAndAncestors), - label: schema.title, - size: 18, - }); - - addedNodeIds.add(entityTypeId); - - for (const [linkTypeId, destinationSchema] of typedEntries( - getInheritedLinks(selfAndAncestors), - )) { - const destinationTypeIds = - "oneOf" in destinationSchema.items - ? destinationSchema.items.oneOf.map((dest) => dest.$ref) - : null; - - /** - * Links can be re-used by multiple different entity types, e.g. - * @hash/person —> @hash/has-friend —> @hash/person - * @alice/person —> @hash/has-friend —> @alice/person - * - * We need to create a separate link node per destination set, even if the link type is the same, - * so that the user can tell the possible destinations for a given link type from a given entity type. - * But we can re-use any with the same destination set. - * The id is therefore based on the link type and the destination types. - */ - const linkNodeId = `${linkTypeId}~${ - destinationTypeIds?.sort().join("-") ?? "anything" - }`; - - if (!addedNodeIds.has(linkNodeId)) { - const linkSchema = entityTypesById[linkTypeId]; - - if (!linkSchema) { - continue; - } - - let isLinkToALink = false; - - if (destinationTypeIds) { - for (const destinationTypeId of destinationTypeIds) { - if (isSpecialEntityTypeLookup?.[destinationTypeId]?.isLink) { - /** - * If the destination is itself a link, we need to account for the multiple places the destination link - * may appear. We won't have the full set until we've gone through all the non-link entity types, - * so we'll need to handle this in a separate loop afterward. - */ - linkNodesByEntityTypeId[destinationTypeId] ??= { - instanceIds: [], - sourceIds: [], - }; - linkNodesByEntityTypeId[destinationTypeId].sourceIds.push( - linkNodeId, - ); - - isLinkToALink = true; - continue; - } - - const edgeId = `${linkNodeId}~${destinationTypeId}`; - if (!addedEdgeIds.has(edgeId)) { - edgesToAdd.push({ - edgeId: `${linkNodeId}~${destinationTypeId}`, - size: 3, - source: linkNodeId, - target: destinationTypeId, - }); - addedEdgeIds.add(edgeId); - } - } - } else { - /** - * There is no constraint on destinations, so we link it to the 'Anything' node. - */ - if (!addedNodeIds.has(anythingNodeId)) { - /** - * We only add the Anything node if it's being used (i.e. here). - */ - nodesToAdd.push(anythingNode); - addedNodeIds.add(anythingNodeId); - } - edgesToAdd.push({ - edgeId: `${linkNodeId}~${anythingNodeId}`, - size: 3, - source: linkNodeId, - target: anythingNodeId, - }); - } - - nodesToAdd.push({ - nodeId: linkNodeId, - color: isLinkToALink ? palette.gray[60] : palette.common.black, - label: linkSchema.title, - size: 14, - }); - addedNodeIds.add(linkNodeId); - - linkNodesByEntityTypeId[linkTypeId] ??= { - instanceIds: [], - sourceIds: [], - }; - linkNodesByEntityTypeId[linkTypeId].instanceIds.push(linkNodeId); - } - - const edgeId = `${entityTypeId}~${linkNodeId}`; - if (!addedEdgeIds.has(edgeId)) { - edgesToAdd.push({ - edgeId, - size: 3, - source: entityTypeId, - target: linkNodeId, - }); - addedEdgeIds.add(edgeId); - } - } - } - - /** - * For each link, check if anything links to it, and if so link from that thing to all instances of the link - */ - for (const { instanceIds, sourceIds } of typedValues( - linkNodesByEntityTypeId, - )) { - for (const sourceId of sourceIds) { - for (const instanceId of instanceIds) { - edgesToAdd.push({ - edgeId: `${sourceId}~${instanceId}`, - size: 3, - source: sourceId, - target: instanceId, - }); - } - } - } - - return { - edges: edgesToAdd, - nodes: nodesToAdd, - }; - }, [entityTypes, isSpecialEntityTypeLookup, palette, types]); - - const onNodeClick = useCallback< - NonNullable["onNodeSecondClick"]> - >( - ({ nodeId }) => { - if (nodeId === anythingNodeId) { - return; - } - - const typeVersionedUrl = nodeId.split("~")[0] as VersionedUrl; - - onTypeClick(typeVersionedUrl); - }, - [onTypeClick], - ); - - return ( - - ); -}; diff --git a/apps/hash-frontend/src/pages/shared/types-table.tsx b/apps/hash-frontend/src/pages/shared/types-table.tsx index 5298f558c8f..4808199dd48 100644 --- a/apps/hash-frontend/src/pages/shared/types-table.tsx +++ b/apps/hash-frontend/src/pages/shared/types-table.tsx @@ -38,7 +38,7 @@ import { useSlideStack } from "./slide-stack"; import { TableHeaderToggle } from "./table-header-toggle"; import { createRenderTextIconCell } from "./text-icon-cell"; import { TOP_CONTEXT_BAR_HEIGHT } from "./top-context-bar"; -import { TypeGraphVisualizer } from "./type-graph-visualizer"; +import { TypeGraphVisualizer } from "./graph-visualizer/type-graph/visualizer"; import { visualizerViewIcons } from "./visualizer-views"; import type { CustomIcon } from "../../components/grid/utils/custom-grid-icons"; diff --git a/apps/hash-graph/Cargo.toml b/apps/hash-graph/Cargo.toml index 0dcb10e4746..02b4f58de69 100644 --- a/apps/hash-graph/Cargo.toml +++ b/apps/hash-graph/Cargo.toml @@ -34,6 +34,7 @@ futures = { workspace = true } jsonwebtoken = { workspace = true } mimalloc = { workspace = true } multiaddr = { workspace = true } +rayon = { workspace = true } regex = { workspace = true } reqwest = { workspace = true, features = ["rustls"] } simple-mermaid = { workspace = true } diff --git a/apps/hash-graph/docs/dependency-diagram.mmd b/apps/hash-graph/docs/dependency-diagram.mmd index 2c772cf559f..c5aad87b71e 100644 --- a/apps/hash-graph/docs/dependency-diagram.mmd +++ b/apps/hash-graph/docs/dependency-diagram.mmd @@ -56,7 +56,6 @@ graph TD 1 -.-> 41 2 -.-> 3 2 --> 23 - 4 --> 6 4 --> 12 4 --> 13 4 --> 19 @@ -64,8 +63,10 @@ graph TD 4 --> 32 5 --> 1 6 --> 14 + 6 -.-> 37 7 --> 8 7 --> 34 + 9 --> 6 9 -.-> 7 9 --> 15 9 --> 33 diff --git a/apps/hash-graph/src/args.rs b/apps/hash-graph/src/args.rs index b3c538276a3..32b717f1316 100644 --- a/apps/hash-graph/src/args.rs +++ b/apps/hash-graph/src/args.rs @@ -7,7 +7,7 @@ use clap::{ }; use hash_telemetry::TracingConfig; -use crate::subcommand::Subcommand; +use crate::subcommand::{Subcommand, WorkerThreads}; /// Arguments passed to the program. #[derive(Debug, Parser)] @@ -16,6 +16,19 @@ pub struct Args { #[clap(flatten)] pub tracing_config: TracingConfig, + /// Number of threads in the global worker pool used for CPU-bound work such as entity + /// clustering. + /// + /// Accepts a fixed count (e.g. `4`) or a count relative to the available CPU cores: `n` for + /// all cores, `n/2` for half, `n/4` for a quarter, and so on. + #[clap( + long, + global = true, + default_value_t, + env = "HASH_GRAPH_WORKER_THREADS" + )] + pub worker_threads: WorkerThreads, + /// Specify a subcommand to run. #[command(subcommand)] pub subcommand: Subcommand, diff --git a/apps/hash-graph/src/main.rs b/apps/hash-graph/src/main.rs index e8ac0681ffc..efdcb62d0b9 100644 --- a/apps/hash-graph/src/main.rs +++ b/apps/hash-graph/src/main.rs @@ -30,9 +30,10 @@ fn main() -> Result<(), Report> { let Args { subcommand, tracing_config, + worker_threads, } = Args::parse_args(); let _sentry_guard = init(&tracing_config.sentry, release_name!()); - subcommand.execute(tracing_config) + subcommand.execute(tracing_config, worker_threads) } diff --git a/apps/hash-graph/src/subcommand/mod.rs b/apps/hash-graph/src/subcommand/mod.rs index 08931291972..d4a4e24d1a2 100644 --- a/apps/hash-graph/src/subcommand/mod.rs +++ b/apps/hash-graph/src/subcommand/mod.rs @@ -6,8 +6,8 @@ mod server; mod snapshot; mod type_fetcher; -use core::time::Duration; -use std::time::Instant; +use core::{fmt, num::NonZero, str::FromStr, time::Duration}; +use std::{sync::Once, thread::available_parallelism, time::Instant}; use clap::Parser; use error_stack::{Report, ensure}; @@ -15,6 +15,19 @@ use hash_telemetry::{TracingConfig, init_tracing}; use tokio::time::sleep; use tokio_util::{sync::CancellationToken, task::TaskTracker}; +pub use self::{ + admin_server::{AdminServerArgs, admin_server}, + completions::{CompletionsArgs, completions}, + migrate::{MigrateArgs, migrate}, + server::{ServerArgs, server}, + snapshot::{SnapshotArgs, snapshot}, + type_fetcher::{TypeFetcherArgs, type_fetcher}, +}; +use crate::{ + error::{GraphError, HealthcheckError}, + subcommand::reindex_cache::{ReindexCacheArgs, reindex_cache}, +}; + /// Drop guard that fires the `abort` token when a server task exits unexpectedly. /// /// "Unexpectedly" means the `shutdown` token has not been cancelled yet. This covers both @@ -87,6 +100,85 @@ impl ServerLifecycle { } } +/// Number of threads for the global worker pool used for CPU-bound work. +/// +/// Parses either a fixed thread count (e.g. `4`) or a count relative to the number of available +/// CPU cores: `n` for all cores, `n/2` for half of them, `n/4` for a quarter, and so on. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum WorkerThreads { + /// The available CPU cores divided by the given divisor (`n`, `n/2`, `n/4`, ...). + Cores { divisor: NonZero }, + /// A fixed number of threads. + Fixed(NonZero), +} + +impl WorkerThreads { + /// Resolves to a concrete thread count, clamped to at least one thread. + #[expect( + clippy::integer_division, + reason = "Deriving a thread count from the core count is inherently lossy." + )] + fn resolve(self) -> NonZero { + match self { + Self::Fixed(threads) => threads, + Self::Cores { divisor } => available_parallelism() + .ok() + .and_then(|cores| NonZero::new(cores.get() / divisor)) + .unwrap_or(NonZero::::MIN), + } + } +} + +impl Default for WorkerThreads { + fn default() -> Self { + const HALF: NonZero = NonZero::new(2).expect("two should be non-zero"); + Self::Cores { divisor: HALF } + } +} + +impl fmt::Display for WorkerThreads { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Self::Cores { divisor } if divisor == NonZero::::MIN => fmt.write_str("n"), + Self::Cores { divisor } => write!(fmt, "n/{divisor}"), + Self::Fixed(threads) => write!(fmt, "{threads}"), + } + } +} + +/// Error returned when parsing a [`WorkerThreads`] value fails. +#[derive(Debug)] +pub struct ParseWorkerThreadsError; + +impl fmt::Display for ParseWorkerThreadsError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt.write_str("expected a positive integer, `n`, or `n/` (e.g. `4`, `n`, `n/2`)") + } +} + +impl core::error::Error for ParseWorkerThreadsError {} + +impl FromStr for WorkerThreads { + type Err = ParseWorkerThreadsError; + + fn from_str(value: &str) -> Result { + match value.strip_prefix(['n', 'N']) { + Some("") => Ok(Self::Cores { + divisor: NonZero::::MIN, + }), + Some(rest) => rest + .strip_prefix('/') + .and_then(|divisor| divisor.parse().ok()) + .map(|divisor| Self::Cores { divisor }) + .ok_or(ParseWorkerThreadsError), + None => value + .parse() + .map(Self::Fixed) + .map_err(|_error: core::num::ParseIntError| ParseWorkerThreadsError), + } + } +} + /// Shared healthcheck arguments for all server subcommands. #[derive(Debug, Clone, Parser)] pub(crate) struct HealthcheckArgs { @@ -103,19 +195,6 @@ pub(crate) struct HealthcheckArgs { pub timeout: Option, } -pub use self::{ - admin_server::{AdminServerArgs, admin_server}, - completions::{CompletionsArgs, completions}, - migrate::{MigrateArgs, migrate}, - server::{ServerArgs, server}, - snapshot::{SnapshotArgs, snapshot}, - type_fetcher::{TypeFetcherArgs, type_fetcher}, -}; -use crate::{ - error::{GraphError, HealthcheckError}, - subcommand::reindex_cache::{ReindexCacheArgs, reindex_cache}, -}; - /// Subcommand for the program. #[derive(Debug, clap::Subcommand)] pub enum Subcommand { @@ -145,7 +224,17 @@ fn block_on( future: impl Future>>, service_name: &'static str, tracing_config: TracingConfig, + worker_threads: WorkerThreads, ) -> Result<(), Report> { + static THREAD_POOL: Once = Once::new(); + THREAD_POOL.call_once(|| { + rayon::ThreadPoolBuilder::new() + .num_threads(worker_threads.resolve().get()) + .thread_name(|index| format!("rayon-{index}")) + .build_global() + .expect("rayon pool should be initialized exactly once"); + }); + tokio::runtime::Builder::new_multi_thread() .enable_all() .build() @@ -159,24 +248,49 @@ fn block_on( } impl Subcommand { - pub(crate) fn execute(self, tracing_config: TracingConfig) -> Result<(), Report> { + pub(crate) fn execute( + self, + tracing_config: TracingConfig, + worker_threads: WorkerThreads, + ) -> Result<(), Report> { match self { - Self::Server(args) => block_on(server(*args), "Graph API", tracing_config), - Self::AdminServer(args) => { - block_on(admin_server(*args), "Graph Admin API", tracing_config) - } - Self::Migrate(args) => block_on(migrate(*args), "Graph Migrations", tracing_config), - Self::TypeFetcher(args) => { - block_on(type_fetcher(*args), "Type Fetcher", tracing_config) + Self::Server(args) => { + block_on(server(*args), "Graph API", tracing_config, worker_threads) } + Self::AdminServer(args) => block_on( + admin_server(*args), + "Graph Admin API", + tracing_config, + worker_threads, + ), + Self::Migrate(args) => block_on( + migrate(*args), + "Graph Migrations", + tracing_config, + worker_threads, + ), + Self::TypeFetcher(args) => block_on( + type_fetcher(*args), + "Type Fetcher", + tracing_config, + worker_threads, + ), Self::Completions(ref args) => { completions(args); Ok(()) } - Self::Snapshot(args) => block_on(snapshot(*args), "Graph Snapshot", tracing_config), - Self::ReindexCache(args) => { - block_on(reindex_cache(*args), "Graph Indexer", tracing_config) - } + Self::Snapshot(args) => block_on( + snapshot(*args), + "Graph Snapshot", + tracing_config, + worker_threads, + ), + Self::ReindexCache(args) => block_on( + reindex_cache(*args), + "Graph Indexer", + tracing_config, + worker_threads, + ), } } } diff --git a/apps/hash-graph/src/subcommand/server.rs b/apps/hash-graph/src/subcommand/server.rs index 8a9bd213701..47922c55b43 100644 --- a/apps/hash-graph/src/subcommand/server.rs +++ b/apps/hash-graph/src/subcommand/server.rs @@ -16,8 +16,8 @@ use harpc_server::Server; use hash_codec::bytes::JsonLinesEncoder; use hash_graph_api::{ rest::{ - ApiConfig, QueryLogger, RestApiStore, RestRouterDependencies, hashql::CompilerContext, - rest_api_router, + ApiConfig, QueryLogger, RestApiStore, RestRouterDependencies, entity::ClusteringContext, + hashql::CompilerContext, rest_api_router, }, rpc::Dependencies, }; @@ -239,6 +239,13 @@ pub struct ServerConfig { #[clap(flatten)] pub compiler: CompilerConfig, + /// Maximum number of entity-clustering requests processed at the same time. + /// + /// Excess requests wait until a slot frees up. If not set, the number of concurrent + /// clustering requests is unbounded. + #[clap(long, env = "HASH_GRAPH_CLUSTERING_CONCURRENCY_LIMIT")] + pub clustering_concurrency_limit: Option>, + /// Outputs the queries made to the graph to the specified file. #[clap(long)] pub log_queries: Option, @@ -457,6 +464,7 @@ where query_logger, api_config: config.api_config, compiler, + clustering: Arc::new(ClusteringContext::new(config.clustering_concurrency_limit)), }); start_rest_server(router, config.http_address, lifecycle); diff --git a/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd b/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd index d97925ceaff..caefa648975 100644 --- a/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd +++ b/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd @@ -41,13 +41,13 @@ graph TD 1 -.-> 26 2 -.-> 3 2 --> 15 - 4 --> 6 4 --> 10 4 --> 13 4 --> 18 4 --> 21 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 diff --git a/libs/@local/codec/docs/dependency-diagram.mmd b/libs/@local/codec/docs/dependency-diagram.mmd index 2dc72bb1081..046b44f0057 100644 --- a/libs/@local/codec/docs/dependency-diagram.mmd +++ b/libs/@local/codec/docs/dependency-diagram.mmd @@ -46,13 +46,13 @@ graph TD 1 -.-> 31 2 -.-> 3 2 --> 19 - 4 --> 6 4 --> 10 4 --> 15 4 --> 23 4 --> 26 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 diff --git a/libs/@local/codegen/docs/dependency-diagram.mmd b/libs/@local/codegen/docs/dependency-diagram.mmd index 46e354a3c6d..08c5bbadc20 100644 --- a/libs/@local/codegen/docs/dependency-diagram.mmd +++ b/libs/@local/codegen/docs/dependency-diagram.mmd @@ -42,13 +42,13 @@ graph TD 1 --> 9 1 -.-> 28 2 -.-> 3 - 4 --> 6 4 --> 10 4 --> 15 4 --> 21 4 --> 24 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 diff --git a/libs/@local/graph/api/docs/dependency-diagram.mmd b/libs/@local/graph/api/docs/dependency-diagram.mmd index 3ec3bd7bc71..b15023971bf 100644 --- a/libs/@local/graph/api/docs/dependency-diagram.mmd +++ b/libs/@local/graph/api/docs/dependency-diagram.mmd @@ -57,7 +57,6 @@ graph TD 1 -.-> 42 2 -.-> 3 2 --> 23 - 4 --> 6 4 --> 12 4 --> 13 4 --> 19 @@ -65,8 +64,10 @@ graph TD 4 --> 32 5 --> 1 6 --> 14 + 6 -.-> 37 7 --> 8 7 --> 34 + 9 --> 6 9 -.-> 7 9 --> 15 9 --> 33 diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index 07d1ae085ab..041fe7f5621 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -1580,6 +1580,54 @@ } } }, + "/entities/embeddings/clusters": { + "post": { + "tags": [ + "Graph", + "Entity" + ], + "operationId": "cluster_entities", + "parameters": [ + { + "name": "X-Authenticated-User-Actor-Id", + "in": "header", + "description": "The ID of the actor which is used to authorize the request", + "required": true, + "schema": { + "$ref": "#/components/schemas/ActorEntityUuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClusterEntitiesParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Clusters of entities by embedding similarity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClusterEntitiesResponse" + } + } + } + }, + "422": { + "description": "Provided request body is invalid" + }, + "500": { + "description": "Store error occurred" + } + } + } + }, "/entities/permissions": { "post": { "tags": [ @@ -3742,6 +3790,77 @@ "propertyName": "kind" } }, + "ClusterEntitiesParams": { + "type": "object", + "required": [ + "entityIds", + "clusterCount" + ], + "properties": { + "clusterCount": { + "type": "integer", + "format": "int32", + "description": "Desired number of clusters.\n\nClamped to the number of entities with embeddings when that is smaller.", + "maximum": 64, + "minimum": 0 + }, + "dimension": { + "type": "integer", + "format": "int32", + "description": "Embedding dimension after matryoshka truncation.\n\nMust be a positive multiple of 8; values above 512 are rejected. Defaults to 256.", + "default": 256, + "example": 256, + "multipleOf": 8, + "maximum": 512, + "minimum": 8 + }, + "entityIds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityId" + } + }, + "seed": { + "type": "integer", + "format": "int64", + "description": "Seed for the random number generator used in clustering.\n\nIf not provided, a random seed will be used.", + "nullable": true, + "minimum": 0 + } + }, + "additionalProperties": false + }, + "ClusterEntitiesResponse": { + "type": "object", + "description": "Result of [`EntityStore::cluster_entities`].", + "required": [ + "clusters", + "missingEmbeddings", + "inertia" + ], + "properties": { + "clusters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityCluster" + }, + "description": "One entry per non-empty cluster. Empty clusters (no points assigned)\nare omitted." + }, + "inertia": { + "type": "number", + "format": "float", + "description": "Sum of squared chord distances from every clustered entity to its\nassigned centroid. Lower is tighter; comparable across runs over the\nsame entities, e.g. to choose a cluster count. `0.0` when nothing was\nclustered." + }, + "missingEmbeddings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityId" + }, + "description": "Entities from the request that were not clustered (either because no embedding exists, or\nbecause the actor lacks permission to view the entity).", + "uniqueItems": true + } + } + }, "CommonQueryEntityTypesParams": { "type": "object", "required": [ @@ -4507,6 +4626,37 @@ } } }, + "EntityCluster": { + "type": "object", + "description": "One cluster from a spherical k-means run over entity embeddings.", + "required": [ + "clusterId", + "entityIds", + "centroid" + ], + "properties": { + "centroid": { + "type": "array", + "items": { + "type": "number", + "format": "float" + }, + "description": "Centroid with length equal to the requested dimension.\n\nTypically unit-normalized, but may be the all-zero vector if all assigned points have zero\nnorm." + }, + "clusterId": { + "type": "integer", + "format": "int32", + "description": "Index in `0..min(cluster_count, n)`.", + "minimum": 0 + }, + "entityIds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityId" + } + } + } + }, "EntityDeletionProvenance": { "type": "object", "required": [ diff --git a/libs/@local/graph/api/src/rest/entity/mod.rs b/libs/@local/graph/api/src/rest/entity/mod.rs index 1be3e54b8de..bc10f42d808 100644 --- a/libs/@local/graph/api/src/rest/entity/mod.rs +++ b/libs/@local/graph/api/src/rest/entity/mod.rs @@ -3,24 +3,26 @@ pub mod query; use alloc::sync::Arc; +use core::num::NonZero; use std::collections::HashMap; use axum::{Extension, Router, routing::post}; use error_stack::{Report, ResultExt as _}; +use futures::future::OptionFuture; use hash_graph_authorization::policies::principal::actor::AuthenticatedActor; use hash_graph_embeddings::OpenAiEmbeddingClient; use hash_graph_postgres_store::store::error::{EntityDoesNotExist, RaceConditionOnUpdate}; use hash_graph_store::{ self, entity::{ - ClosedMultiEntityTypeMap, CreateEntityParams, DiffEntityParams, DiffEntityResult, - EntityPermissions, EntityQueryCursor, EntityQuerySortingRecord, EntityQuerySortingToken, - EntityQueryToken, EntityStore, EntityTypesError, EntityValidationReport, - EntityValidationType, HasPermissionForEntitiesParams, LinkDataStateError, - LinkDataValidationReport, LinkError, LinkTargetError, LinkValidationReport, - LinkedEntityError, MetadataValidationReport, PatchEntityParams, - PropertyMetadataValidationReport, QueryConversion, QueryEntitiesResponse, - SearchEntitiesFilter, SearchEntitiesParams, SearchEntitiesResponse, + ClosedMultiEntityTypeMap, ClusterEntitiesParams, ClusterEntitiesResponse, + CreateEntityParams, DiffEntityParams, DiffEntityResult, EntityCluster, EntityPermissions, + EntityQueryCursor, EntityQuerySortingRecord, EntityQuerySortingToken, EntityQueryToken, + EntityStore, EntityTypesError, EntityValidationReport, EntityValidationType, + HasPermissionForEntitiesParams, LinkDataStateError, LinkDataValidationReport, LinkError, + LinkTargetError, LinkValidationReport, LinkedEntityError, MetadataValidationReport, + PatchEntityParams, PropertyMetadataValidationReport, QueryConversion, + QueryEntitiesResponse, SearchEntitiesFilter, SearchEntitiesParams, SearchEntitiesResponse, SummarizeEntitiesParams, SummarizeEntitiesResponse, UnexpectedEntityType, UpdateEntityEmbeddingsParams, ValidateEntityComponents, ValidateEntityParams, }, @@ -45,6 +47,7 @@ use hash_graph_types::{ }; use hash_temporal_client::TemporalClient; use serde::Deserialize as _; +use tokio::sync::Semaphore; use type_system::{ knowledge::{ Confidence, Entity, Property, @@ -97,6 +100,7 @@ use crate::rest::{ search_entities, patch_entity, update_entity_embeddings, + cluster_entities, diff_entity, ), components( @@ -114,6 +118,9 @@ use crate::rest::{ Embedding, UpdateEntityEmbeddingsParams, EntityEmbedding, + ClusterEntitiesParams, + ClusterEntitiesResponse, + EntityCluster, EntityQueryToken, PatchEntityParams, @@ -226,7 +233,12 @@ impl EntityResource { .route("/bulk", post(create_entities::)) .route("/diff", post(diff_entity::)) .route("/validate", post(validate_entity::)) - .route("/embeddings", post(update_entity_embeddings::)) + .nest( + "/embeddings", + Router::new() + .route("/", post(update_entity_embeddings::)) + .route("/clusters", post(cluster_entities::)), + ) .route("/permissions", post(has_permission_for_entities::)) .route("/search", post(search_entities::)) .nest( @@ -597,6 +609,61 @@ where .map_err(report_to_response) } +pub struct ClusteringContext { + pub limit: Option, +} + +impl ClusteringContext { + #[must_use] + pub fn new(concurrency_limit: Option>) -> Self { + Self { + limit: concurrency_limit.map(|limit| Semaphore::new(limit.get())), + } + } +} + +#[utoipa::path( + post, + path = "/entities/embeddings/clusters", + tag = "Entity", + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + ), + responses( + (status = 200, content_type = "application/json", description = "Clusters of entities by embedding similarity", body = ClusterEntitiesResponse), + (status = 422, content_type = "text/plain", description = "Provided request body is invalid"), + + (status = 500, description = "Store error occurred"), + ), + request_body = ClusterEntitiesParams, +)] +async fn cluster_entities( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + Extension(store_pool): Extension>, + Extension(temporal_client): Extension>>, + Extension(context): Extension>, + Json(params): Json, +) -> Result, BoxedResponse> +where + S: StorePool + Send + Sync, +{ + let _permit = OptionFuture::from(context.limit.as_ref().map(Semaphore::acquire)) + .await + .transpose() + .expect("semaphore should never be closed"); + + let store = store_pool + .acquire(temporal_client) + .await + .map_err(report_to_response)?; + + store + .cluster_entities(actor_id, params) + .await + .map_err(report_to_response) + .map(Json) +} + #[utoipa::path( post, path = "/entities/diff", diff --git a/libs/@local/graph/api/src/rest/mod.rs b/libs/@local/graph/api/src/rest/mod.rs index 3ebd2a169d9..114ac3a3748 100644 --- a/libs/@local/graph/api/src/rest/mod.rs +++ b/libs/@local/graph/api/src/rest/mod.rs @@ -100,6 +100,7 @@ use utoipa::{ use uuid::Uuid; use self::{ + entity::ClusteringContext, status::{BoxedResponse, report_to_response, status_to_response}, utoipa_typedef::{ MaybeListOfDataTypeMetadata, MaybeListOfEntityTypeMetadata, @@ -543,6 +544,7 @@ where pub query_logger: Option, pub api_config: ApiConfig, pub compiler: Arc, + pub clustering: Arc, } /// A [`Router`] that only serves the `OpenAPI` specification (JSON, and necessary subschemas) for @@ -593,7 +595,8 @@ where .layer(Extension(dependencies.embedding_client)) .layer(Extension(dependencies.domain_regex)) .layer(Extension(dependencies.api_config)) - .layer(Extension(dependencies.compiler)); + .layer(Extension(dependencies.compiler)) + .layer(Extension(dependencies.clustering)); if let Some(query_logger) = dependencies.query_logger { router = router.layer(Extension(query_logger)); diff --git a/libs/@local/graph/authorization/docs/dependency-diagram.mmd b/libs/@local/graph/authorization/docs/dependency-diagram.mmd index 9b7424d94ae..aa9283d70f0 100644 --- a/libs/@local/graph/authorization/docs/dependency-diagram.mmd +++ b/libs/@local/graph/authorization/docs/dependency-diagram.mmd @@ -41,13 +41,13 @@ graph TD 1 -.-> 26 2 -.-> 3 2 --> 15 - 4 --> 6 4 --> 10 4 --> 13 4 --> 18 4 --> 21 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 diff --git a/libs/@local/graph/embeddings/Cargo.toml b/libs/@local/graph/embeddings/Cargo.toml index 907e2eb5420..d903507bb96 100644 --- a/libs/@local/graph/embeddings/Cargo.toml +++ b/libs/@local/graph/embeddings/Cargo.toml @@ -17,6 +17,9 @@ error-stack = { workspace = true } # Private third-party dependencies derive_more = { workspace = true, features = ["display", "error"] } +rand = { workspace = true } +rand_xoshiro = { workspace = true } +rayon = { workspace = true } reqwest = { workspace = true } reqwest-middleware = { workspace = true, features = ["json"] } reqwest-retry = { workspace = true } @@ -25,5 +28,14 @@ serde = { workspace = true, features = ["derive"] } simple-mermaid = { workspace = true } tracing = { workspace = true } +[dev-dependencies] +codspeed-criterion-compat = { workspace = true } +darwin-kperf-criterion = { workspace = true, features = ["codspeed"] } + +[[bench]] +name = "clustering" +harness = false + + [lints] workspace = true diff --git a/libs/@local/graph/embeddings/benches/clustering.rs b/libs/@local/graph/embeddings/benches/clustering.rs new file mode 100644 index 00000000000..6cd7f86a5be --- /dev/null +++ b/libs/@local/graph/embeddings/benches/clustering.rs @@ -0,0 +1,243 @@ +//! Benchmarks for the embedding k-means module. +//! +//! Two groups: +//! +//! * `embedding/kernel/*` — single-threaded SIMD micro-kernels, measured in retired instructions +//! via Apple PMCs (near-deterministic; requires root on macOS) with an automatic wall-clock +//! fallback on other platforms. +//! * `embedding/cluster/*` — end-to-end [`cluster`] runs. Always wall-clock, because the work is +//! spread across the rayon pool and per-thread instruction counts would only see the calling +//! thread. +//! +//! [`cluster`]: hash_graph_embeddings::clustering::cluster +#![expect( + unsafe_code, + clippy::float_arithmetic, + clippy::indexing_slicing, + clippy::integer_division, + clippy::integer_division_remainder_used, + clippy::min_ident_chars, + clippy::significant_drop_tightening, + reason = "benchmarks exercise the unsafe SIMD kernels directly and build float test data; \ + single-char idents (k, n, d) are standard mathematical notation for clustering; the \ + drop-tightening warning originates inside `criterion_group!`" +)] + +use core::{hint::black_box, num::NonZero}; + +use codspeed_criterion_compat::{ + BenchmarkId, Criterion, criterion_group, criterion_main, measurement::Measurement, +}; +use hash_graph_embeddings::{ + D256, D1536, D3072, Dimension, + clustering::{Config, cluster}, + kernel, +}; +use rand::{RngExt as _, SeedableRng as _, distr::Uniform}; +use rand_xoshiro::Xoshiro256PlusPlus; + +macro_rules! nz { + ($expr:expr) => { + const { ::core::num::NonZero::new($expr).unwrap() } + }; +} + +/// Uniform random values in `[-1, 1)`. +fn random_vec(n: usize, seed: u64) -> Vec { + let rng = Xoshiro256PlusPlus::seed_from_u64(seed); + rng.sample_iter(Uniform::new(-1.0, 1.0).expect("uniform range is non-empty")) + .take(n) + .collect() +} + +/// Uniform random values in `[0.1, 1)`, guaranteed positive so repeated +/// accumulation saturates at infinity instead of producing NaNs. +fn random_positive_vec(n: usize, seed: u64) -> Vec { + let rng = Xoshiro256PlusPlus::seed_from_u64(seed); + rng.sample_iter(Uniform::new(0.1, 1.0).expect("uniform range is non-empty")) + .take(n) + .collect() +} + +/// Well-separated blobs: `k` clusters of `points_per_cluster` points in +/// `d`-dimensional space, each with a dominant axis. Mirrors the shape of +/// real embedding workloads better than uniform noise: the fit converges +/// instead of always exhausting `max_iters`. +fn blobs(points_per_cluster: usize, k: usize, d: usize, seed: u64) -> Vec { + let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed); + let mut data = vec![0.0_f32; points_per_cluster * k * d]; + + for (index, row) in data.chunks_exact_mut(d).enumerate() { + let axis = (index / points_per_cluster) % d; + row[axis] = 10.0; + for value in row.iter_mut() { + *value += rng.random_range(-0.01..0.01); + } + } + + data +} + +const KERNEL_DIMS: [Dimension; 3] = [D256, D1536, D3072]; + +fn bench_dot(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("kernel/dot"); + + for dim in KERNEL_DIMS { + let lhs = random_vec(dim.get() as usize, 1); + let rhs = random_vec(dim.get() as usize, 2); + + group.bench_with_input(BenchmarkId::from_parameter(dim), &dim, |bencher, _| { + // SAFETY: both slices have length `d`, a multiple of 8. + bencher.iter(|| unsafe { kernel::dot(black_box(&lhs), black_box(&rhs)) }); + }); + } + + group.finish(); +} + +fn bench_add_scaled_into(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("kernel/add_scaled_into"); + + for dim in KERNEL_DIMS { + let src = random_positive_vec(dim.get() as usize, 3); + let mut dst = random_positive_vec(dim.get() as usize, 4); + + group.bench_with_input(BenchmarkId::from_parameter(dim), &dim, |bencher, _| { + // SAFETY: both slices have length `d`, a multiple of 8. + bencher.iter(|| unsafe { + kernel::add_scaled_into(black_box(&mut dst), black_box(&src), black_box(0.5)); + }); + }); + } + + group.finish(); +} + +fn bench_micro_4x2(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("kernel/micro_4x2"); + + for dim in KERNEL_DIMS { + let [p0, p1, p2, p3] = + core::array::from_fn(|index| random_vec(dim.get() as usize, 10 + index as u64)); + let c0 = random_vec(dim.get() as usize, 20); + let c1 = random_vec(dim.get() as usize, 21); + + group.bench_with_input(BenchmarkId::from_parameter(dim), &dim, |bencher, _| { + // SAFETY: all six slices have length `d`, a multiple of 8. + bencher.iter(|| unsafe { + kernel::micro_4x2( + black_box(&p0), + black_box(&p1), + black_box(&p2), + black_box(&p3), + black_box(&c0), + black_box(&c1), + ) + }); + }); + } + + group.finish(); +} + +fn bench_nearest4(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("kernel/nearest4"); + + // k = 15 exercises the odd-k remainder path. + for &(dim, k) in &[ + (D256, nz!(15)), + (D256, nz!(16)), + (D256, nz!(64)), + (D1536, nz!(16)), + (D3072, nz!(16)), + ] { + let [p0, p1, p2, p3] = + core::array::from_fn(|index| random_vec(dim.get() as usize, 30 + index as u64)); + let centroids = random_vec(k.get() * dim.get() as usize, 40); + + group.bench_with_input( + BenchmarkId::new(format!("d{dim}"), k), + &(dim, k), + |bencher, _| { + // SAFETY: point slices have length `d` (multiple of 8), + // centroids has length `k * d`, and `k > 0`. + bencher.iter(|| unsafe { + kernel::nearest4( + black_box(&p0), + black_box(&p1), + black_box(&p2), + black_box(&p3), + black_box(¢roids), + black_box(k), + black_box(NonZero::from(dim.value())), + ) + }); + }, + ); + } + + group.finish(); +} + +fn bench_cluster(criterion: &mut Criterion) { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(1) + .build() + .expect("should be built exactly once"); + + let mut group = criterion.benchmark_group("cluster"); + + // (n, k): n = 10k exercises the subsampled fit (m = 8192) plus the + // full-data refinement; n = 50k shifts the weight onto the full-data + // passes. + for &(n, k) in &[ + (10_000_usize, 8_u16), + (10_000, 32), + (10_000, 128), + (50_000, 32), + ] { + let data = blobs(n / usize::from(k), usize::from(k), 256, 7); + let config = Config::for_k_with_seed(k, 42); + + group.bench_with_input( + BenchmarkId::new(format!("n{n}_d256"), k), + &(n, k), + |bencher, _| { + pool.install(|| { + bencher.iter(|| cluster(black_box(&data), black_box(D256), &config)); + }); + }, + ); + } + + group.finish(); +} + +fn kernel_measurement() -> Criterion { + use core::time::Duration; + + // Retired instructions on Apple Silicon (needs root there), wall-clock + // fallback everywhere else. Instruction counts are near-deterministic, + // so short windows and small samples suffice. + Criterion::default() + .with_measurement( + darwin_kperf_criterion::HardwareCounter::instructions() + .expect("instruction counting requires root on Apple Silicon (run under sudo)"), + ) + .warm_up_time(Duration::from_millis(500)) + .measurement_time(Duration::from_secs(1)) + .sample_size(20) +} + +criterion_group!( + name = kernel; + config = kernel_measurement(); + targets = bench_dot, bench_add_scaled_into, bench_micro_4x2, bench_nearest4 +); +criterion_group!( + name = clustering; + config = Criterion::default(); + targets = bench_cluster +); +criterion_main!(kernel, clustering); diff --git a/libs/@local/graph/embeddings/docs/dependency-diagram.mmd b/libs/@local/graph/embeddings/docs/dependency-diagram.mmd index 1ebad383105..5aa3d1e61bb 100644 --- a/libs/@local/graph/embeddings/docs/dependency-diagram.mmd +++ b/libs/@local/graph/embeddings/docs/dependency-diagram.mmd @@ -16,32 +16,58 @@ graph TD 5[hash-graph-authorization] 6[hash-graph-embeddings] class 6 root - 7[hash-graph-store] - 8[hash-graph-temporal-versioning] - 9[hash-graph-types] - 10[harpc-types] - 11[harpc-wire-protocol] - 12[hash-temporal-client] - 13[error-stack] - 14[hash-graph-benches] - 15[hash-graph-test-data] + 7[hash-graph-postgres-store] + 8[hash-graph-store] + 9[hash-graph-temporal-versioning] + 10[hash-graph-types] + 11[harpc-types] + 12[harpc-wire-protocol] + 13[hashql-ast] + 14[hashql-compiletest] + 15[hashql-eval] + 16[hashql-hir] + 17[hashql-mir] + 18[hashql-syntax-jexpr] + 19[hash-temporal-client] + 20[darwin-kperf] + 21[darwin-kperf-criterion] + 22[darwin-kperf-events] + 23[darwin-kperf-sys] + 24[error-stack] + 25[hash-graph-benches] + 26[hash-graph-integration] + 27[hash-graph-test-data] 0 --> 4 - 1 --> 8 - 1 -.-> 15 + 1 --> 9 + 1 -.-> 27 2 -.-> 3 - 2 --> 11 - 4 --> 6 - 4 --> 7 + 2 --> 12 + 4 --> 15 + 4 --> 18 5 --> 1 - 6 --> 9 - 7 --> 5 - 7 --> 9 - 7 --> 12 - 8 --> 2 - 9 -.-> 15 - 11 -.-> 10 - 11 --> 10 - 11 --> 13 - 12 --> 1 - 14 -.-> 4 + 6 --> 10 + 6 -.-> 21 + 7 --> 6 + 8 --> 5 + 8 --> 10 + 8 --> 19 + 9 --> 2 + 10 -.-> 27 + 12 -.-> 11 + 12 --> 11 + 12 --> 24 + 13 -.-> 14 + 14 --> 15 + 14 --> 18 15 --> 7 + 15 --> 17 + 16 -.-> 14 + 17 --> 16 + 18 --> 13 + 19 --> 1 + 20 --> 22 + 20 --> 23 + 21 --> 20 + 25 -.-> 4 + 26 -.-> 7 + 27 --> 8 diff --git a/libs/@local/graph/embeddings/package.json b/libs/@local/graph/embeddings/package.json index 1ed4d31f77d..61d00ad4ee1 100644 --- a/libs/@local/graph/embeddings/package.json +++ b/libs/@local/graph/embeddings/package.json @@ -4,12 +4,19 @@ "private": true, "license": "AGPL-3", "scripts": { + "build:codspeed": "cargo codspeed build -p hash-graph-embeddings", "doc:dependency-diagram": "cargo run -p hash-repo-chores -- dependency-diagram --output docs/dependency-diagram.mmd --root hash-graph-embeddings --root-deps-and-dependents --link-mode non-roots --include-dev-deps --include-build-deps --logging-console-level info", "fix:clippy": "just clippy --fix", - "lint:clippy": "just clippy" + "lint:clippy": "just clippy", + "test:codspeed": "cargo codspeed run -p hash-graph-embeddings", + "test:miri": "cargo miri nextest run -- kernel clustering::tests::nearest_centroid_argmax_independent_of_inv_norm", + "test:unit": "mise run test:unit @rust/hash-graph-embeddings" }, "dependencies": { "@rust/error-stack": "workspace:*", "@rust/hash-graph-types": "workspace:*" + }, + "devDependencies": { + "@rust/darwin-kperf-criterion": "workspace:*" } } diff --git a/libs/@local/graph/embeddings/src/clustering.rs b/libs/@local/graph/embeddings/src/clustering.rs new file mode 100644 index 00000000000..0837494cf08 --- /dev/null +++ b/libs/@local/graph/embeddings/src/clustering.rs @@ -0,0 +1,1859 @@ +#![expect( + unsafe_code, + clippy::indexing_slicing, + clippy::float_arithmetic, + clippy::min_ident_chars, + clippy::many_single_char_names, + reason = "Single-char idents (k, n, m, d, x) are standard mathematical notation for \ + clustering." +)] +use alloc::borrow::Cow; +use core::{cmp, num::NonZero}; +use std::collections::HashSet; + +use rand::{Rng, RngExt as _, SeedableRng as _}; +use rand_xoshiro::Xoshiro256PlusPlus; +use rayon::{ + iter::{ + IndexedParallelIterator as _, IntoParallelIterator as _, IntoParallelRefIterator as _, + IntoParallelRefMutIterator as _, ParallelIterator as _, + }, + slice::{ParallelSlice as _, ParallelSliceMut as _}, +}; + +use super::{dimension::Dimension, kernel}; + +/// Parameters for k-means clustering. +/// +/// Use [`Config::for_k_with_seed`] to construct with reasonable defaults, then override individual +/// fields as needed. +#[derive(Debug, Copy, Clone)] +pub struct Config { + /// Number of clusters. + pub k: u16, + + /// Maximum Lloyd iterations per run before declaring convergence. + pub max_iters: NonZero, + + /// Number of independent restarts. The run with the lowest inertia wins. + pub n_init: NonZero, + + /// Convergence tolerance: a run stops early when the relative change in + /// inertia between iterations falls below this value. + pub tol: f32, + + /// Maximum number of points sampled during k-means++ seeding. + /// Capped to avoid quadratic seeding cost on very large datasets. + pub sample_cap: usize, + + /// Base seed for the PRNG. + /// + /// Runs with the same seed, input, and configuration produce identical + /// labels and centroids. + pub seed: u64, + + /// Number of points processed per batch in the parallel passes. + /// Values larger than the number of points are clamped. + pub chunk: NonZero, +} + +impl Config { + /// Creates a configuration for `k` clusters with a fixed seed. + /// + /// Defaults: 30 max iterations, 5 restarts, 1e-4 convergence tolerance, + /// sample cap of min(256k, 8192), chunk size 256. + #[must_use] + pub fn for_k_with_seed(k: u16, seed: u64) -> Self { + Self { + k, + max_iters: const { NonZero::new(30).unwrap() }, + n_init: const { NonZero::new(5).unwrap() }, + tol: 1e-4, + sample_cap: cmp::min(256 * usize::from(k), 8192), + seed, + chunk: const { NonZero::new(256).unwrap() }, + } + } +} + +/// Result of spherical k-means clustering. +/// +/// `centroids` is a flat `k * d` row-major buffer where `d` is the +/// embedding [`Dimension`]. Centroid `i` occupies +/// `centroids[i * d .. (i + 1) * d]`. +pub struct Clustering { + pub dimension: Dimension, + + /// Flat centroid matrix, `k * d` elements in row-major order. + /// + /// Centroids are unit-normalized, with one exception: a cluster whose + /// members are all zero-norm points keeps a zero centroid, since there + /// is no direction to normalize. + pub centroids: Box<[f32]>, + + /// Cluster assignment for each input point, values in `0..k`. + /// + /// When [`cluster`] ran with `k == 0` (requested or clamped) the labels + /// are all-zero placeholders and there are no centroids to index. + pub labels: Box<[u16]>, + + /// Sum of squared chord distances from every input point to its assigned + /// centroid, measured against the final centroids. Lower is tighter; + /// comparable across runs on the same input, e.g. for choosing `k`. + /// `0.0` when `k == 0` or the input is empty. + /// + /// The value is precise only up to floating-point summation order: + /// repeated runs over identical input can differ in the final bits. + pub inertia: f32, +} + +impl Clustering { + /// Allocates a zeroed clustering for `k` centroids over `n` points. + fn new(k: u16, n: usize, d: Dimension) -> Self { + let centroids: Box<[f32]> = vec![0.0; (k as usize) * (d.get() as usize)].into_boxed_slice(); + let labels: Box<[u16]> = vec![0; n].into_boxed_slice(); + + Self { + centroids, + labels, + dimension: d, + inertia: 0.0, + } + } + + /// Returns the `D`-dimensional slice for centroid `cluster`. + /// + /// # Panics + /// + /// Panics if `cluster` is not below the number of centroids. + #[must_use] + pub fn centroid(&self, cluster: u16) -> &[f32] { + &self.centroids[cluster as usize * (self.dimension.get() as usize) + ..(cluster + 1) as usize * (self.dimension.get() as usize)] + } + + /// Returns a mutable `D`-dimensional slice for centroid `cluster`. + fn centroid_mut(&mut self, cluster: u16) -> &mut [f32] { + &mut self.centroids[cluster as usize * (self.dimension.get() as usize) + ..(cluster + 1) as usize * (self.dimension.get() as usize)] + } + + /// Returns the cluster label for point `entity`. + /// + /// # Panics + /// + /// Panics if `entity` is not below the number of input points. + #[must_use] + pub fn label(&self, entity: usize) -> u16 { + self.labels[entity] + } +} + +/// Draws `m` distinct indices uniformly at random from `0..n` in O(m) time +/// and memory (Robert Floyd's sampling algorithm). +/// +/// The result is sorted and deterministic for a given RNG state. +fn sample_indices(n: usize, m: usize, mut rng: impl Rng) -> Vec { + debug_assert!(m <= n); + + let mut selected: HashSet = HashSet::with_capacity(m); + + for upper in n - m..n { + let candidate = rng.random_range(0..=upper); + + if !selected.insert(candidate) { + // `candidate` was already drawn in an earlier round. Earlier + // rounds only drew from `0..upper`, so `upper` itself is fresh. + selected.insert(upper); + } + } + + let mut indices: Vec = selected.into_iter().collect(); + // Sorting erases the hash set's nondeterministic iteration order and + // turns the caller's gather into a forward walk over `x`. + indices.sort_unstable(); + indices +} + +/// Squared chord distance between a point and a unit centroid. +/// +/// For a unit centroid `c` and a point with inverse norm `inv`, the cosine +/// similarity is `dot(point, c) * inv`. The squared chord distance is +/// `2 - 2 * similarity`, which lies in `[0, 4]` and equals `||u - c||²` +/// when `u` is the unit-normalized point. +/// +/// Returns `0.0` for zero-norm points (`point_inv_norm == 0.0`). +/// +/// This is a squared distance. Do not square it again for D² sampling. +#[inline] +fn squared_chord_distance(dot: f32, point_inv_norm: f32) -> f32 { + if point_inv_norm == 0.0 { + return 0.0; + } + + let similarity = (dot * point_inv_norm).clamp(-1.0, 1.0); + + 2.0_f32.mul_add(-similarity, 2.0).max(0.0) +} + +/// Finds the nearest centroid to `point` and returns its index and spherical +/// distance. +/// +/// # Safety +/// +/// * `point.len() == d` +/// * `centroids.len() == k * d` +/// * `d` is a multiple of 8 (guaranteed by [`Dimension`]). +#[inline] +#[must_use] +pub(crate) unsafe fn nearest_centroid( + point: &[f32], + point_inv_norm: f32, + centroids: &[f32], + k: NonZero, + d: NonZero, +) -> (u16, f32) { + debug_assert_eq!(point.len(), d.get()); + debug_assert_eq!(centroids.len(), k.get() * d.get()); + + // SAFETY: the caller guarantees these preconditions. The hints let the + // compiler elide bounds checks on the centroid slicing inside the loop. + unsafe { + core::hint::assert_unchecked(point.len() == d.get()); + core::hint::assert_unchecked(centroids.len() == k.get() * d.get()); + core::hint::assert_unchecked(d.get().is_multiple_of(8)); + } + + let mut best = 0; + let mut best_dot = f32::NEG_INFINITY; + + for cluster in 0..k.get() { + let start = cluster * d.get(); + let centroid = ¢roids[start..start + d.get()]; + + // SAFETY: `point` and `centroid` both have length `D`, and `D` is a + // multiple of 8 (guaranteed by Dimension). + let dot = unsafe { kernel::dot(point, centroid) }; + + #[expect( + clippy::cast_possible_truncation, + reason = "cluster < k, and k originates from Config::k (u16)" + )] + if dot > best_dot { + best = cluster as u16; + best_dot = dot; + } + } + + (best, squared_chord_distance(best_dot, point_inv_norm)) +} + +/// Assigns one chunk of points during Lloyd iterations: writes each point's +/// nearest centroid into `labels` and its squared chord distance into +/// `distances`. +/// +/// # Safety +/// +/// * `points.len() == labels.len() * d` +/// * `inv_norms.len() == labels.len()` +/// * `distances.len() == labels.len()` +/// * `centroids.len() == k * d` +/// * `d` is a multiple of 8 +unsafe fn lloyd_assign( + k: NonZero, + d: NonZero, + centroids: &[f32], + points: &[f32], + inv_norms: &[f32], + labels: &mut [u16], + distances: &mut [f32], +) { + let count = labels.len(); + + // SAFETY: the caller guarantees the length relations; the hints let the + // compiler elide bounds checks in the tiled loop below. + unsafe { + core::hint::assert_unchecked(points.len() == count * d.get()); + core::hint::assert_unchecked(inv_norms.len() == count); + core::hint::assert_unchecked(distances.len() == count); + core::hint::assert_unchecked(d.get().is_multiple_of(8)); + } + + let mut i = 0; + while i + 4 <= count { + let p0 = &points[i * d.get()..i * d.get() + d.get()]; + let p1 = &points[(i + 1) * d.get()..(i + 1) * d.get() + d.get()]; + let p2 = &points[(i + 2) * d.get()..(i + 2) * d.get() + d.get()]; + let p3 = &points[(i + 3) * d.get()..(i + 3) * d.get() + d.get()]; + + // SAFETY: each point length d, centroids length k*d, + // k > 0, d a multiple of 8 (guaranteed by Dimension). + let nearest = unsafe { kernel::nearest4(p0, p1, p2, p3, centroids, k, d) }; + + for offset in 0..4 { + labels[i + offset] = nearest[offset].0; + distances[i + offset] = + squared_chord_distance(nearest[offset].1, inv_norms[i + offset]); + } + i += 4; + } + + while i < count { + let point = &points[i * d.get()..i * d.get() + d.get()]; + + // SAFETY: point length d, centroids length k*d, k > 0, d mult of 8. + let (label, distance) = unsafe { nearest_centroid(point, inv_norms[i], centroids, k, d) }; + labels[i] = label; + distances[i] = distance; + i += 1; + } +} + +/// Per-restart scratch and state for one k-means fit on the sample. +/// +/// Restarts run in parallel, so each owns its buffers. [`Restart::new`] +/// hands them back zeroed. +struct Restart { + k: NonZero, + m: usize, + d: NonZero, + + /// Centroids for this restart, `k * d` elements. + centroids: Box<[f32]>, + /// Per-cluster accumulator for centroid recomputation, `k * d` elements. + sums: Box<[f32]>, + /// Per-cluster point count for the empty-cluster check. + counts: Box<[usize]>, + /// Per-sample-point cluster assignment. + labels: Box<[u16]>, + /// Per-sample-point distance scratch. + point_distances: Box<[f32]>, + /// Tracks which sample points have been selected as seeds. + selected: Box<[bool]>, + /// Point indices grouped by cluster, `m` elements; grouping scratch for + /// [`accumulate_clusters`]. + order: Box<[usize]>, + /// Bucket cursors/boundaries, `k + 1` elements; grouping scratch for + /// [`accumulate_clusters`]. + bounds: Box<[usize]>, +} + +impl Restart { + fn new(k: NonZero, m: usize, d: NonZero) -> Self { + let centroids: Box<[f32]> = vec![0.0; k.get() * d.get()].into_boxed_slice(); + let sums: Box<[f32]> = vec![0.0; k.get() * d.get()].into_boxed_slice(); + let counts: Box<[usize]> = vec![0; k.get()].into_boxed_slice(); + let labels: Box<[u16]> = vec![0; m].into_boxed_slice(); + let point_distances: Box<[f32]> = vec![0.0; m].into_boxed_slice(); + let selected: Box<[bool]> = vec![false; m].into_boxed_slice(); + let order: Box<[usize]> = vec![0; m].into_boxed_slice(); + let bounds: Box<[usize]> = vec![0; k.get() + 1].into_boxed_slice(); + + Self { + k, + m, + d, + centroids, + sums, + counts, + labels, + point_distances, + selected, + order, + bounds, + } + } + + /// Runs one restart: k-means++ seeding followed by Lloyd iterations. + /// + /// Returns the sample inertia of the fitted centroids. + fn run( + &mut self, + sample: &[f32], + chunk: usize, + row_chunk: usize, + sample_inv_norms: &[f32], + seed: u64, + config: &Config, + ) -> f32 { + let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed); + + // `new` zeroes everything else; the distance scratch must start at + // infinity so the first seeding pass overwrites every entry. + self.point_distances.fill(f32::INFINITY); + + self.seed_plusplus(sample, sample_inv_norms, &mut rng); + self.lloyd(sample, chunk, row_chunk, sample_inv_norms, config) + } + + /// k-means++ D² weighted seeding. Picks `k` initial centroids from the + /// sample, each chosen with probability proportional to its squared + /// distance from the nearest already-chosen centroid. + fn seed_plusplus(&mut self, sample: &[f32], sample_inv_norms: &[f32], rng: &mut impl Rng) { + let &mut Self { d, k, m, .. } = self; + let mut point = rng.random_range(0..m); + + for cluster in 0..k.get() { + let centroid_start = cluster * d.get(); + let point_start = point * d.get(); + + self.centroids[centroid_start..centroid_start + d.get()] + .copy_from_slice(&sample[point_start..point_start + d.get()]); + + // SAFETY: centroid rows have length `d`, and `d` is a multiple of 8. + unsafe { + kernel::normalize(&mut self.centroids[centroid_start..centroid_start + d.get()]); + } + + self.selected[point] = true; + + // The last centroid needs no D² update: those distances would + // only be used to sample a further seed. + if cluster + 1 == k.get() { + break; + } + + let centroid = &self.centroids[centroid_start..centroid_start + d.get()]; + + // Per-element writes only, so the pass is deterministic under + // rayon; the D² total is summed sequentially below. + sample + .par_chunks_exact(d.get()) + .zip(sample_inv_norms.par_iter()) + .zip(self.point_distances.par_iter_mut()) + .enumerate() + .for_each(|(index, ((point, &inv_norm), closest))| { + if self.selected[index] { + *closest = 0.0; + return; + } + + // SAFETY: `point` and `centroid` both have length `D`, and + // `D` is a multiple of 8 (guaranteed by Dimension). + let dot = unsafe { kernel::dot(point, centroid) }; + let distance = squared_chord_distance(dot, inv_norm); + + if distance < *closest { + *closest = distance; + } + }); + + let total: f32 = self.point_distances.iter().sum(); + + point = if total.is_finite() && total > 0.0 { + let mut target = rng.random_range(0.0..total); + let mut sampled = None; + let mut last_positive = 0; + + for (index, &distance) in self.point_distances.iter().enumerate() { + if distance <= 0.0 { + continue; + } + + last_positive = index; + target -= distance; + + if target <= 0.0 { + sampled = Some(index); + break; + } + } + + // Rounding can leave `target` marginally positive after the last bucket; + // fall back to the last point with positive mass. + sampled.unwrap_or(last_positive) + } else { + // Degenerate geometry: every remaining point coincides with a seed. + // Pick uniformly among the unselected points. + let remaining = self.selected.iter().filter(|selected| !**selected).count(); + let mut target = rng.random_range(0..remaining); + let mut sampled = 0; + + for (index, selected) in self.selected.iter().copied().enumerate() { + if selected { + continue; + } + + if target == 0 { + sampled = index; + break; + } + + target -= 1; + } + + sampled + }; + } + } + + /// Runs Lloyd iterations on the sample until convergence or `max_iters`. + /// + /// Returns the final inertia (sum of distances to assigned centroids). + fn lloyd( + &mut self, + sample: &[f32], + chunk: usize, + row_chunk: usize, + sample_inv_norms: &[f32], + config: &Config, + ) -> f32 { + let &mut Self { d, k, .. } = self; + let mut previous_inertia = f32::INFINITY; + let mut inertia = f32::INFINITY; + + for _ in 0..config.max_iters.get() { + // Assignment: labels and per-point distances. + // Trivially deterministic under rayon, as writes are per element. + sample + .par_chunks(row_chunk) + .zip(sample_inv_norms.par_chunks(chunk)) + .zip(self.labels.par_chunks_mut(chunk)) + .zip(self.point_distances.par_chunks_mut(chunk)) + .for_each(|(((points, inv_norms), labels), distances)| { + // SAFETY: `par_chunks(row_chunk)` with `row_chunk == chunk * d` + // pairs `labels.len()` labels, distances, and inv norms with + // `labels.len() * d` floats of points. `self.centroids` has + // length `k * d`, and `d` is a multiple of 8 (guaranteed by + // Dimension). + unsafe { + lloyd_assign(k, d, &self.centroids, points, inv_norms, labels, distances); + }; + }); + + inertia = self.point_distances.iter().sum(); + + let mut scratch = Scratch { + sums: &mut self.sums, + counts: &mut self.counts, + order: &mut self.order, + bounds: &mut self.bounds, + }; + + // SAFETY: `d` is a multiple of 8 (guaranteed by Dimension); + // every other requirement is checked by `accumulate_clusters` + // itself and panics rather than misbehaving. + unsafe { + accumulate_clusters( + sample, + &self.labels, + Some(sample_inv_norms), + &mut scratch, + d, + ); + } + + for cluster in 0..k.get() { + if self.counts[cluster] == 0 { + continue; + } + + let start = cluster * d.get(); + + // Normalization is scale-invariant, so the raw sum gives the same direction as the + // average. + self.centroids[start..start + d.get()] + .copy_from_slice(&self.sums[start..start + d.get()]); + + // SAFETY: centroid rows have length `d`, and `d` is a multiple of 8 + // (guaranteed by Dimension). + unsafe { + kernel::normalize(&mut self.centroids[start..start + d.get()]); + } + } + + let reseeded = self.reinit_empty_clusters(sample); + + // Skip the convergence check when a cluster was just reseeded: + // the reseeded centroid hasn't had an assignment pass yet, so + // breaking now would waste the reinit. + if !reseeded && previous_inertia.is_finite() { + let relative_change = + (previous_inertia - inertia).abs() / previous_inertia.max(f32::EPSILON); + + if relative_change <= config.tol { + break; + } + } + + previous_inertia = inertia; + } + + inertia + } + + /// Reinitializes empty clusters from the sample point farthest from its + /// assigned centroid, using the distances stored by the assignment pass. + /// + /// After relocating a point its stored distance is zeroed and its label + /// updated, so subsequent empty clusters in the same pass pick different + /// points. + #[expect( + clippy::cast_possible_truncation, + reason = "cluster index < k, and k originates from Config::k (u16)" + )] + fn reinit_empty_clusters(&mut self, sample: &[f32]) -> bool { + let &mut Self { d, k, .. } = self; + let mut reseeded = false; + + for cluster in 0..k.get() { + if self.counts[cluster] != 0 { + continue; + } + + reseeded = true; + + let mut farthest_idx = 0; + let mut farthest_dist = -1.0_f32; + + for (index, &distance) in self.point_distances.iter().enumerate() { + if distance > farthest_dist { + farthest_dist = distance; + farthest_idx = index; + } + } + + let point_start = farthest_idx * d.get(); + let centroid_start = cluster * d.get(); + + self.centroids[centroid_start..centroid_start + d.get()] + .copy_from_slice(&sample[point_start..point_start + d.get()]); + + // SAFETY: centroid rows have length `d`, a multiple of 8. + unsafe { + kernel::normalize(&mut self.centroids[centroid_start..centroid_start + d.get()]); + } + + self.labels[farthest_idx] = cluster as u16; + self.point_distances[farthest_idx] = 0.0; + } + + reseeded + } +} + +/// Borrowed scratch for [`accumulate_clusters`]. +struct Scratch<'ctx> { + /// Per-cluster accumulator, `k * d` elements. + sums: &'ctx mut [f32], + /// Per-cluster point count, `k` elements. + counts: &'ctx mut [usize], + /// Point indices grouped by cluster, one per labeled point. + order: &'ctx mut [usize], + /// Bucket cursors during the scatter, bucket boundaries after; + /// `k + 1` elements. + bounds: &'ctx mut [usize], +} + +/// Recomputes per-cluster sums and counts from labeled points. +/// +/// Points are grouped by cluster first (a stable counting sort on labels, +/// through `order` and `bounds`), then each cluster task walks only its own +/// bucket: one pass over the labels instead of one per cluster. Buckets +/// preserve ascending point order, so every cluster sum receives its +/// additions in a fixed order and the result is impervious to any thread +/// schedule order: the grouping runs sequentially on the calling thread, +/// and each sum is reduced by exactly one task over a fixed range. +/// +/// `inv_norms` supplies precomputed inverse norms; pass `None` to compute +/// them on the fly. +/// +/// Zero-norm points are counted but contribute nothing to the sums. +/// +/// `order` and `bounds` are grouping scratch; their contents on entry are +/// irrelevant. +/// +/// # Panics +/// +/// Panics if any label is not below `counts.len()`, or if the scratch and +/// input shapes are inconsistent: `order.len() != labels.len()`, +/// `bounds.len() != counts.len() + 1`, `sums.len() != counts.len() * d`, +/// or `inv_norms` (when provided) not one entry per label. +/// +/// # Safety +/// +/// * `d` is a multiple of 8 +unsafe fn accumulate_clusters( + points: &[f32], + labels: &[u16], + inv_norms: Option<&[f32]>, + Scratch { + sums, + counts, + order, + bounds, + }: &mut Scratch<'_>, + d: NonZero, +) { + let d = d.get(); + + // We deliberately opt into checked indexing here. Profiling via performance counters on + // Apple M5 (which does have a large out-of-order execution window) showed that the fully + // checked version has negligible cost: an additional ~13 instructions and ~6 branches per + // point (+5.3% instructions), yet no increase in cycle count. This is because the check + // branches are >99.8% predicted, and the extra µops retire from issue slots that otherwise + // sit idle behind FMA and load latency (backend stall slots *drop* ~4%); the loop is + // memory/FMA-bound, not issue-bound. + // + // While the measurements are specific to Apple M5, the general trend should be transferable + // to other architectures. + // + // The checks buy panics-instead-of-UB for free, shrinking `# Safety` to the kernels' + // alignment requirement. Do not switch this back to unchecked indexing without new + // measurements. + assert!(inv_norms.is_none_or(|norms| norms.len() == labels.len())); + assert_eq!(order.len(), labels.len()); + assert_eq!(bounds.len(), counts.len() + 1); + assert!(!bounds.is_empty()); + assert_eq!(sums.len(), counts.len() * d); + + // 1. Histogram: the counts double as the bucket sizes, and the checked indexing doubles as + // validation: an out-of-range label panics here, before any scratch is written. + counts.fill(0); + for &label in labels { + counts[usize::from(label)] += 1; + } + + // 2. Bucket starts. During the scatter, `bounds[c + 1]` is cluster `c`'s write cursor; each + // cursor ends at its bucket end, leaving `bounds` as exactly the boundary array the gather + // needs: cluster `c` owns `order[bounds[c]..bounds[c + 1]]`. + bounds[0] = 0; + let mut running = 0; + for (bound, &count) in bounds[1..].iter_mut().zip(counts.iter()) { + *bound = running; + running += count; + } + + // 3. Stable scatter: visiting points in ascending index order keeps each bucket ascending, + // which pins the per-cluster addition order (and therefore the sums) regardless of how rayon + // schedules the gather. + for (index, &label) in labels.iter().enumerate() { + // Cluster `c`'s cursor starts at its bucket start and is bumped once + // per point labeled `c`, of which the histogram counted exactly + // `counts[c]`, so it stays below the bucket end (at most + // `order.len()`): for histogram-validated labels these accesses + // cannot panic. + let cursor = &mut bounds[usize::from(label) + 1]; + order[*cursor] = index; + *cursor += 1; + } + + // Shared views for the parallel gather. + let order: &[usize] = order; + let bounds: &[usize] = bounds; + + // 4. Accumulate: one task per cluster, walking only its own bucket. + sums.par_chunks_exact_mut(d) + .zip(bounds.par_array_windows::<2>()) + .for_each(|(sum, &[start, end])| { + sum.fill(0.0); + + for &index in &order[start..end] { + let row = index * d; + let point = &points[row..row + d]; + + let inv_norm = inv_norms.map_or_else( + || { + // SAFETY: `point` has length `d`, a multiple of 8 + // (guaranteed by the caller). + let norm = unsafe { kernel::dot(point, point) }.sqrt(); + + if norm > 0.0 { norm.recip() } else { 0.0 } + }, + |inv_norms| inv_norms[index], + ); + + if inv_norm == 0.0 { + continue; + } + + // SAFETY: `sum` and `point` both have length `d`, and `d` is + // a multiple of 8 (guaranteed by the caller). + unsafe { + kernel::add_scaled_into(sum, point, inv_norm); + } + } + }); +} + +/// Labels one parallel chunk: each point gets its nearest centroid. +/// +/// # Safety +/// +/// * `points.len() == labels.len() * d` +/// * `centroids.len() >= k * d` +/// * `d` is a multiple of 8 +unsafe fn label_chunk( + centroids: &[f32], + k_nz: NonZero, + d_nz: NonZero, + points: &[f32], + labels: &mut [u16], +) { + let k = k_nz.get(); + let d = d_nz.get(); + + let count = labels.len(); + + // SAFETY: each parallel chunk pairs `count` labels with `count * d` + // floats of point data; `d` is a multiple of 8 (guaranteed by Dimension). + unsafe { + core::hint::assert_unchecked(points.len() == count * d); + core::hint::assert_unchecked(centroids.len() >= k * d); + core::hint::assert_unchecked(d.is_multiple_of(8)); + } + + let mut i = 0; + while i + 4 <= count { + let p0 = &points[i * d..i * d + d]; + let p1 = &points[(i + 1) * d..(i + 1) * d + d]; + let p2 = &points[(i + 2) * d..(i + 2) * d + d]; + let p3 = &points[(i + 3) * d..(i + 3) * d + d]; + + // SAFETY: each point length d, centroids length k*d, k > 0, + // d a multiple of 8 (guaranteed by Dimension). + let nearest = unsafe { kernel::nearest4(p0, p1, p2, p3, centroids, k_nz, d_nz) }; + + labels[i] = nearest[0].0; + labels[i + 1] = nearest[1].0; + labels[i + 2] = nearest[2].0; + labels[i + 3] = nearest[3].0; + i += 4; + } + + while i < count { + let point = &points[i * d..i * d + d]; + // SAFETY: point length d, centroids length k*d, k > 0, d mult of 8. + let (label, _) = unsafe { nearest_centroid(point, 1.0, centroids, k_nz, d_nz) }; + labels[i] = label; + i += 1; + } +} + +/// Labels one parallel chunk against the final centroids and returns its +/// inertia contribution. Inverse norms are computed on the fly. +/// +/// # Safety +/// +/// * `points.len() == labels.len() * d` +/// * `centroids.len() >= k * d` +/// * `d` is a multiple of 8 +unsafe fn score_chunk( + centroids: &[f32], + k: NonZero, + d_nz: NonZero, + points: &[f32], + labels: &mut [u16], +) -> f32 { + let d = d_nz.get(); + + debug_assert_eq!(points.len(), labels.len() * d); + + // SAFETY: The caller must ensure `points.len() == labels.len() * d`. + unsafe { + core::hint::assert_unchecked(points.len() == labels.len() * d); + } + + let count = labels.len(); + let mut inertia = 0.0_f32; + + let mut i = 0; + while i + 4 <= count { + let p0 = &points[i * d..i * d + d]; + let p1 = &points[(i + 1) * d..(i + 1) * d + d]; + let p2 = &points[(i + 2) * d..(i + 2) * d + d]; + let p3 = &points[(i + 3) * d..(i + 3) * d + d]; + + // SAFETY: each point length d, centroids length k*d, k > 0, + // d a multiple of 8 (guaranteed by Dimension). + let nearest = unsafe { kernel::nearest4(p0, p1, p2, p3, centroids, k, d_nz) }; + let ps = [p0, p1, p2, p3]; + + for offset in 0..4 { + labels[i + offset] = nearest[offset].0; + + // SAFETY: point length d, a multiple of 8. + let norm = unsafe { kernel::dot(ps[offset], ps[offset]) }.sqrt(); + let inv_norm = if norm > 0.0 { norm.recip() } else { 0.0 }; + inertia += squared_chord_distance(nearest[offset].1, inv_norm); + } + i += 4; + } + + while i < count { + let point = &points[i * d..i * d + d]; + + // SAFETY: point length d, a multiple of 8. + let norm = unsafe { kernel::dot(point, point) }.sqrt(); + let inv_norm = if norm > 0.0 { norm.recip() } else { 0.0 }; + + // SAFETY: point length d, centroids length k*d, k > 0, d mult of 8. + let (label, distance) = unsafe { nearest_centroid(point, inv_norm, centroids, k, d_nz) }; + labels[i] = label; + inertia += distance; + i += 1; + } + + inertia +} + +/// Labels every point with its nearest centroid. +/// +/// # Safety +/// +/// * `x.len() == n * d` for some `n` +/// * `clustering.centroids.len() == k * d` +/// * `clustering.labels.len() == n` +/// * `d` is a multiple of 8 +unsafe fn reassign( + x: &[f32], + centroids: &[f32], + labels: &mut [u16], + k: NonZero, + d: NonZero, + chunk: usize, + row_chunk: usize, +) { + x.par_chunks(row_chunk) + .zip(labels.par_chunks_mut(chunk)) + .for_each(|(points, labels)| { + // SAFETY: `par_chunks(row_chunk)` with `row_chunk = chunk * d` + // ensures `points.len() == labels.len() * d`. Centroids and k + // are valid from the caller. + unsafe { + label_chunk(centroids, k, d, points, labels); + } + }); +} + +/// Labels every point with its nearest centroid and returns the total +/// inertia. +/// +/// Labels are exact; the inertia is precise only up to floating-point +/// summation order. +/// +/// # Safety +/// +/// * `x.len() == n * d` for some `n` +/// * `clustering.centroids.len() == k * d` +/// * `clustering.labels.len() == n` +/// * `d` is a multiple of 8 +unsafe fn reassign_scored( + x: &[f32], + centroids: &[f32], + labels: &mut [u16], + k: NonZero, + d: NonZero, + chunk: usize, + row_chunk: usize, +) -> f32 { + // Unordered parallel reduction: the grouping follows rayon's scheduling, so the sum is not + // bit-stable. An ordered reduction would need to collect per-chunk partials, costing an + // allocation per call. + x.par_chunks(row_chunk) + .zip(labels.par_chunks_mut(chunk)) + .map(|(points, labels)| { + // SAFETY: `par_chunks(row_chunk)` with `row_chunk = chunk * d` + // ensures `points.len() == labels.len() * d`. Centroids and k + // are valid from the caller. + unsafe { score_chunk(centroids, k, d, points, labels) } + }) + .sum() +} + +/// Assigns all `n` points to their nearest centroid, recomputes centroids +/// from the full population, and re-labels against the final centroids. +/// Returns the full-data inertia. +/// +/// `scratch` contents on entry are irrelevant; its shape is validated by +/// [`accumulate_clusters`], which panics on any mismatch. +/// +/// # Safety +/// +/// * `x.len() == n * d` for some `n` +/// * `clustering.centroids.len() == k * d` +/// * `clustering.labels.len() == n` +/// * `d` is a multiple of 8 +unsafe fn assign( + x: &[f32], + clustering: &mut Clustering, + k: NonZero, + chunk: usize, + row_chunk: usize, + scratch: &mut Scratch<'_>, +) -> f32 { + let d = NonZero::::from(clustering.dimension.value()); + + // 1. Label all points against the sample-fitted centroids. + // SAFETY: forwarded from the caller. + unsafe { + reassign( + x, + &clustering.centroids, + &mut clustering.labels, + k, + d, + chunk, + row_chunk, + ); + } + + // 2. Recompute centroids from the full population. + // SAFETY: `d` is a multiple of 8 (guaranteed by Dimension); every other + // requirement is checked by `accumulate_clusters` itself and panics + // rather than misbehaving. + unsafe { + accumulate_clusters(x, &clustering.labels, None, scratch, d); + } + + for (cluster, count) in scratch.counts.iter_mut().enumerate() { + if *count == 0 { + continue; + } + + let start = cluster * d.get(); + + #[expect( + clippy::cast_possible_truncation, + reason = "cluster < k and k originates from Config::k (u16)" + )] + let centroid = clustering.centroid_mut(cluster as u16); + // Normalization is scale-invariant, so the raw sum gives the same + // direction as the average. + centroid.copy_from_slice(&scratch.sums[start..start + d.get()]); + + // SAFETY: centroid length d, a multiple of 8. + unsafe { + kernel::normalize(centroid); + } + } + + // 3. Final labels and inertia against the recomputed centroids. + // SAFETY: centroids were just recomputed in place; same invariants hold. + unsafe { + reassign_scored( + x, + &clustering.centroids, + &mut clustering.labels, + k, + d, + chunk, + row_chunk, + ) + } +} + +/// Runs spherical k-means over a flat row-major embedding matrix. +/// +/// `x` contains `n` points of `dimension` floats each, laid out +/// contiguously. Returns cluster assignments, unit-normalized centroids, and +/// the full-data inertia. +/// +/// Given the same input and configuration, the returned labels and +/// centroids are identical across runs; the inertia is precise only up to +/// floating-point summation order (see [`Clustering::inertia`]). +/// +/// Zero-norm points do not influence centroids, and are always assigned to +/// cluster 0 at distance 0. If a cluster consists solely of zero-norm points, +/// its centroid is zero; see [`Clustering::centroids`]. +/// +/// If `config.k == 0` or `x` is empty there is nothing to fit: the result +/// has no centroids, all-zero placeholder labels, and an inertia of `0.0`. +/// +/// # Panics +/// +/// Panics if `x.len()` is not a multiple of `dimension`. +#[must_use] +#[expect(clippy::integer_division_remainder_used, clippy::integer_division)] +pub fn cluster(x: &[f32], dimension: Dimension, config: &Config) -> Clustering { + let d = dimension.get() as usize; + assert!(x.len().is_multiple_of(d)); + + let n = x.len() / d; + let k = cmp::min(config.k, n.saturating_truncate()); + + let mut clustering = Clustering::new(k, n, dimension); + + let Some(k) = NonZero::new(k) else { + return clustering; + }; + + let k = NonZero::from(k); + let mut rng = Xoshiro256PlusPlus::seed_from_u64(config.seed); + + // 1. subsample (fit on all of n only when n is already small) + let m = config.sample_cap.max(k.get()).min(n); + + let sample = if m == n { + Cow::Borrowed(x) + } else { + let indices = sample_indices(n, m, &mut rng); + let mut sampled = vec![0.0_f32; m * d]; + + let chunks = sampled.chunks_mut(d); + assert_eq!(chunks.len(), indices.len()); + + for (chunk, index) in chunks.zip(indices) { + chunk.copy_from_slice(&x[index * d..(index + 1) * d]); + } + + Cow::Owned(sampled) + }; + + let sample = sample.as_ref(); + + // Clamping to `n` keeps `row_chunk` from overflowing: `chunk * d` is at most `n * d == + // x.len()`. + let chunk = cmp::min(config.chunk.get(), n); + let row_chunk = chunk * d; + + let sample_inv_norms: Vec = sample + .par_chunks_exact(d) + .map(|point| { + // SAFETY: every point is a `d`-sized row, and `d` is a multiple of 8 (guaranteed by + // Dimension). + let norm = unsafe { kernel::dot(point, point) }.sqrt(); + + if norm > 0.0 { norm.recip() } else { 0.0 } + }) + .collect(); + + // 2. fit on the sample: independent k-means++ restarts in parallel, the + // run with the lowest inertia wins (guards against bad initializations). + // Seeds are pre-derived so the stream matches a sequential run; ties + // break on the restart index, which keeps the winner deterministic no + // matter how rayon schedules the restarts. + let seeds: Vec = core::iter::repeat_with(|| rng.random()) + .take(usize::try_from(config.n_init.get()).unwrap_or(usize::MAX)) + .collect(); + + let best = seeds + .into_par_iter() + .enumerate() + .map(|(index, seed)| { + let mut restart = Restart::new(k, m, dimension.value().into()); + let inertia = restart.run(sample, chunk, row_chunk, &sample_inv_norms, seed, config); + + (inertia, index, restart) + }) + .min_by(|lhs, rhs| lhs.0.total_cmp(&rhs.0).then(lhs.1.cmp(&rhs.1))) + .expect("config.n_init is non-zero, so at least one restart ran"); + + // Reuse the winning restart's buffers: its centroids become the result + // and its per-cluster accumulators serve the full-data recomputation, + // instead of allocating fresh ones. Only `order` needs a fresh, `n`-sized + // allocation (the restart's is sample-sized); this is the sole per-fit + // scratch allocation outside setup, and none happen per iteration. + let Restart { + centroids, + mut sums, + mut counts, + mut bounds, + .. + } = best.2; + + clustering.centroids = centroids; + + let mut order = vec![0_usize; n]; + + let mut scratch = Scratch { + sums: &mut sums, + counts: &mut counts, + order: &mut order, + bounds: &mut bounds, + }; + + // 3. assign points to clusters + // SAFETY: `x.len() == n * d` (asserted above), `clustering.centroids.len() == k * d`, + // `sums` and `counts` are the restart's `k * d` and `k` sized accumulators, + // `order` was just allocated with `n` entries, `bounds` is the restart's + // `k + 1` boundary scratch, and `d` is a multiple of 8 (guaranteed by + // Dimension). + clustering.inertia = unsafe { assign(x, &mut clustering, k, chunk, row_chunk, &mut scratch) }; + + clustering +} + +#[cfg(test)] +mod tests { + #![expect( + clippy::float_cmp, + clippy::integer_division_remainder_used, + reason = "test module: float comparisons are intentional for exact-zero and distance \ + checks; modulo is used in test data construction" + )] + use super::*; + + macro_rules! nz { + ($expr:expr) => { + const { NonZero::new($expr).unwrap() } + }; + } + + /// Builds well-separated blob clusters in D-dimensional space. + /// + /// Each blob has a dominant axis so clusters are far apart in cosine + /// space. Returns `(flat_points, ground_truth_labels)`. + #[expect( + clippy::cast_possible_truncation, + reason = "k is small in tests, fits in u16" + )] + fn make_blobs( + points_per_cluster: usize, + k: usize, + seed: u64, + ) -> (Vec, Vec) { + let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed); + let n = points_per_cluster * k; + let mut data = vec![0.0_f32; n * D]; + let mut truth = vec![0_u16; n]; + + for c in 0..k { + let axis = c % D; + for p in 0..points_per_cluster { + let idx = c * points_per_cluster + p; + let row = &mut data[idx * D..(idx + 1) * D]; + + row[axis] = 10.0; + for val in row.iter_mut() { + *val += rng.random_range(-0.01..0.01); + } + + truth[idx] = c as u16; + } + } + + (data, truth) + } + + const D: usize = 64; + + fn l2(v: &[f32]) -> f32 { + v.iter().map(|x| x * x).sum::().sqrt() + } + + /// Random unit-norm centroids in `D`-dimensional space. + fn unit_random(k: NonZero, seed: u64) -> Vec { + let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed); + let mut c = vec![0.0_f32; k.get() * D]; + for row in c.chunks_exact_mut(D) { + for v in row.iter_mut() { + *v = rng.random_range(-1.0..1.0); + } + let n = l2(row); + for v in row.iter_mut() { + *v /= n; + } + } + c + } + + /// Brute-force nearest centroid by cosine similarity. + #[expect(clippy::cast_possible_truncation, reason = "k is small in tests")] + fn brute_nearest_cosine(point: &[f32], centroids: &[f32], k: NonZero) -> u16 { + let pn = l2(point); + let mut best = 0_u16; + let mut best_cos = f32::NEG_INFINITY; + + for c in 0..k.get() { + let cent = ¢roids[c * D..(c + 1) * D]; + let d: f32 = point.iter().zip(cent).map(|(a, b)| a * b).sum(); + let cn = l2(cent); + let cos = if pn == 0.0 || cn == 0.0 { + 0.0 + } else { + d / (pn * cn) + }; + + if cos > best_cos { + best_cos = cos; + best = c as u16; + } + } + best + } + + /// Computes clustering accuracy using majority-vote label mapping. + /// + /// K-means labels are permutation-invariant, so this assigns each + /// predicted cluster to whichever ground-truth cluster it overlaps + /// most, then counts correctly assigned points. + #[expect( + clippy::cast_precision_loss, + reason = "counts are small test values, well within f64 precision" + )] + fn accuracy(predicted: &[u16], truth: &[u16], k: usize) -> f64 { + let mut votes = vec![vec![0_usize; k]; k]; + for (&pred, &true_label) in predicted.iter().zip(truth) { + votes[pred as usize][true_label as usize] += 1; + } + + let correct: usize = votes + .iter() + .map(|row| row.iter().copied().max().unwrap_or(0)) + .sum(); + + correct as f64 / predicted.len() as f64 + } + + /// Shorthand for [`Dimension::new`] that panics on invalid input. + fn dim(d: u16) -> Dimension { + Dimension::new(d).expect("test dimension must be a positive multiple of 8") + } + + #[test] + fn chord_identical_vectors_is_zero() { + // dot=1.0, inv_norm=1.0 => similarity=1 => distance=0 + assert_eq!(squared_chord_distance(1.0, 1.0), 0.0); + } + + #[test] + fn chord_orthogonal_vectors() { + // dot=0 => similarity=0 => distance=2 + let dist = squared_chord_distance(0.0, 1.0); + assert!((dist - 2.0).abs() < 1e-6, "expected 2.0, got {dist}"); + } + + #[test] + fn chord_opposite_vectors() { + // dot=-1.0 => similarity=-1 => distance=4 + let dist = squared_chord_distance(-1.0, 1.0); + assert!((dist - 4.0).abs() < 1e-6, "expected 4.0, got {dist}"); + } + + #[test] + fn chord_zero_norm_returns_zero() { + assert_eq!(squared_chord_distance(0.5, 0.0), 0.0); + assert_eq!(squared_chord_distance(-0.5, 0.0), 0.0); + } + + #[test] + fn chord_is_non_negative() { + for dot_val in [0.0, 0.5, 1.0, -0.5, -1.0, 2.0, -2.0] { + for inv in [0.0, 0.5, 1.0, 2.0] { + let dist = squared_chord_distance(dot_val, inv); + assert!(dist >= 0.0, "negative for dot={dot_val}, inv={inv}: {dist}"); + } + } + } + + #[test] + fn sample_indices_unique_sorted_in_range() { + let rng = Xoshiro256PlusPlus::seed_from_u64(1); + let indices = sample_indices(1000, 100, rng); + + assert_eq!(indices.len(), 100); + assert!( + indices.is_sorted_by(|lhs, rhs| lhs < rhs), + "indices must be strictly increasing (sorted, unique)" + ); + assert!(indices.iter().all(|&index| index < 1000)); + } + + #[test] + fn cluster_empty_input() { + let config = Config::for_k_with_seed(4, 42); + let result = cluster(&[], dim(8), &config); + assert_eq!(result.labels.len(), 0); + assert_eq!(result.centroids.len(), 0); + assert_eq!(result.inertia, 0.0); + } + + #[test] + fn cluster_k0() { + let data = vec![1.0_f32; 8]; + let config = Config::for_k_with_seed(0, 42); + let result = cluster(&data, dim(8), &config); + assert_eq!(result.labels.len(), 1); + assert_eq!(result.labels[0], 0); + assert_eq!(result.centroids.len(), 0); + assert_eq!(result.inertia, 0.0); + } + + #[test] + fn cluster_k1_all_same_label() { + let (data, _) = make_blobs::<8>(20, 3, 123); + let config = Config::for_k_with_seed(1, 42); + let result = cluster(&data, dim(8), &config); + + assert_eq!(result.labels.len(), 60); + assert!( + result.labels.iter().all(|&l| l == 0), + "k=1: all labels must be 0" + ); + } + + #[test] + fn cluster_single_point() { + let data = vec![1.0_f32; 16]; + let config = Config::for_k_with_seed(5, 42); + // k clamped to min(k, n) = 1 + let result = cluster(&data, dim(16), &config); + assert_eq!(result.labels.len(), 1); + assert_eq!(result.labels[0], 0); + } + + #[test] + fn cluster_n_less_than_4() { + // n=3 exercises the scalar tail (no nearest4 tiling). + let (data, _) = make_blobs::<8>(1, 3, 99); + let config = Config::for_k_with_seed(3, 42); + let result = cluster(&data, dim(8), &config); + + assert_eq!(result.labels.len(), 3); + let mut seen = [false; 3]; + for &label in &*result.labels { + seen[label as usize] = true; + } + assert!( + seen.iter().all(|&s| s), + "each point should have a unique cluster" + ); + } + + #[test] + fn cluster_n_equals_k() { + let (data, _) = make_blobs::<8>(1, 5, 77); + let config = Config::for_k_with_seed(5, 42); + let result = cluster(&data, dim(8), &config); + + assert_eq!(result.labels.len(), 5); + let mut seen = [false; 5]; + for &label in &*result.labels { + seen[label as usize] = true; + } + assert!( + seen.iter().all(|&s| s), + "n=k: each point should be its own cluster" + ); + } + + #[test] + fn cluster_recovers_well_separated_blobs() { + let (data, truth) = make_blobs::<8>(50, 4, 314); + let config = Config::for_k_with_seed(4, 42); + let result = cluster(&data, dim(8), &config); + + let acc = accuracy(&result.labels, &truth, 4); + assert!( + acc > 0.95, + "expected >95% accuracy on well-separated blobs, got {:.1}%", + acc * 100.0 + ); + } + + #[test] + fn cluster_deterministic_with_same_seed() { + let (data, _) = make_blobs::<8>(30, 3, 555); + + let r1 = cluster(&data, dim(8), &Config::for_k_with_seed(3, 42)); + let r2 = cluster(&data, dim(8), &Config::for_k_with_seed(3, 42)); + + assert_eq!(r1.labels, r2.labels); + assert_eq!(r1.centroids, r2.centroids); + + // The inertia reduction is a parallel sum, so it is only + // deterministic up to float summation order. + let tolerance = r1.inertia.abs().max(f32::EPSILON) * 1e-5; + assert!( + (r1.inertia - r2.inertia).abs() <= tolerance, + "inertia should agree within summation-order tolerance: {} vs {}", + r1.inertia, + r2.inertia + ); + } + + #[test] + fn cluster_recovers_blobs_across_seeds() { + let (data, truth) = make_blobs::<8>(30, 3, 555); + + for seed in [42, 9999] { + let result = cluster(&data, dim(8), &Config::for_k_with_seed(3, seed)); + let acc = accuracy(&result.labels, &truth, 3); + assert!( + acc > 0.95, + "seed {seed}: expected >95% accuracy, got {:.1}%", + acc * 100.0 + ); + } + } + + #[test] + fn cluster_centroids_are_unit_normalized() { + let (data, _) = make_blobs::<8>(40, 4, 222); + let config = Config::for_k_with_seed(4, 42); + let result = cluster(&data, dim(8), &config); + + for c in 0..4_u16 { + let centroid = result.centroid(c); + // SAFETY: centroid has length 8 (= D), a multiple of 8. + let norm = unsafe { kernel::dot(centroid, centroid).sqrt() }; + assert!( + (norm - 1.0).abs() < 1e-5, + "centroid {c} has norm {norm}, expected 1.0" + ); + } + } + + #[test] + fn cluster_labels_in_range() { + let (data, _) = make_blobs::<8>(25, 5, 333); + let config = Config::for_k_with_seed(5, 42); + let result = cluster(&data, dim(8), &config); + + for (i, &label) in result.labels.iter().enumerate() { + assert!(label < 5, "label[{i}] = {label}, expected < 5"); + } + } + + #[test] + fn cluster_labels_nearest_to_assigned_centroid() { + let (data, _) = make_blobs::<8>(30, 3, 444); + let config = Config::for_k_with_seed(3, 42); + let result = cluster(&data, dim(8), &config); + + let k = 3_usize; + let d = 8_usize; + for (i, point) in data.chunks_exact(d).enumerate() { + let assigned = result.labels[i]; + // SAFETY: point and centroid both have length 8 (= D), a multiple of 8. + let assigned_dot = unsafe { kernel::dot(point, result.centroid(assigned)) }; + + #[expect(clippy::cast_possible_truncation, reason = "k=3 fits in u16")] + for c in 0..k as u16 { + // SAFETY: point and centroid both have length 8, a multiple of 8. + let other_dot = unsafe { kernel::dot(point, result.centroid(c)) }; + assert!( + other_dot <= assigned_dot + 1e-5, + "point {i}: assigned to {assigned} (dot={assigned_dot}) but centroid {c} has \ + higher dot={other_dot}" + ); + } + } + } + + #[test] + fn cluster_d32_recovers_blobs() { + let (data, truth) = make_blobs::<32>(40, 3, 888); + let config = Config::for_k_with_seed(3, 42); + let result = cluster(&data, dim(32), &config); + + let acc = accuracy(&result.labels, &truth, 3); + assert!( + acc > 0.95, + "D=32: expected >95% accuracy, got {:.1}%", + acc * 100.0 + ); + } + + #[test] + fn cluster_d256_recovers_blobs() { + // Production default dimension (matryoshka truncation target). + let (data, truth) = make_blobs::<256>(50, 4, 1234); + let config = Config::for_k_with_seed(4, 42); + let result = cluster(&data, dim(256), &config); + + let acc = accuracy(&result.labels, &truth, 4); + assert!( + acc > 0.95, + "D=256: expected >95% accuracy, got {:.1}%", + acc * 100.0 + ); + } + + #[test] + fn cluster_d1536_recovers_blobs() { + let (data, truth) = make_blobs::<1536>(20, 3, 4321); + let config = Config::for_k_with_seed(3, 42); + let result = cluster(&data, dim(1536), &config); + + let acc = accuracy(&result.labels, &truth, 3); + assert!( + acc > 0.95, + "D=1536: expected >95% accuracy, got {:.1}%", + acc * 100.0 + ); + } + + #[test] + fn cluster_chunk_sizes_produce_valid_results() { + let (data, truth) = make_blobs::<8>(50, 4, 314); + + for chunk in [1_usize, 3, 1_000_000] { + let mut config = Config::for_k_with_seed(4, 42); + config.chunk = NonZero::new(chunk).expect("chunk is non-zero"); + let result = cluster(&data, dim(8), &config); + + let acc = accuracy(&result.labels, &truth, 4); + assert!( + acc > 0.95, + "chunk={chunk}: expected >95% accuracy, got {:.1}%", + acc * 100.0 + ); + } + } + + #[test] + fn cluster_inertia_reflects_fit_quality() { + let (data, _) = make_blobs::<8>(50, 4, 99); + + let tight = cluster(&data, dim(8), &Config::for_k_with_seed(4, 42)); + assert!(tight.inertia.is_finite()); + assert!(tight.inertia >= 0.0); + + // Forcing 4 well-separated blobs into a single cluster must fit + // strictly worse. + let loose = cluster(&data, dim(8), &Config::for_k_with_seed(1, 42)); + assert!( + loose.inertia > tight.inertia, + "k=1 inertia {} should exceed k=4 inertia {}", + loose.inertia, + tight.inertia + ); + } + + #[test] + fn cluster_recovers_with_subsampling() { + // n=12000 with sample_cap=1024 exercises the Cow::Owned path. + let (data, truth) = make_blobs::<8>(2000, 6, 21); + let mut config = Config::for_k_with_seed(6, 5); + config.sample_cap = 1024; + let result = cluster(&data, dim(8), &config); + + let acc = accuracy(&result.labels, &truth, 6); + assert!( + acc > 0.95, + "subsampled: expected >95% accuracy, got {:.1}%", + acc * 100.0 + ); + } + + #[test] + fn cluster_more_clusters_than_natural_groups() { + // 3 natural groups but k=8: empty clusters keep their seed centroid, + // nothing should be NaN or infinite. + let (data, _) = make_blobs::<8>(400, 3, 31); + let result = cluster(&data, dim(8), &Config::for_k_with_seed(8, 1)); + + assert!( + result.centroids.iter().all(|v| v.is_finite()), + "NaN or infinite centroid" + ); + assert!(result.labels.iter().all(|&l| l < 8)); + } + + #[test] + fn cluster_all_identical_points() { + // Every point identical: D² distances are all zero during seeding, + // which triggers the uniform fallback path. + let n = 100; + let mut data = vec![0.0_f32; n * 8]; + for row in data.chunks_exact_mut(8) { + row[0] = 1.0; + } + let result = cluster(&data, dim(8), &Config::for_k_with_seed(4, 1)); + + assert!(result.centroids.iter().all(|v| v.is_finite())); + assert!(result.labels.iter().all(|&l| l < 4)); + } + + #[test] + fn nearest_centroid_matches_brute_force_cosine() { + let k = nz!(7); + let centroids = unit_random(k, 99); + let mut rng = Xoshiro256PlusPlus::seed_from_u64(100); + + for _ in 0..1000 { + let p: Vec = core::iter::repeat_with(|| rng.random_range(-3.0..3.0)) + .take(D) + .collect(); + let pn = l2(&p); + let inv = if pn > 0.0 { pn.recip() } else { 0.0 }; + + // SAFETY: point has length D=64, centroids has length k*D, + // k > 0, D is a multiple of 8. + let (got, _) = unsafe { nearest_centroid(&p, inv, ¢roids, k, nz!(D)) }; + assert_eq!( + got, + brute_nearest_cosine(&p, ¢roids, k), + "mismatch for point norm={pn}" + ); + } + } + + #[test] + fn nearest_centroid_argmax_independent_of_inv_norm() { + let k = nz!(5); + let centroids = unit_random(k, 7); + let mut rng = Xoshiro256PlusPlus::seed_from_u64(8); + + for _ in 0..500 { + let p: Vec = core::iter::repeat_with(|| rng.random_range(-2.0..2.0)) + .take(D) + .collect(); + + // SAFETY: point has length D=64, centroids has length k*D, + // k > 0, D is a multiple of 8. + let (a, _) = unsafe { nearest_centroid(&p, 1.0, ¢roids, k, nz!(D)) }; + // SAFETY: same preconditions. + let (b, _) = unsafe { nearest_centroid(&p, 0.123, ¢roids, k, nz!(D)) }; + assert_eq!(a, b, "inv_norm must not change the selected centroid"); + } + } + + #[test] + fn cluster_mixed_zero_norm_rows() { + // Some all-zero rows exercise the inv_norm == 0 path in accumulation + // and the squared_chord_distance == 0 return. + let n = 120; + let mut data = vec![0.0_f32; n * 8]; + let mut rng = Xoshiro256PlusPlus::seed_from_u64(7); + for (i, row) in data.chunks_exact_mut(8).enumerate() { + if i % 10 == 0 { + continue; // leave all-zero + } + for v in row.iter_mut() { + *v = rng.random_range(-1.0..1.0); + } + } + let result = cluster(&data, dim(8), &Config::for_k_with_seed(5, 1)); + + assert!(result.centroids.iter().all(|v| v.is_finite())); + assert!(result.labels.iter().all(|&l| l < 5)); + } + + /// Sequential reference for [`accumulate_clusters`]: one pass over the + /// points in ascending index order, adding each to its cluster's sum + /// with the same kernels. Bit-identical to the parallel version by + /// construction if (and only if) the grouping preserves per-cluster + /// ascending order, which is exactly the property under test. + fn accumulate_reference( + points: &[f32], + labels: &[u16], + inv_norms: Option<&[f32]>, + k: usize, + d: usize, + ) -> (Vec, Vec) { + let mut sums = vec![0.0_f32; k * d]; + let mut counts = vec![0_usize; k]; + + for (index, (point, &label)) in points.chunks_exact(d).zip(labels).enumerate() { + let cluster = usize::from(label); + counts[cluster] += 1; + + let inv_norm = inv_norms.map_or_else( + || { + // SAFETY: `point` has length `d`, a multiple of 8. + let norm = unsafe { kernel::dot(point, point) }.sqrt(); + + if norm > 0.0 { norm.recip() } else { 0.0 } + }, + |inv_norms| inv_norms[index], + ); + + if inv_norm == 0.0 { + continue; + } + + // SAFETY: sum rows and `point` have length `d`, a multiple of 8. + unsafe { + kernel::add_scaled_into(&mut sums[cluster * d..(cluster + 1) * d], point, inv_norm); + } + } + + (sums, counts) + } + + #[test] + fn accumulate_clusters_matches_sequential_reference_bitwise() { + // Half the points land in cluster 0 (skew), the rest spread over + // 1..=5; cluster 6 stays empty. + const PATTERN: [u16; 10] = [0, 1, 0, 2, 0, 3, 0, 4, 0, 5]; + let n = 203; + let d = 32; + let k = 7_usize; + + let mut rng = Xoshiro256PlusPlus::seed_from_u64(31); + let mut points = vec![0.0_f32; n * d]; + for (i, row) in points.chunks_exact_mut(d).enumerate() { + if i % 17 == 0 { + continue; // leave all-zero: counted, but contributes nothing + } + for v in row.iter_mut() { + *v = rng.random_range(-1.0..1.0); + } + } + + let labels: Vec = (0..n).map(|i| PATTERN[i % PATTERN.len()]).collect(); + + let inv_norms: Vec = points + .chunks_exact(d) + .map(|row| { + let norm = l2(row); + if norm > 0.0 { norm.recip() } else { 0.0 } + }) + .collect(); + + for inv in [None, Some(inv_norms.as_slice())] { + // Sentinels: the accumulator must overwrite all of its outputs. + let mut sums = vec![f32::NAN; k * d]; + let mut counts = vec![usize::MAX; k]; + let mut order = vec![usize::MAX; n]; + let mut bounds = vec![usize::MAX; k + 1]; + + let mut scratch = Scratch { + sums: &mut sums, + counts: &mut counts, + order: &mut order, + bounds: &mut bounds, + }; + + // SAFETY: `points` is `n * d` floats with `n` labels, all labels + // are drawn from `PATTERN` and below `k == 7`, sums are `k * d` + // with `k` counts, inv norms (when provided) have one entry per + // label, order has `n` entries, bounds has `k + 1`, and + // `d == 32` is a multiple of 8. + unsafe { + accumulate_clusters(&points, &labels, inv, &mut scratch, nz!(32)); + } + + let (expected_sums, expected_counts) = + accumulate_reference(&points, &labels, inv, k, d); + + assert_eq!(counts, expected_counts); + assert_eq!(counts[6], 0, "cluster 6 must stay empty"); + assert_eq!(counts.iter().sum::(), n, "every point counted"); + + for (i, (sum, expected)) in sums.iter().zip(&expected_sums).enumerate() { + assert_eq!( + sum.to_bits(), + expected.to_bits(), + "sums diverge at element {i}: {sum} vs {expected}" + ); + } + } + } + + /// The determinism contract: identical bits regardless of pool width. + /// Guards the accumulate/assignment structure against rewrites whose + /// float reduction order depends on rayon's scheduling. + #[test] + fn cluster_bitwise_identical_across_pool_sizes() { + let (data, _) = make_blobs::<8>(40, 4, 2024); + let config = Config::for_k_with_seed(4, 7); + + let single = rayon::ThreadPoolBuilder::new() + .num_threads(1) + .build() + .expect("single-thread pool should build") + .install(|| cluster(&data, dim(8), &config)); + let multi = rayon::ThreadPoolBuilder::new() + .num_threads(8) + .build() + .expect("multi-thread pool should build") + .install(|| cluster(&data, dim(8), &config)); + + assert_eq!(single.labels, multi.labels); + assert_eq!(single.centroids, multi.centroids); + } +} diff --git a/libs/@local/graph/embeddings/src/dimension.rs b/libs/@local/graph/embeddings/src/dimension.rs new file mode 100644 index 00000000000..cf4f021ea82 --- /dev/null +++ b/libs/@local/graph/embeddings/src/dimension.rs @@ -0,0 +1,52 @@ +use core::{fmt, fmt::Display, num::NonZero}; + +/// An embedding vector dimension, guaranteed to be a positive multiple of 8. +/// +/// The multiple-of-8 invariant ensures that the dimension evenly divides into +/// SIMD lanes (8×f32 = `f32x8`), so vectorized kernels can operate without +/// remainder handling. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Dimension(NonZero); + +impl Dimension { + /// Creates a new dimension if `value` is non-zero and a multiple of 8. + /// + /// Returns [`None`] otherwise. + #[must_use] + pub const fn new(value: u16) -> Option { + // not using `?` here because it isn't `const` + let Some(value) = NonZero::new(value) else { + return None; + }; + + if !value.get().is_multiple_of(8) { + return None; + } + + Some(Self(value)) + } + + /// The raw dimension value. + #[must_use] + pub const fn get(self) -> u16 { + self.0.get() + } + + /// The raw dimension value as a [`NonZero`]. + #[must_use] + pub const fn value(self) -> NonZero { + self.0 + } +} + +impl Display for Dimension { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + Display::fmt(&self.get(), f) + } +} + +pub const D128: Dimension = Dimension(NonZero::new(128).unwrap()); +pub const D256: Dimension = Dimension(NonZero::new(256).unwrap()); +pub const D512: Dimension = Dimension(NonZero::new(512).unwrap()); +pub const D1536: Dimension = Dimension(NonZero::new(1536).unwrap()); +pub const D3072: Dimension = Dimension(NonZero::new(3072).unwrap()); diff --git a/libs/@local/graph/embeddings/src/kernel.rs b/libs/@local/graph/embeddings/src/kernel.rs new file mode 100644 index 00000000000..13040e483d6 --- /dev/null +++ b/libs/@local/graph/embeddings/src/kernel.rs @@ -0,0 +1,804 @@ +#![expect( + unsafe_code, + clippy::indexing_slicing, + clippy::float_arithmetic, + clippy::min_ident_chars, + reason = "Single-char idents (k, n, m, d, x) are standard mathematical notation for \ + clustering." +)] +#![expect( + clippy::inline_always, + reason = "while usually discouraged, SIMD operations need to be inlined, as otherwise we \ + spill SIMD registers, see the SIMD documentation." +)] + +use core::{ + num::NonZero, + simd::{Simd, f32x8, num::SimdFloat as _}, +}; + +/// Fused multiply-add when the target has native FMA, separate mul+add otherwise. +/// +/// On `aarch64`, FMA is part of the base NEON instruction set (`fmla`). +/// On `x86_64`, FMA requires the `fma` target feature (`vfmadd`); without it, +/// `StdFloat::mul_add` falls back to a per-lane `fmaf` libc call which +/// destroys throughput. The non-FMA path uses a plain multiply and add +/// (`vmulps` + `vaddps`) instead. +#[inline(always)] +#[cfg(not(any(target_arch = "aarch64", target_feature = "fma")))] +fn simd_mul_add(lhs: f32x8, rhs: f32x8, acc: f32x8) -> f32x8 { + lhs * rhs + acc +} + +/// See non-FMA variant above for rationale. +#[inline(always)] +#[cfg(any(target_arch = "aarch64", target_feature = "fma"))] +fn simd_mul_add(lhs: f32x8, rhs: f32x8, acc: f32x8) -> f32x8 { + use std::simd::StdFloat as _; + + lhs.mul_add(rhs, acc) +} + +/// Computes the dot product of two equal-length `f32` slices using SIMD. +/// +/// Four independent accumulators are interleaved to saturate FMA throughput: +/// each accumulator feeds a separate dependency chain, hiding the 4-cycle +/// latency of `fmla`/`vfmadd` on typical micro-architectures. +/// +/// # Safety +/// +/// * `lhs.len() == rhs.len()` +/// * Both lengths are multiples of 8. +#[inline] +#[must_use] +pub unsafe fn dot(lhs: &[f32], rhs: &[f32]) -> f32 { + debug_assert!(lhs.len().is_multiple_of(8) && lhs.len() == rhs.len()); + + // SAFETY: the caller guarantees equal lengths and a multiple of 8. + // These hints let the compiler elide bounds checks in `as_chunks` and + // the subsequent indexing without raw pointer arithmetic. + unsafe { + core::hint::assert_unchecked(lhs.len() == rhs.len()); + core::hint::assert_unchecked(lhs.len().is_multiple_of(8)); + } + + let (lhs, _) = lhs.as_chunks::<8>(); + let (rhs, _) = rhs.as_chunks::<8>(); + + // SAFETY: both original slices have the same length and that length is a + // multiple of 8, so `as_chunks::<8>` produces equal-length chunk slices + // with empty remainders. + unsafe { + core::hint::assert_unchecked(lhs.len() == rhs.len()); + } + + let mut s0 = f32x8::splat(0.0); + let mut s1 = f32x8::splat(0.0); + let mut s2 = f32x8::splat(0.0); + let mut s3 = f32x8::splat(0.0); + + // Unrolled loop: process 4 chunks (32 floats) per iteration. + let mut offset = 0; + while offset + 4 <= lhs.len() { + let Some([l0, l1, l2, l3]) = lhs[offset..offset + 4].as_array() else { + unreachable!() + }; + let Some([r0, r1, r2, r3]) = rhs[offset..offset + 4].as_array() else { + unreachable!() + }; + + s0 = simd_mul_add(Simd::from_slice(l0), Simd::from_slice(r0), s0); + s1 = simd_mul_add(Simd::from_slice(l1), Simd::from_slice(r1), s1); + s2 = simd_mul_add(Simd::from_slice(l2), Simd::from_slice(r2), s2); + s3 = simd_mul_add(Simd::from_slice(l3), Simd::from_slice(r3), s3); + + offset += 4; + } + + // Tail: process remaining 0..3 chunks one at a time. + #[expect(clippy::min_ident_chars)] + while offset < lhs.len() { + let l = &lhs[offset]; + let r = &rhs[offset]; + + s0 = simd_mul_add(Simd::from_slice(l), Simd::from_slice(r), s0); + offset += 1; + } + + (s0 + s1 + s2 + s3).reduce_sum() +} + +/// Scales `value` in-place by `factor`. +/// +/// # Safety +/// +/// * `value.len()` is a multiple of 8. +#[inline] +pub(crate) unsafe fn scale(value: &mut [f32], factor: f32) { + debug_assert!(value.len().is_multiple_of(8)); + + // SAFETY: the caller guarantees a multiple of 8. + unsafe { + core::hint::assert_unchecked(value.len().is_multiple_of(8)); + } + + let factor = f32x8::splat(factor); + let (dst, _) = value.as_chunks_mut::<8>(); + + for dst in dst { + *dst = (f32x8::from_slice(dst) * factor).to_array(); + } +} + +/// Accumulates `src * factor` element-wise into `dst` (`dst += src * factor`). +/// +/// Fuses a scale and add into a single pass, using FMA where available. +/// Avoids the need for a scratch buffer when accumulating normalized vectors. +/// +/// # Safety +/// +/// * `dst.len() == src.len()` +/// * Both lengths are multiples of 8. +#[inline] +pub unsafe fn add_scaled_into(dst: &mut [f32], src: &[f32], factor: f32) { + debug_assert!(dst.len().is_multiple_of(8) && dst.len() == src.len()); + + // SAFETY: the caller guarantees equal lengths and a multiple of 8. + unsafe { + core::hint::assert_unchecked(dst.len() == src.len()); + core::hint::assert_unchecked(dst.len().is_multiple_of(8)); + } + + let factor = f32x8::splat(factor); + let (dst, _) = dst.as_chunks_mut::<8>(); + let (src, _) = src.as_chunks::<8>(); + + // SAFETY: same reasoning as the pre-chunk hints: equal input lengths + // that are multiples of 8 produce equal chunk counts. + unsafe { core::hint::assert_unchecked(dst.len() == src.len()) } + + for index in 0..dst.len() { + let acc = f32x8::from_slice(&dst[index]); + let val = f32x8::from_slice(&src[index]); + dst[index] = simd_mul_add(val, factor, acc).to_array(); + } +} + +/// Normalizes `value` to unit length in-place. +/// +/// If the vector has zero norm, it is left unchanged. +/// +/// # Safety +/// +/// * `value.len()` is a multiple of 8. +#[inline] +pub(crate) unsafe fn normalize(value: &mut [f32]) { + // SAFETY: `dot` requires equal lengths (trivially true, same slice) + // and a multiple of 8 (guaranteed by the caller). + let norm = unsafe { dot(value, value).sqrt() }; + + if norm > 0.0 { + let factor = 1.0 / norm; + // SAFETY: same slice, same length guarantee. + unsafe { + scale(value, factor); + } + } +} + +/// 4 points x 2 centroids. Eight independent accumulators give ILP 8 (enough to +/// saturate FMA throughput); each point chunk feeds 2 FMAs and each centroid +/// chunk feeds 4. Returns `dot[point][centroid]`. +/// +/// Register budget: 8 `f32x8` accumulators. On AVX2 (16 ymm) this leaves room +/// for the 6 operand loads. On NEON each `f32x8` is two 128-bit regs, so the 8 +/// accumulators take 16 of 32 registers; a 4x4 tile (16 accumulators) also fits +/// there if you want more centroid reuse. Either way, check the asm shows the +/// accumulators staying in registers with no stack spills, and that +/// `simd_mul_add` lowered to `vfmadd`/`fmla` and not a `fmaf` call. If the array +/// form ever spills, the manual unroll below is what keeps them in registers. +/// +/// # Safety +/// * all six slices have length `d` +/// * `d` is a multiple of 8 +#[inline(always)] // micro-kernel must inline nearest4 to keep accumulators in registers +#[must_use] +pub unsafe fn micro_4x2( + p0: &[f32], + p1: &[f32], + p2: &[f32], + p3: &[f32], + c0: &[f32], + c1: &[f32], +) -> [[f32; 2]; 4] { + debug_assert!(p0.len().is_multiple_of(8)); + debug_assert!( + [p1.len(), p2.len(), p3.len(), c0.len(), c1.len()] + .iter() + .all(|&l| l == p0.len()) + ); + + let (p0, _) = p0.as_chunks::<8>(); + let (p1, _) = p1.as_chunks::<8>(); + let (p2, _) = p2.as_chunks::<8>(); + let (p3, _) = p3.as_chunks::<8>(); + let (c0, _) = c0.as_chunks::<8>(); + let (c1, _) = c1.as_chunks::<8>(); + + // SAFETY: the caller guarantees all six slices have equal length `d`, + // and `d` is a multiple of 8. The hints let the compiler prove that + // `as_chunks` produces equal-length chunk slices. + unsafe { + core::hint::assert_unchecked(p0.len() == p1.len()); + core::hint::assert_unchecked(p0.len() == p2.len()); + core::hint::assert_unchecked(p0.len() == p3.len()); + core::hint::assert_unchecked(p0.len() == c0.len()); + core::hint::assert_unchecked(p0.len() == c1.len()); + } + + let mut a00 = f32x8::splat(0.0); + let mut a01 = f32x8::splat(0.0); + let mut a10 = f32x8::splat(0.0); + let mut a11 = f32x8::splat(0.0); + let mut a20 = f32x8::splat(0.0); + let mut a21 = f32x8::splat(0.0); + let mut a30 = f32x8::splat(0.0); + let mut a31 = f32x8::splat(0.0); + + for t in 0..c0.len() { + let v0 = Simd::from_array(c0[t]); + let v1 = Simd::from_array(c1[t]); + let x0 = Simd::from_array(p0[t]); + let x1 = Simd::from_array(p1[t]); + let x2 = Simd::from_array(p2[t]); + let x3 = Simd::from_array(p3[t]); + + // super::simd_mul_add picks the FMA arm per target. + a00 = simd_mul_add(x0, v0, a00); + a01 = simd_mul_add(x0, v1, a01); + a10 = simd_mul_add(x1, v0, a10); + a11 = simd_mul_add(x1, v1, a11); + a20 = simd_mul_add(x2, v0, a20); + a21 = simd_mul_add(x2, v1, a21); + a30 = simd_mul_add(x3, v0, a30); + a31 = simd_mul_add(x3, v1, a31); + } + + [ + [a00.reduce_sum(), a01.reduce_sum()], + [a10.reduce_sum(), a11.reduce_sum()], + [a20.reduce_sum(), a21.reduce_sum()], + [a30.reduce_sum(), a31.reduce_sum()], + ] +} + +/// 4 points x 1 centroid: the odd-`k` remainder of [`nearest4`]. Four +/// independent accumulators (one per point) share each centroid load, so the +/// centroid streams through registers once instead of once per point. +/// +/// # Safety +/// +/// * all five slices have length `d` +/// * `d` is a multiple of 8 +#[inline(always)] // micro-kernel must inline into nearest4 to keep accumulators in registers +pub(crate) unsafe fn micro_4x1( + p0: &[f32], + p1: &[f32], + p2: &[f32], + p3: &[f32], + c: &[f32], +) -> [f32; 4] { + debug_assert!(p0.len().is_multiple_of(8)); + debug_assert!( + [p1.len(), p2.len(), p3.len(), c.len()] + .iter() + .all(|&l| l == p0.len()) + ); + + let (p0, _) = p0.as_chunks::<8>(); + let (p1, _) = p1.as_chunks::<8>(); + let (p2, _) = p2.as_chunks::<8>(); + let (p3, _) = p3.as_chunks::<8>(); + let (c, _) = c.as_chunks::<8>(); + + // SAFETY: the caller guarantees all five slices have equal length `d`, + // and `d` is a multiple of 8. The hints let the compiler prove that + // `as_chunks` produces equal-length chunk slices. + unsafe { + core::hint::assert_unchecked(p0.len() == p1.len()); + core::hint::assert_unchecked(p0.len() == p2.len()); + core::hint::assert_unchecked(p0.len() == p3.len()); + core::hint::assert_unchecked(p0.len() == c.len()); + } + + let mut a0 = f32x8::splat(0.0); + let mut a1 = f32x8::splat(0.0); + let mut a2 = f32x8::splat(0.0); + let mut a3 = f32x8::splat(0.0); + + for t in 0..c.len() { + let v = Simd::from_array(c[t]); + + a0 = simd_mul_add(Simd::from_array(p0[t]), v, a0); + a1 = simd_mul_add(Simd::from_array(p1[t]), v, a1); + a2 = simd_mul_add(Simd::from_array(p2[t]), v, a2); + a3 = simd_mul_add(Simd::from_array(p3[t]), v, a3); + } + + [ + a0.reduce_sum(), + a1.reduce_sum(), + a2.reduce_sum(), + a3.reduce_sum(), + ] +} + +/// Finds the nearest centroid for 4 points simultaneously using the +/// [`micro_4x2`] tiled kernel. +/// +/// Returns `(centroid_index, raw_dot_product)` for each of the 4 points. +/// The raw dot product is **not** a distance; and must be converted via +/// using the chord distance formula. +/// +/// # Safety +/// +/// * All four point slices have length `d`. +/// * `centroids.len() >= k * d`. +/// * `d` is a multiple of 8. +#[inline] +#[must_use] +pub unsafe fn nearest4( + p0: &[f32], + p1: &[f32], + p2: &[f32], + p3: &[f32], + centroids: &[f32], + k: NonZero, + d: NonZero, +) -> [(u16, f32); 4] { + let d = d.get(); + let k = k.get(); + + let mut best_dot = [f32::NEG_INFINITY; 4]; + let mut best_idx = [0_u16; 4]; + + // SAFETY: the caller guarantees these preconditions. + unsafe { + core::hint::assert_unchecked(p0.len() == d); + core::hint::assert_unchecked(p0.len() == p1.len()); + core::hint::assert_unchecked(p0.len() == p2.len()); + core::hint::assert_unchecked(p0.len() == p3.len()); + core::hint::assert_unchecked(centroids.len() >= k * d); + core::hint::assert_unchecked(d.is_multiple_of(8)); + } + + let mut j = 0; + while j + 2 <= k { + // SAFETY: `j + 2 <= k` and `centroids.len() >= k * d`, so both + // slices `[j*d .. (j+2)*d]` are in-bounds. + let c0 = unsafe { centroids.get_unchecked(j * d..j * d + d) }; + // SAFETY: see above. + let c1 = unsafe { centroids.get_unchecked((j + 1) * d..(j + 1) * d + d) }; + + // SAFETY: all six slices have length `d`, a multiple of 8. + let dots = unsafe { micro_4x2(p0, p1, p2, p3, c0, c1) }; + + #[expect( + clippy::cast_possible_truncation, + reason = "k originates from Config::k (u16), so j < k fits in u16" + )] + for m in 0..4 { + if dots[m][0] > best_dot[m] { + best_dot[m] = dots[m][0]; + best_idx[m] = j as u16; + } + if dots[m][1] > best_dot[m] { + best_dot[m] = dots[m][1]; + best_idx[m] = (j + 1) as u16; + } + } + j += 2; + } + + // Handle odd k: one remaining centroid via the 4x1 tile. + if j < k { + let c = ¢roids[j * d..j * d + d]; + // SAFETY: all five slices have length `d`, a multiple of 8. + let dots = unsafe { micro_4x1(p0, p1, p2, p3, c) }; + + #[expect( + clippy::cast_possible_truncation, + reason = "k originates from Config::k (u16)" + )] + for m in 0..4 { + if dots[m] > best_dot[m] { + best_dot[m] = dots[m]; + best_idx[m] = j as u16; + } + } + } + + [ + (best_idx[0], best_dot[0]), + (best_idx[1], best_dot[1]), + (best_idx[2], best_dot[2]), + (best_idx[3], best_dot[3]), + ] +} + +#[cfg(test)] +mod tests { + #![expect(clippy::float_cmp, clippy::integer_division_remainder_used)] + + use super::*; + + macro_rules! nz { + ($expr:expr) => { + const { NonZero::new($expr).unwrap() } + }; + } + + /// Scalar dot product for reference. + fn ref_dot(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).map(|(x, y)| x * y).sum() + } + + /// Scalar normalize for reference. + fn ref_normalize(v: &mut [f32]) { + let norm = v.iter().map(|x| x * x).sum::().sqrt(); + if norm > 0.0 { + for x in v { + *x /= norm; + } + } + } + + /// Deterministic test vector: entry `i` gets `(i+1) * scale`. + #[expect(clippy::cast_precision_loss)] + fn ramp(len: usize, factor: f32) -> Vec { + (0..len).map(|i| (i + 1) as f32 * factor).collect() + } + + /// Asserts two f32 values are within relative tolerance, with an absolute + /// floor for values near zero. + fn assert_close(a: f32, b: f32, tol: f32) { + let diff = (a - b).abs(); + let denom = a.abs().max(b.abs()).max(1e-12); + assert!( + diff / denom < tol, + "values differ: {a} vs {b} (diff={diff}, rel={})", + diff / denom + ); + } + + #[test] + fn dot_matches_scalar_d8() { + let a = ramp(8, 1.0); + let b = ramp(8, 0.5); + let expected = ref_dot(&a, &b); + // SAFETY: both slices have length 8, a multiple of 8. + let got = unsafe { dot(&a, &b) }; + assert_close(got, expected, 1e-6); + } + + #[test] + fn dot_matches_scalar_d24() { + // 24 = 3 chunks of 8: the 4-unrolled body runs 0 iterations, + // all 3 chunks go through the tail path. + let a = ramp(24, 0.1); + let b = ramp(24, -0.2); + let expected = ref_dot(&a, &b); + // SAFETY: both slices have length 24, a multiple of 8. + let got = unsafe { dot(&a, &b) }; + assert_close(got, expected, 1e-5); + } + + #[test] + fn dot_matches_scalar_d3072() { + let a = ramp(3072, 0.001); + let b = ramp(3072, -0.002); + let expected = ref_dot(&a, &b); + // SAFETY: both slices have length 3072, a multiple of 8. + let got = unsafe { dot(&a, &b) }; + assert_close(got, expected, 1e-4); + } + + #[test] + fn dot_is_commutative() { + let a = ramp(32, 0.3); + let b = ramp(32, -0.7); + // SAFETY: both slices have length 32, a multiple of 8. + let ab = unsafe { dot(&a, &b) }; + // SAFETY: same slices, reversed. + let ba = unsafe { dot(&b, &a) }; + assert_eq!(ab, ba); + } + + #[test] + fn dot_self_is_squared_norm() { + let a = ramp(16, 0.5); + let expected: f32 = a.iter().map(|x| x * x).sum(); + // SAFETY: both arguments are the same 16-element slice. + let got = unsafe { dot(&a, &a) }; + assert_close(got, expected, 1e-6); + } + + #[test] + fn dot_orthogonal_is_zero() { + let mut a = vec![0.0_f32; 8]; + let mut b = vec![0.0_f32; 8]; + a[0] = 1.0; + b[1] = 1.0; + // SAFETY: both slices have length 8. + let got = unsafe { dot(&a, &b) }; + assert_eq!(got, 0.0); + } + + #[test] + fn scale_matches_scalar() { + let mut v = ramp(16, 1.0); + let factor = -0.5; + let expected: Vec = v.iter().map(|x| x * factor).collect(); + // SAFETY: slice has length 16, a multiple of 8. + unsafe { scale(&mut v, factor) } + assert_eq!(v, expected); + } + + #[test] + fn add_scaled_into_matches_separate_ops() { + let src = ramp(16, 1.0); + let factor = 0.3; + let mut dst = ramp(16, 0.5); + let expected: Vec = dst.iter().zip(&src).map(|(d, s)| d + s * factor).collect(); + + // SAFETY: both slices have length 16, a multiple of 8. + unsafe { add_scaled_into(&mut dst, &src, factor) } + + for (&got, &exp) in dst.iter().zip(&expected) { + assert_close(got, exp, 1e-6); + } + } + + #[test] + fn add_scaled_into_factor_zero_is_identity() { + let src = ramp(8, 100.0); + let mut dst = ramp(8, 1.0); + let original = dst.clone(); + // SAFETY: both slices have length 8, a multiple of 8. + unsafe { add_scaled_into(&mut dst, &src, 0.0) } + assert_eq!(dst, original); + } + + #[test] + fn normalize_produces_unit_norm() { + let mut v = ramp(32, 0.7); + // SAFETY: length 32, a multiple of 8. + unsafe { normalize(&mut v) } + // SAFETY: same slice, length unchanged. + let norm = unsafe { dot(&v, &v).sqrt() }; + assert_close(norm, 1.0, 1e-6); + } + + #[test] + fn normalize_preserves_direction() { + let mut v = ramp(16, 2.0); + let mut ref_v = v.clone(); + ref_normalize(&mut ref_v); + // SAFETY: length 16, a multiple of 8. + unsafe { normalize(&mut v) } + for (&a, &b) in v.iter().zip(&ref_v) { + assert_close(a, b, 1e-6); + } + } + + #[test] + fn normalize_zero_vector_unchanged() { + let mut v = vec![0.0_f32; 8]; + // SAFETY: length 8, a multiple of 8. + unsafe { normalize(&mut v) } + assert!(v.iter().all(|&x| x == 0.0)); + } + + #[test] + fn normalize_already_unit_is_stable() { + let mut v = vec![0.0_f32; 8]; + v[0] = 1.0; + // SAFETY: length 8, a multiple of 8. + unsafe { normalize(&mut v) } + assert_close(v[0], 1.0, 1e-7); + assert!(v[1..].iter().all(|&x| x == 0.0)); + } + + #[test] + fn micro_4x2_matches_individual_dots() { + let d = 16; + let p0 = ramp(d, 0.1); + let p1 = ramp(d, -0.2); + let p2 = ramp(d, 0.3); + let p3 = ramp(d, -0.4); + let c0 = ramp(d, 0.5); + let c1 = ramp(d, -0.6); + + // SAFETY: all 6 slices have length 16, a multiple of 8. + let got = unsafe { micro_4x2(&p0, &p1, &p2, &p3, &c0, &c1) }; + + let expected = [ + [ref_dot(&p0, &c0), ref_dot(&p0, &c1)], + [ref_dot(&p1, &c0), ref_dot(&p1, &c1)], + [ref_dot(&p2, &c0), ref_dot(&p2, &c1)], + [ref_dot(&p3, &c0), ref_dot(&p3, &c1)], + ]; + + for (g, e) in got.iter().zip(&expected) { + assert_close(g[0], e[0], 1e-5); + assert_close(g[1], e[1], 1e-5); + } + } + + #[test] + fn micro_4x2_d3072() { + let d = 3072; + let p0 = ramp(d, 0.001); + let p1 = ramp(d, -0.001); + let p2 = ramp(d, 0.002); + let p3 = ramp(d, -0.002); + let c0 = ramp(d, 0.001); + let c1 = ramp(d, -0.001); + + // SAFETY: all 6 slices have length 3072, a multiple of 8. + let got = unsafe { micro_4x2(&p0, &p1, &p2, &p3, &c0, &c1) }; + + let expected = [ + [ref_dot(&p0, &c0), ref_dot(&p0, &c1)], + [ref_dot(&p1, &c0), ref_dot(&p1, &c1)], + [ref_dot(&p2, &c0), ref_dot(&p2, &c1)], + [ref_dot(&p3, &c0), ref_dot(&p3, &c1)], + ]; + + for (g, e) in got.iter().zip(&expected) { + assert_close(g[0], e[0], 1e-3); + assert_close(g[1], e[1], 1e-3); + } + } + + #[test] + fn micro_4x1_matches_individual_dots() { + let d = 16; + let p0 = ramp(d, 0.1); + let p1 = ramp(d, -0.2); + let p2 = ramp(d, 0.3); + let p3 = ramp(d, -0.4); + let c = ramp(d, 0.5); + + // SAFETY: all 5 slices have length 16, a multiple of 8. + let got = unsafe { micro_4x1(&p0, &p1, &p2, &p3, &c) }; + + let expected = [ + ref_dot(&p0, &c), + ref_dot(&p1, &c), + ref_dot(&p2, &c), + ref_dot(&p3, &c), + ]; + + for (g, e) in got.iter().zip(&expected) { + assert_close(*g, *e, 1e-5); + } + } + + #[test] + fn micro_4x1_d3072() { + let d = 3072; + let p0 = ramp(d, 0.001); + let p1 = ramp(d, -0.001); + let p2 = ramp(d, 0.002); + let p3 = ramp(d, -0.002); + let c = ramp(d, 0.001); + + // SAFETY: all 5 slices have length 3072, a multiple of 8. + let got = unsafe { micro_4x1(&p0, &p1, &p2, &p3, &c) }; + + let expected = [ + ref_dot(&p0, &c), + ref_dot(&p1, &c), + ref_dot(&p2, &c), + ref_dot(&p3, &c), + ]; + + for (g, e) in got.iter().zip(&expected) { + assert_close(*g, *e, 1e-3); + } + } + + #[test] + fn nearest4_matches_brute_force_even_k() { + let d = 8; + let k = nz!(4); + + // 4 centroids: axis-aligned unit vectors. + let mut centroids = vec![0.0_f32; k.get() * d]; + for i in 0..k.get() { + centroids[i * d + i] = 1.0; + } + + // 4 points, each close to a different centroid. + let mut points: [Vec; 4] = core::array::from_fn(|_| vec![0.0_f32; d]); + for i in 0..4 { + points[i][i] = 10.0; + points[i][(i + 1) % d] = 0.1; + } + + // SAFETY: d=8 (multiple of 8), k=4 > 0, centroids has length k*d, + // all point slices have length d. + let got = unsafe { + nearest4( + &points[0], + &points[1], + &points[2], + &points[3], + ¢roids, + k, + nz!(8), + ) + }; + + assert_eq!(got[0].0, 0); + assert_eq!(got[1].0, 1); + assert_eq!(got[2].0, 2); + assert_eq!(got[3].0, 3); + } + + #[test] + fn nearest4_matches_brute_force_odd_k() { + let d = 8; + let k = nz!(3); // odd: exercises the remainder path + + let mut centroids = vec![0.0_f32; k.get() * d]; + for i in 0..k.get() { + centroids[i * d + i] = 1.0; + } + + let mut points: [Vec; 4] = core::array::from_fn(|_| vec![0.0_f32; d]); + points[0][0] = 5.0; + points[1][1] = 5.0; + points[2][2] = 5.0; + points[3][0] = 3.0; // closest to centroid 0 + + // SAFETY: d=8 (multiple of 8), k=3 > 0, centroids has length k*d, + // all point slices have length d. + let got = unsafe { + nearest4( + &points[0], + &points[1], + &points[2], + &points[3], + ¢roids, + k, + nz!(8), + ) + }; + + assert_eq!(got[0].0, 0); + assert_eq!(got[1].0, 1); + assert_eq!(got[2].0, 2); + assert_eq!(got[3].0, 0); + } + + #[test] + fn nearest4_k1_all_same() { + let d = 8; + let centroids = ramp(d, 1.0); + let p0 = ramp(d, 0.1); + let p1 = ramp(d, -0.2); + let p2 = ramp(d, 0.3); + let p3 = ramp(d, -0.4); + + // SAFETY: d=8 (multiple of 8), k=1 > 0, centroids has length d, + // all point slices have length d. + let got = unsafe { nearest4(&p0, &p1, &p2, &p3, ¢roids, nz!(1), nz!(8)) }; + + assert_eq!(got[0].0, 0); + assert_eq!(got[1].0, 0); + assert_eq!(got[2].0, 0); + assert_eq!(got[3].0, 0); + } +} diff --git a/libs/@local/graph/embeddings/src/lib.rs b/libs/@local/graph/embeddings/src/lib.rs index a5e819a016f..0351f0a0f81 100644 --- a/libs/@local/graph/embeddings/src/lib.rs +++ b/libs/@local/graph/embeddings/src/lib.rs @@ -4,18 +4,33 @@ //! //! ## Workspace dependencies #![cfg_attr(doc, doc = simple_mermaid::mermaid!("../docs/dependency-diagram.mmd"))] +#![feature( + // Library Features + portable_simd, + integer_widen_truncate +)] -pub use self::{ - error::EmbeddingError, - openai::{OpenAiEmbeddingClient, OpenAiEmbeddingClientConfig}, -}; +extern crate alloc; mod error; mod openai; +pub mod clustering; +mod dimension; +// Hidden from docs: the kernel is an implementation detail, exposed only so +// the `embedding` bench target can measure it in isolation. +#[doc(hidden)] +pub mod kernel; + use error_stack::Report; use hash_graph_types::Embedding; +pub use self::{ + dimension::{D128, D256, D512, D1536, D3072, Dimension}, + error::EmbeddingError, + openai::{OpenAiEmbeddingClient, OpenAiEmbeddingClientConfig}, +}; + /// Generates embedding vectors for text inputs. /// /// Implementations call out to an embedding provider (e.g. OpenAI). The generated embeddings are diff --git a/libs/@local/graph/postgres-store/Cargo.toml b/libs/@local/graph/postgres-store/Cargo.toml index a96ddd34dc6..af076b8ab31 100644 --- a/libs/@local/graph/postgres-store/Cargo.toml +++ b/libs/@local/graph/postgres-store/Cargo.toml @@ -25,6 +25,7 @@ tokio-postgres = { workspace = true, public = true } # Private workspace dependencies error-stack = { workspace = true, features = ["std", "serde", "unstable"] } hash-codec = { workspace = true, features = ["numeric", "postgres"] } +hash-graph-embeddings = { workspace = true } hash-graph-temporal-versioning = { workspace = true, features = ["postgres"] } hash-graph-types = { workspace = true, features = ["postgres"] } hash-status = { workspace = true } @@ -39,6 +40,7 @@ derive_more = { workspace = true } dotenv-flow = { workspace = true } futures = { workspace = true } postgres-types = { workspace = true, features = ["derive", "with-serde_json-1"] } +rayon = { workspace = true } refinery = { workspace = true, features = ["tokio-postgres"] } regex = { workspace = true } semver = { workspace = true, features = ["serde"] } diff --git a/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd b/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd index 84ed6a1efa0..eaa2eff809a 100644 --- a/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd +++ b/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd @@ -14,61 +14,72 @@ graph TD 3[hash-codegen] 4[hash-graph-api] 5[hash-graph-authorization] - 6[hash-graph-migrations] - 7[hash-graph-migrations-macros] - 8[hash-graph-postgres-store] - class 8 root - 9[hash-graph-store] - 10[hash-graph-temporal-versioning] - 11[hash-graph-types] - 12[hash-graph-validation] - 13[harpc-types] - 14[harpc-wire-protocol] - 15[hashql-ast] - 16[hashql-compiletest] - 17[hashql-eval] - 18[hashql-hir] - 19[hashql-mir] - 20[hashql-syntax-jexpr] - 21[hash-status] - 22[hash-telemetry] - 23[hash-temporal-client] - 24[error-stack] - 25[hash-graph-benches] - 26[hash-graph-integration] - 27[hash-graph-test-data] + 6[hash-graph-embeddings] + 7[hash-graph-migrations] + 8[hash-graph-migrations-macros] + 9[hash-graph-postgres-store] + class 9 root + 10[hash-graph-store] + 11[hash-graph-temporal-versioning] + 12[hash-graph-types] + 13[hash-graph-validation] + 14[harpc-types] + 15[harpc-wire-protocol] + 16[hashql-ast] + 17[hashql-compiletest] + 18[hashql-eval] + 19[hashql-hir] + 20[hashql-mir] + 21[hashql-syntax-jexpr] + 22[hash-status] + 23[hash-telemetry] + 24[hash-temporal-client] + 25[darwin-kperf] + 26[darwin-kperf-criterion] + 27[darwin-kperf-events] + 28[darwin-kperf-sys] + 29[error-stack] + 30[hash-graph-benches] + 31[hash-graph-integration] + 32[hash-graph-test-data] 0 --> 4 - 1 --> 10 - 1 -.-> 27 + 1 --> 11 + 1 -.-> 32 2 -.-> 3 - 2 --> 14 - 4 --> 17 - 4 --> 20 + 2 --> 15 + 4 --> 18 + 4 --> 21 5 --> 1 - 6 --> 7 - 6 --> 22 - 8 -.-> 6 - 8 --> 12 - 8 --> 21 - 9 --> 5 - 9 --> 11 - 9 --> 23 - 10 --> 2 - 11 -.-> 27 - 12 -.-> 27 - 14 -.-> 13 - 14 --> 13 - 14 --> 24 - 15 -.-> 16 - 16 --> 17 - 16 --> 20 - 17 --> 8 - 17 --> 19 - 18 -.-> 16 - 19 --> 18 - 20 --> 15 - 22 --> 24 - 23 --> 1 - 25 -.-> 4 - 26 -.-> 8 - 27 --> 9 + 6 --> 12 + 6 -.-> 26 + 7 --> 8 + 7 --> 23 + 9 --> 6 + 9 -.-> 7 + 9 --> 13 + 9 --> 22 + 10 --> 5 + 10 --> 12 + 10 --> 24 + 11 --> 2 + 12 -.-> 32 + 13 -.-> 32 + 15 -.-> 14 + 15 --> 14 + 15 --> 29 + 16 -.-> 17 + 17 --> 18 + 17 --> 21 + 18 --> 9 + 18 --> 20 + 19 -.-> 17 + 20 --> 19 + 21 --> 16 + 23 --> 29 + 24 --> 1 + 25 --> 27 + 25 --> 28 + 26 --> 25 + 30 -.-> 4 + 31 -.-> 9 + 32 --> 10 diff --git a/libs/@local/graph/postgres-store/package.json b/libs/@local/graph/postgres-store/package.json index 20b59a9c1a8..2ae76eade07 100644 --- a/libs/@local/graph/postgres-store/package.json +++ b/libs/@local/graph/postgres-store/package.json @@ -16,6 +16,7 @@ "@rust/error-stack": "workspace:*", "@rust/hash-codec": "workspace:*", "@rust/hash-graph-authorization": "workspace:*", + "@rust/hash-graph-embeddings": "workspace:*", "@rust/hash-graph-store": "workspace:*", "@rust/hash-graph-temporal-versioning": "workspace:*", "@rust/hash-graph-types": "workspace:*", diff --git a/libs/@local/graph/postgres-store/src/lib.rs b/libs/@local/graph/postgres-store/src/lib.rs index 4fc05125e87..7733674896f 100644 --- a/libs/@local/graph/postgres-store/src/lib.rs +++ b/libs/@local/graph/postgres-store/src/lib.rs @@ -9,7 +9,7 @@ // Library Features extend_one, - iter_intersperse, + iter_intersperse )] #![cfg_attr(not(miri), doc(test(attr(deny(warnings, clippy::all)))))] #![expect( diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index 3a41b84b8c7..d7d388b69a8 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -2,8 +2,9 @@ mod delete; mod query; mod read; mod summary; + use alloc::borrow::Cow; -use core::{borrow::Borrow as _, mem}; +use core::{any::Any, borrow::Borrow as _, fmt, mem}; use std::collections::{HashMap, HashSet}; use error_stack::{FutureExt as _, Report, ResultExt as _, TryReportStreamExt as _, ensure}; @@ -16,19 +17,23 @@ use hash_graph_authorization::policies::{ resource::{EntityResourceConstraint, ResourceConstraint}, store::{PolicyCreationParams, PrincipalStore as _}, }; +use hash_graph_embeddings::{Dimension, clustering::Clustering}; use hash_graph_store::{ entity::{ - CreateEntityParams, DeleteEntitiesParams, DeletionSummary, EmptyEntityTypes, - EntityPermissions, EntityQueryCursor, EntityQueryPath, EntityQuerySorting, EntityStore, - EntityTypeRetrieval, EntityTypesError, EntityValidationReport, EntityValidationType, - HasPermissionForEntitiesParams, PatchEntityParams, QueryConversion, QueryEntitiesParams, - QueryEntitiesResponse, QueryEntitySubgraphParams, QueryEntitySubgraphResponse, - SearchEntitiesFilter, SearchEntitiesParams, SearchEntitiesResponse, - SummarizeEntitiesParams, SummarizeEntitiesResponse, UpdateEntityEmbeddingsParams, - ValidateEntityComponents, ValidateEntityParams, + ClusterEntitiesParams, ClusterEntitiesResponse, CreateEntityParams, DeleteEntitiesParams, + DeletionSummary, EmptyEntityTypes, EntityCluster, EntityPermissions, EntityQueryCursor, + EntityQueryPath, EntityQuerySorting, EntityStore, EntityTypeRetrieval, EntityTypesError, + EntityValidationReport, EntityValidationType, HasPermissionForEntitiesParams, + PatchEntityParams, QueryConversion, QueryEntitiesParams, QueryEntitiesResponse, + QueryEntitySubgraphParams, QueryEntitySubgraphResponse, SearchEntitiesFilter, + SearchEntitiesParams, SearchEntitiesResponse, SummarizeEntitiesParams, + SummarizeEntitiesResponse, UpdateEntityEmbeddingsParams, ValidateEntityComponents, + ValidateEntityParams, }, entity_type::{EntityTypeStore as _, IncludeEntityTypeOption}, - error::{CheckPermissionError, DeletionError, InsertionError, QueryError, UpdateError}, + error::{ + CheckPermissionError, ClusterError, DeletionError, InsertionError, QueryError, UpdateError, + }, filter::{ Filter, FilterExpression, FilterExpressionList, Parameter, ParameterList, protection::transform_filter, @@ -54,12 +59,14 @@ use hash_graph_temporal_versioning::{ TransactionTime, }; use hash_graph_types::{ + Embedding, knowledge::property::visitor::EntityVisitor as _, ontology::{DataTypeLookup, OntologyTypeProvider}, }; use hash_graph_validation::{EntityPreprocessor, Validate as _}; use hash_status::StatusCode; use postgres_types::ToSql; +use tokio::sync::oneshot; use tokio_postgres::{GenericClient as _, error::SqlState}; use tracing::Instrument as _; use type_system::{ @@ -83,7 +90,7 @@ use type_system::{ entity_type::{ClosedEntityType, ClosedMultiEntityType, EntityTypeUuid}, id::{OntologyTypeUuid, VersionedUrl}, }, - principal::actor::ActorEntityUuid, + principal::{actor::ActorEntityUuid, actor_group::WebId}, }; use uuid::Uuid; @@ -108,6 +115,71 @@ use crate::store::{ validation::StoreProvider, }; +/// The panic that happened during a spawned task. +/// +/// Opaque to fulfil the `Sync` contract, which has the safety requirement that it must be sound for +/// `&JoinError`, to cross thread boundaries. By design, a `&JoinError` has no API whatsoever, +/// making it useless, thus harmless, thus memory safe. +/// +/// This use has precedent, see the nightly `SyncView`, the `SyncWrapper` inside tokio, and +/// `SyncWrapper` of the `sync_wrapper` crate. +struct JoinError(Box); + +// SAFETY: An immutable reference to a `JoinError` is useless, as the value can only be interacted +// with by getting the inner value. This mirrors the design of `SyncView`, see the rationale behind +// it. We choose to implement a custom wrapper instead, to be able to downcast, as long as the +// actual value hidden behind is `Sync`, making the wrapper a no-op, mirroring the internal +// `SyncWrapper` type of tokio, used for it's `JoinError`. +// See: https://github.com/tokio-rs/tokio/blob/c4c6265a0746a79d4a2f3852f726aa0101f29fd3/tokio/src/util/sync_wrapper.rs#L8 +// and: https://github.com/rust-lang/rust/blob/f10db292a3733b5c67c8da8c7661195ff4b05774/library/core/src/sync/sync_view.rs#L90 +#[expect(unsafe_code)] +unsafe impl Sync for JoinError {} + +impl JoinError { + // Adapted from: https://github.com/rust-lang/rust/blob/6c8138de8f1c96b2f66adbbc0e37c73525444750/library/std/src/panicking.rs#L779-L787 + fn message(&self) -> Option<&str> { + if let Some(value) = self.downcast_ref_sync::<&'static str>() { + return Some(*value); + } + + if let Some(value) = self.downcast_ref_sync::() { + return Some(&**value); + } + + None + } + + fn downcast_ref_sync(&self) -> Option<&T> { + // If the downcast fails, the inner value is not touched, so no thread-safety violation can + // occur. + self.0.downcast_ref() + } +} + +impl fmt::Debug for JoinError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = fmt.debug_tuple("JoinError"); + + if let Some(message) = self.message() { + return debug.field(&message).finish(); + } + + debug.finish_non_exhaustive() + } +} + +impl fmt::Display for JoinError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(message) = self.message() { + return write!(fmt, "task panicked with message: {message}"); + } + + fmt.write_str("task panicked") + } +} + +impl core::error::Error for JoinError {} + impl PostgresStore where C: AsClient, @@ -2593,6 +2665,208 @@ where Ok(permitted_ids) } + + #[expect(clippy::too_many_lines)] + #[tracing::instrument(skip(self, params))] + async fn cluster_entities( + &self, + actor_id: ActorEntityUuid, + params: ClusterEntitiesParams, + ) -> Result> { + const MAX_ALLOWED_DIM: u16 = 512; + const MAX_ALLOWED_K: u16 = 64; + const { assert!(Embedding::DIM <= u16::MAX as usize) }; + + let dimension = Dimension::new(params.dimension.get()).ok_or_else(|| { + Report::new(ClusterError::InvalidDimension { + dimension: params.dimension, + }) + .attach(StatusCode::InvalidArgument) + })?; + + if dimension.get() > MAX_ALLOWED_DIM { + return Err(Report::new(ClusterError::DimensionTooLarge { + dimension: dimension.value(), + max: MAX_ALLOWED_DIM, + }) + .attach(StatusCode::InvalidArgument)); + } + + if params.cluster_count > MAX_ALLOWED_K { + return Err(Report::new(ClusterError::KTooLarge { + count: params.cluster_count, + max: MAX_ALLOWED_K, + }) + .attach(StatusCode::InvalidArgument)); + } + + let truncated_dim = usize::from(dimension.get()); + + // Filter to entities the actor is allowed to view. + let permitted = self + .has_permission_for_entities( + AuthenticatedActor::from(actor_id), + HasPermissionForEntitiesParams { + action: ActionName::ViewEntity, + entity_ids: Cow::Borrowed(¶ms.entity_ids), + temporal_axes: QueryTemporalAxesUnresolved::TransactionTime { + pinned: PinnedTemporalAxisUnresolved::new(None), + variable: VariableTemporalAxisUnresolved::new(None, None), + }, + include_drafts: false, + }, + ) + .await + .change_context(ClusterError::Store)?; + + let permitted_ids: Vec<_> = params + .entity_ids + .iter() + .filter(|&id| permitted.contains_key(id)) + .copied() + .collect(); + + let entity_uuids: Vec<_> = permitted_ids.iter().map(|id| id.entity_uuid).collect(); + let web_ids: Vec<_> = permitted_ids.iter().map(|id| id.web_id).collect(); + + // Truncate server-side via `subvector` so postgres only sends + // `truncated_dim`-dimensional vectors over the wire. + // + // Matryoshka truncation shortens the vectors without re-normalizing; + // that is fine here because spherical k-means normalizes internally + // (it works with inverse norms), so no `l2_normalize` is needed. + let row_stream = self + .as_client() + .query_raw( + &format!( + "SELECT + u.web_id, + u.entity_uuid, + subvector(e.embedding, 1, {truncated_dim})::vector({truncated_dim}) AS \ + embedding + FROM ( + SELECT DISTINCT ON (t.web_id, t.entity_uuid) + t.web_id, + t.entity_uuid, + t.ord + FROM unnest($1::uuid[], $2::uuid[]) + WITH ORDINALITY AS t(web_id, entity_uuid, ord) + ORDER BY t.web_id, t.entity_uuid, t.ord + ) u + JOIN entity_embeddings e + ON e.web_id = u.web_id + AND e.entity_uuid = u.entity_uuid + WHERE e.property IS NULL + ORDER BY u.ord" + ), + [ + &web_ids as &(dyn ToSql + Sync), + &entity_uuids as &(dyn ToSql + Sync), + ], + ) + .instrument(tracing::info_span!( + "cluster_entities.embeddings", + otel.kind = "client", + db.system = "postgresql", + peer.service = "Postgres", + )) + .await + .change_context(ClusterError::Store)?; + + let mut row_stream = core::pin::pin!(row_stream); + + let mut flat: Vec<_> = Vec::with_capacity(permitted_ids.len() * truncated_dim); + let mut found_ids: Vec<_> = Vec::with_capacity(permitted_ids.len()); + + // Every requested entity not in a cluster goes into `missing_embeddings`, whether due to + // permissions or no embedding. Distinguishing the two would leak permission information. + let mut missing_ids: HashSet<_> = params.entity_ids.iter().copied().collect(); + + while let Some(row) = row_stream + .try_next() + .await + .change_context(ClusterError::Store)? + { + let web_id: WebId = row.get(0); + let entity_uuid: EntityUuid = row.get(1); + let embedding: Embedding<'_> = row.get(2); + + flat.extend(embedding.iter()); + + let id = EntityId { + web_id, + entity_uuid, + draft_id: None, + }; + found_ids.push(id); + missing_ids.remove(&id); + } + + if found_ids.is_empty() || params.cluster_count == 0 { + return Ok(ClusterEntitiesResponse { + clusters: Vec::new(), + missing_embeddings: missing_ids, + inertia: 0.0, + }); + } + + let config = hash_graph_embeddings::clustering::Config::for_k_with_seed( + params.cluster_count, + params.seed.unwrap_or_else(|| { + std::time::SystemTime::UNIX_EPOCH + .elapsed() + .map_or(0, |elapsed| { + #[expect( + clippy::cast_possible_truncation, + reason = "seed only needs entropy, truncation is fine" + )] + let seed = elapsed.as_nanos() as u64; + seed + }) + }), + ); + + let (tx, rx) = oneshot::channel(); + rayon::spawn(move || { + let result = std::panic::catch_unwind(core::panic::AssertUnwindSafe(|| { + hash_graph_embeddings::clustering::cluster(&flat, dimension, &config) + })); + + let result = result.map_err(JoinError); + + let _: Result<(), Result> = tx.send(result); + }); + + let clustering = rx + .await + .change_context(ClusterError::Store)? + .change_context(ClusterError::Store)?; + + let mut groups = vec![Vec::new(); config.k as usize]; + + #[expect(clippy::indexing_slicing, reason = "we only ever have k groups")] + for (index, &id) in found_ids.iter().enumerate() { + let label = clustering.label(index) as usize; + groups[label].push(id); + } + + let clusters = groups + .into_iter() + .zip(0_u16..) + .filter(|(entity_ids, _)| !entity_ids.is_empty()) + .map(|(entity_ids, cluster_id)| EntityCluster { + cluster_id, + entity_ids, + centroid: clustering.centroid(cluster_id).to_vec(), + }) + .collect(); + + Ok(ClusterEntitiesResponse { + clusters, + missing_embeddings: missing_ids, + inertia: clustering.inertia, + }) + } } #[derive(Debug)] diff --git a/libs/@local/graph/store/docs/dependency-diagram.mmd b/libs/@local/graph/store/docs/dependency-diagram.mmd index 0ba92b32822..c4dd3693f57 100644 --- a/libs/@local/graph/store/docs/dependency-diagram.mmd +++ b/libs/@local/graph/store/docs/dependency-diagram.mmd @@ -41,13 +41,13 @@ graph TD 1 -.-> 26 2 -.-> 3 2 --> 15 - 4 --> 6 4 --> 10 4 --> 13 4 --> 18 4 --> 21 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 diff --git a/libs/@local/graph/store/src/entity/mod.rs b/libs/@local/graph/store/src/entity/mod.rs index f1174879703..e9aa8161500 100644 --- a/libs/@local/graph/store/src/entity/mod.rs +++ b/libs/@local/graph/store/src/entity/mod.rs @@ -4,14 +4,15 @@ pub use self::{ EntityQuerySortingToken, EntityQueryToken, }, store::{ - ClosedMultiEntityTypeMap, CreateEntityParams, DeleteEntitiesParams, DeletionScope, - DeletionSummary, DiffEntityParams, DiffEntityResult, EntityPermissions, EntityStore, - EntityValidationType, HasPermissionForEntitiesParams, LinkDeletionBehavior, - PatchEntityParams, QueryConversion, QueryEntitiesParams, QueryEntitiesResponse, - QueryEntitySubgraphParams, QueryEntitySubgraphResponse, SearchEntitiesFilter, - SearchEntitiesParams, SearchEntitiesResponse, SummarizeEntitiesParams, - SummarizeEntitiesResponse, UpdateEntityEmbeddingsParams, ValidateEntityComponents, - ValidateEntityError, ValidateEntityParams, + ClosedMultiEntityTypeMap, ClusterEntitiesParams, ClusterEntitiesResponse, + CreateEntityParams, DeleteEntitiesParams, DeletionScope, DeletionSummary, DiffEntityParams, + DiffEntityResult, EntityCluster, EntityPermissions, EntityStore, EntityValidationType, + HasPermissionForEntitiesParams, LinkDeletionBehavior, PatchEntityParams, QueryConversion, + QueryEntitiesParams, QueryEntitiesResponse, QueryEntitySubgraphParams, + QueryEntitySubgraphResponse, SearchEntitiesFilter, SearchEntitiesParams, + SearchEntitiesResponse, SummarizeEntitiesParams, SummarizeEntitiesResponse, + UpdateEntityEmbeddingsParams, ValidateEntityComponents, ValidateEntityError, + ValidateEntityParams, }, validation_report::{ EmptyEntityTypes, EntityRetrieval, EntityTypeRetrieval, EntityTypesError, diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index 94ff152a36b..ca2a0b1458a 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -1,4 +1,5 @@ use alloc::borrow::Cow; +use core::num::NonZero; use std::collections::{HashMap, HashSet}; use error_stack::Report; @@ -36,7 +37,9 @@ use utoipa::{ use crate::{ entity::{EntityQueryCursor, EntityQuerySorting, EntityValidationReport}, entity_type::{EntityTypeResolveDefinitions, IncludeEntityTypeOption}, - error::{CheckPermissionError, DeletionError, InsertionError, QueryError, UpdateError}, + error::{ + CheckPermissionError, ClusterError, DeletionError, InsertionError, QueryError, UpdateError, + }, filter::{Filter, SemanticDistance}, subgraph::{ Subgraph, @@ -525,6 +528,68 @@ impl PatchEntityParams { } } +#[derive(Debug, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ClusterEntitiesParams { + pub entity_ids: Vec, + /// Desired number of clusters. + /// + /// Clamped to the number of entities with embeddings when that is smaller. + #[cfg_attr(feature = "utoipa", schema(minimum = 0, maximum = 64))] + pub cluster_count: u16, + /// Embedding dimension after matryoshka truncation. + /// + /// Must be a positive multiple of 8; values above 512 are rejected. Defaults to 256. + #[serde(default = "ClusterEntitiesParams::default_dimension")] + #[cfg_attr(feature = "utoipa", schema(value_type = u16, minimum = 8, maximum = 512, multiple_of = 8, default = 256, example = 256))] + pub dimension: NonZero, + + /// Seed for the random number generator used in clustering. + /// + /// If not provided, a random seed will be used. + pub seed: Option, +} + +impl ClusterEntitiesParams { + const fn default_dimension() -> NonZero { + const { NonZero::new(256).unwrap() } + } +} + +/// One cluster from a spherical k-means run over entity embeddings. +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct EntityCluster { + /// Index in `0..min(cluster_count, n)`. + pub cluster_id: u16, + pub entity_ids: Vec, + /// Centroid with length equal to the requested dimension. + /// + /// Typically unit-normalized, but may be the all-zero vector if all assigned points have zero + /// norm. + pub centroid: Vec, +} + +/// Result of [`EntityStore::cluster_entities`]. +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ClusterEntitiesResponse { + /// One entry per non-empty cluster. Empty clusters (no points assigned) + /// are omitted. + pub clusters: Vec, + /// Entities from the request that were not clustered (either because no embedding exists, or + /// because the actor lacks permission to view the entity). + pub missing_embeddings: HashSet, + /// Sum of squared chord distances from every clustered entity to its + /// assigned centroid. Lower is tighter; comparable across runs over the + /// same entities, e.g. to choose a cluster count. `0.0` when nothing was + /// clustered. + pub inertia: f32, +} + #[derive(Debug, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -912,6 +977,27 @@ pub trait EntityStore { params: UpdateEntityEmbeddingsParams<'_>, ) -> impl Future>> + Send; + /// Groups entities by embedding similarity using spherical k-means. + /// + /// Each entity's combined embedding is truncated to the requested + /// dimension (matryoshka encoding) before clustering. The returned + /// centroids are unit-normalized and have the same dimension. + /// + /// Entities without a stored embedding are not clustered; they appear + /// in [`ClusterEntitiesResponse::missing_embeddings`]. + /// + /// # Errors + /// + /// Returns [`ClusterError::InvalidDimension`] if the dimension is not a + /// positive multiple of 8, [`ClusterError::DimensionTooLarge`] if it + /// exceeds the maximum allowed dimension, or [`ClusterError::Store`] if the + /// embedding query fails. + fn cluster_entities( + &self, + actor_id: ActorEntityUuid, + params: ClusterEntitiesParams, + ) -> impl Future>> + Send; + /// Re-indexes the cache for entities. /// /// This is only needed if the entity was changed in place without an update procedure. This is diff --git a/libs/@local/graph/store/src/error.rs b/libs/@local/graph/store/src/error.rs index d9f39229103..546e8db5e09 100644 --- a/libs/@local/graph/store/src/error.rs +++ b/libs/@local/graph/store/src/error.rs @@ -1,4 +1,4 @@ -use core::{error::Error, fmt}; +use core::{error::Error, fmt, num::NonZero}; #[derive(Debug)] #[must_use] @@ -68,3 +68,20 @@ pub enum CheckPermissionError { } impl Error for CheckPermissionError {} + +/// Failure to cluster entities by embedding similarity. +#[derive(Debug, derive_more::Display)] +#[display("Could not cluster entities: {_variant}")] +#[must_use] +pub enum ClusterError { + #[display("dimension {dimension} is not a positive multiple of 8")] + InvalidDimension { dimension: NonZero }, + #[display("dimension {dimension} exceeds maximum allowed dimension {max}")] + DimensionTooLarge { dimension: NonZero, max: u16 }, + #[display("cluster count {count} exceeds maximum allowed {max}")] + KTooLarge { count: u16, max: u16 }, + #[display("embedding query failed")] + Store, +} + +impl Error for ClusterError {} diff --git a/libs/@local/graph/store/src/lib.rs b/libs/@local/graph/store/src/lib.rs index 2931d3f0eca..15a1b96c52f 100644 --- a/libs/@local/graph/store/src/lib.rs +++ b/libs/@local/graph/store/src/lib.rs @@ -4,7 +4,7 @@ #![cfg_attr(doc, doc = simple_mermaid::mermaid!("../docs/dependency-diagram.mmd"))] #![feature( // Language Features - impl_trait_in_assoc_type, + impl_trait_in_assoc_type )] #![cfg_attr(test, feature( // Language Features diff --git a/libs/@local/graph/temporal-versioning/docs/dependency-diagram.mmd b/libs/@local/graph/temporal-versioning/docs/dependency-diagram.mmd index 3df7e86b38a..a6593b53518 100644 --- a/libs/@local/graph/temporal-versioning/docs/dependency-diagram.mmd +++ b/libs/@local/graph/temporal-versioning/docs/dependency-diagram.mmd @@ -41,13 +41,13 @@ graph TD 1 -.-> 26 2 -.-> 3 2 --> 15 - 4 --> 6 4 --> 10 4 --> 13 4 --> 18 4 --> 21 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 diff --git a/libs/@local/graph/type-fetcher/src/store.rs b/libs/@local/graph/type-fetcher/src/store.rs index 99a3ad3415c..87a124b74b0 100644 --- a/libs/@local/graph/type-fetcher/src/store.rs +++ b/libs/@local/graph/type-fetcher/src/store.rs @@ -35,9 +35,9 @@ use hash_graph_store::{ UnarchiveDataTypeParams, UpdateDataTypeEmbeddingParams, UpdateDataTypesParams, }, entity::{ - CreateEntityParams, DeleteEntitiesParams, DeletionSummary, EntityStore, - EntityValidationReport, HasPermissionForEntitiesParams, PatchEntityParams, - QueryEntitiesParams, QueryEntitiesResponse, QueryEntitySubgraphParams, + ClusterEntitiesParams, ClusterEntitiesResponse, CreateEntityParams, DeleteEntitiesParams, + DeletionSummary, EntityStore, EntityValidationReport, HasPermissionForEntitiesParams, + PatchEntityParams, QueryEntitiesParams, QueryEntitiesResponse, QueryEntitySubgraphParams, QueryEntitySubgraphResponse, SearchEntitiesParams, SearchEntitiesResponse, SummarizeEntitiesParams, SummarizeEntitiesResponse, UpdateEntityEmbeddingsParams, ValidateEntityParams, @@ -50,7 +50,9 @@ use hash_graph_store::{ QueryEntityTypesResponse, SearchEntityTypesParams, SearchEntityTypesResponse, UnarchiveEntityTypeParams, UpdateEntityTypeEmbeddingParams, UpdateEntityTypesParams, }, - error::{CheckPermissionError, DeletionError, InsertionError, QueryError, UpdateError}, + error::{ + CheckPermissionError, ClusterError, DeletionError, InsertionError, QueryError, UpdateError, + }, filter::{Filter, QueryRecord}, pool::StorePool, property_type::{ @@ -1713,6 +1715,14 @@ where self.store.update_entity_embeddings(actor_id, params).await } + async fn cluster_entities( + &self, + actor_id: ActorEntityUuid, + params: ClusterEntitiesParams, + ) -> Result> { + self.store.cluster_entities(actor_id, params).await + } + async fn reindex_entity_cache(&mut self) -> Result<(), Report> { self.store.reindex_entity_cache().await } diff --git a/libs/@local/graph/types/docs/dependency-diagram.mmd b/libs/@local/graph/types/docs/dependency-diagram.mmd index 64f359477cc..dfec2861ca5 100644 --- a/libs/@local/graph/types/docs/dependency-diagram.mmd +++ b/libs/@local/graph/types/docs/dependency-diagram.mmd @@ -41,13 +41,13 @@ graph TD 1 -.-> 26 2 -.-> 3 2 --> 15 - 4 --> 6 4 --> 10 4 --> 13 4 --> 18 4 --> 21 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 diff --git a/libs/@local/harpc/types/docs/dependency-diagram.mmd b/libs/@local/harpc/types/docs/dependency-diagram.mmd index 91c4ae06387..e19512e8d9f 100644 --- a/libs/@local/harpc/types/docs/dependency-diagram.mmd +++ b/libs/@local/harpc/types/docs/dependency-diagram.mmd @@ -44,13 +44,13 @@ graph TD 1 --> 8 1 -.-> 30 2 --> 19 - 3 --> 5 3 --> 9 3 --> 15 3 --> 23 3 --> 26 4 --> 1 5 --> 10 + 6 --> 5 6 --> 11 7 --> 4 7 --> 10 diff --git a/libs/@local/harpc/wire-protocol/docs/dependency-diagram.mmd b/libs/@local/harpc/wire-protocol/docs/dependency-diagram.mmd index e665aaf4f40..4b9d3502f7b 100644 --- a/libs/@local/harpc/wire-protocol/docs/dependency-diagram.mmd +++ b/libs/@local/harpc/wire-protocol/docs/dependency-diagram.mmd @@ -44,13 +44,13 @@ graph TD 1 --> 8 1 -.-> 30 2 --> 18 - 3 --> 5 3 --> 9 3 --> 14 3 --> 22 3 --> 25 4 --> 1 5 --> 10 + 6 --> 5 6 --> 11 7 --> 4 7 --> 10 diff --git a/libs/@local/hashql/ast/docs/dependency-diagram.mmd b/libs/@local/hashql/ast/docs/dependency-diagram.mmd index 1c4dfe4d89f..49b505b3f35 100644 --- a/libs/@local/hashql/ast/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/ast/docs/dependency-diagram.mmd @@ -14,74 +14,78 @@ graph TD 3[hash-codegen] 4[hash-graph-api] 5[hash-graph-authorization] - 6[hash-graph-migrations] - 7[hash-graph-migrations-macros] - 8[hash-graph-postgres-store] - 9[hash-graph-store] - 10[hash-graph-temporal-versioning] - 11[hash-graph-types] - 12[hash-graph-validation] - 13[harpc-types] - 14[harpc-wire-protocol] - 15[hashql-ast] - class 15 root - 16[hashql-compiletest] - 17[hashql-core] - 18[hashql-diagnostics] - 19[hashql-eval] - 20[hashql-hir] - 21[hashql-macros] - 22[hashql-mir] - 23[hashql-syntax-jexpr] - 24[hash-status] - 25[hash-telemetry] - 26[hash-temporal-client] - 27[darwin-kperf] - 28[darwin-kperf-criterion] - 29[darwin-kperf-events] - 30[darwin-kperf-sys] - 31[error-stack] - 32[hash-graph-benches] - 33[hash-graph-test-data] + 6[hash-graph-embeddings] + 7[hash-graph-migrations] + 8[hash-graph-migrations-macros] + 9[hash-graph-postgres-store] + 10[hash-graph-store] + 11[hash-graph-temporal-versioning] + 12[hash-graph-types] + 13[hash-graph-validation] + 14[harpc-types] + 15[harpc-wire-protocol] + 16[hashql-ast] + class 16 root + 17[hashql-compiletest] + 18[hashql-core] + 19[hashql-diagnostics] + 20[hashql-eval] + 21[hashql-hir] + 22[hashql-macros] + 23[hashql-mir] + 24[hashql-syntax-jexpr] + 25[hash-status] + 26[hash-telemetry] + 27[hash-temporal-client] + 28[darwin-kperf] + 29[darwin-kperf-criterion] + 30[darwin-kperf-events] + 31[darwin-kperf-sys] + 32[error-stack] + 33[hash-graph-benches] + 34[hash-graph-test-data] 0 --> 4 - 1 --> 10 - 1 -.-> 33 + 1 --> 11 + 1 -.-> 34 2 -.-> 3 - 2 --> 14 - 4 --> 19 - 4 --> 23 + 2 --> 15 + 4 --> 20 + 4 --> 24 5 --> 1 - 6 --> 7 - 6 --> 25 - 8 -.-> 6 - 8 --> 12 - 8 --> 24 - 9 --> 5 - 9 --> 11 - 9 --> 26 - 10 --> 2 - 11 -.-> 33 - 12 -.-> 33 - 14 -.-> 13 - 14 --> 13 - 14 --> 31 - 15 -.-> 16 - 16 --> 19 - 16 --> 23 - 17 --> 2 - 17 --> 18 - 17 --> 21 - 17 -.-> 28 - 19 --> 8 - 19 --> 22 - 20 -.-> 16 - 22 --> 20 - 23 --> 15 - 23 --> 17 - 25 --> 31 - 26 --> 1 - 27 --> 29 - 27 --> 30 - 28 --> 27 - 32 -.-> 4 - 33 --> 9 + 6 --> 12 + 6 -.-> 29 + 7 --> 8 + 7 --> 26 + 9 --> 6 + 9 -.-> 7 + 9 --> 13 + 9 --> 25 + 10 --> 5 + 10 --> 12 + 10 --> 27 + 11 --> 2 + 12 -.-> 34 + 13 -.-> 34 + 15 -.-> 14 + 15 --> 14 + 15 --> 32 + 16 -.-> 17 + 17 --> 20 + 17 --> 24 + 18 --> 2 + 18 --> 19 + 18 --> 22 + 18 -.-> 29 + 20 --> 9 + 20 --> 23 + 21 -.-> 17 + 23 --> 21 + 24 --> 16 + 24 --> 18 + 26 --> 32 + 27 --> 1 + 28 --> 30 + 28 --> 31 + 29 --> 28 + 33 -.-> 4 + 34 --> 10 diff --git a/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd b/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd index 4d2d74f3e20..9a2ec12d921 100644 --- a/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd @@ -14,74 +14,78 @@ graph TD 3[hash-codegen] 4[hash-graph-api] 5[hash-graph-authorization] - 6[hash-graph-migrations] - 7[hash-graph-migrations-macros] - 8[hash-graph-postgres-store] - 9[hash-graph-store] - 10[hash-graph-temporal-versioning] - 11[hash-graph-types] - 12[hash-graph-validation] - 13[harpc-types] - 14[harpc-wire-protocol] - 15[hashql-ast] - 16[hashql-compiletest] - class 16 root - 17[hashql-core] - 18[hashql-diagnostics] - 19[hashql-eval] - 20[hashql-hir] - 21[hashql-macros] - 22[hashql-mir] - 23[hashql-syntax-jexpr] - 24[hash-status] - 25[hash-telemetry] - 26[hash-temporal-client] - 27[darwin-kperf] - 28[darwin-kperf-criterion] - 29[darwin-kperf-events] - 30[darwin-kperf-sys] - 31[error-stack] - 32[hash-graph-benches] - 33[hash-graph-test-data] + 6[hash-graph-embeddings] + 7[hash-graph-migrations] + 8[hash-graph-migrations-macros] + 9[hash-graph-postgres-store] + 10[hash-graph-store] + 11[hash-graph-temporal-versioning] + 12[hash-graph-types] + 13[hash-graph-validation] + 14[harpc-types] + 15[harpc-wire-protocol] + 16[hashql-ast] + 17[hashql-compiletest] + class 17 root + 18[hashql-core] + 19[hashql-diagnostics] + 20[hashql-eval] + 21[hashql-hir] + 22[hashql-macros] + 23[hashql-mir] + 24[hashql-syntax-jexpr] + 25[hash-status] + 26[hash-telemetry] + 27[hash-temporal-client] + 28[darwin-kperf] + 29[darwin-kperf-criterion] + 30[darwin-kperf-events] + 31[darwin-kperf-sys] + 32[error-stack] + 33[hash-graph-benches] + 34[hash-graph-test-data] 0 --> 4 - 1 --> 10 - 1 -.-> 33 + 1 --> 11 + 1 -.-> 34 2 -.-> 3 - 2 --> 14 - 4 --> 19 - 4 --> 23 + 2 --> 15 + 4 --> 20 + 4 --> 24 5 --> 1 - 6 --> 7 - 6 --> 25 - 8 -.-> 6 - 8 --> 12 - 8 --> 24 - 9 --> 5 - 9 --> 11 - 9 --> 26 - 10 --> 2 - 11 -.-> 33 - 12 -.-> 33 - 14 -.-> 13 - 14 --> 13 - 14 --> 31 - 15 -.-> 16 - 16 --> 19 - 16 --> 23 - 17 --> 2 - 17 --> 18 - 17 --> 21 - 17 -.-> 28 - 19 --> 8 - 19 --> 22 - 20 -.-> 16 - 22 --> 20 - 23 --> 15 - 23 --> 17 - 25 --> 31 - 26 --> 1 - 27 --> 29 - 27 --> 30 - 28 --> 27 - 32 -.-> 4 - 33 --> 9 + 6 --> 12 + 6 -.-> 29 + 7 --> 8 + 7 --> 26 + 9 --> 6 + 9 -.-> 7 + 9 --> 13 + 9 --> 25 + 10 --> 5 + 10 --> 12 + 10 --> 27 + 11 --> 2 + 12 -.-> 34 + 13 -.-> 34 + 15 -.-> 14 + 15 --> 14 + 15 --> 32 + 16 -.-> 17 + 17 --> 20 + 17 --> 24 + 18 --> 2 + 18 --> 19 + 18 --> 22 + 18 -.-> 29 + 20 --> 9 + 20 --> 23 + 21 -.-> 17 + 23 --> 21 + 24 --> 16 + 24 --> 18 + 26 --> 32 + 27 --> 1 + 28 --> 30 + 28 --> 31 + 29 --> 28 + 33 -.-> 4 + 34 --> 10 diff --git a/libs/@local/hashql/eval/docs/dependency-diagram.mmd b/libs/@local/hashql/eval/docs/dependency-diagram.mmd index 09fbd7269eb..0817d44f171 100644 --- a/libs/@local/hashql/eval/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/eval/docs/dependency-diagram.mmd @@ -14,74 +14,78 @@ graph TD 3[hash-codegen] 4[hash-graph-api] 5[hash-graph-authorization] - 6[hash-graph-migrations] - 7[hash-graph-migrations-macros] - 8[hash-graph-postgres-store] - 9[hash-graph-store] - 10[hash-graph-temporal-versioning] - 11[hash-graph-types] - 12[hash-graph-validation] - 13[harpc-types] - 14[harpc-wire-protocol] - 15[hashql-ast] - 16[hashql-compiletest] - 17[hashql-core] - 18[hashql-diagnostics] - 19[hashql-eval] - class 19 root - 20[hashql-hir] - 21[hashql-macros] - 22[hashql-mir] - 23[hashql-syntax-jexpr] - 24[hash-status] - 25[hash-telemetry] - 26[hash-temporal-client] - 27[darwin-kperf] - 28[darwin-kperf-criterion] - 29[darwin-kperf-events] - 30[darwin-kperf-sys] - 31[error-stack] - 32[hash-graph-benches] - 33[hash-graph-test-data] + 6[hash-graph-embeddings] + 7[hash-graph-migrations] + 8[hash-graph-migrations-macros] + 9[hash-graph-postgres-store] + 10[hash-graph-store] + 11[hash-graph-temporal-versioning] + 12[hash-graph-types] + 13[hash-graph-validation] + 14[harpc-types] + 15[harpc-wire-protocol] + 16[hashql-ast] + 17[hashql-compiletest] + 18[hashql-core] + 19[hashql-diagnostics] + 20[hashql-eval] + class 20 root + 21[hashql-hir] + 22[hashql-macros] + 23[hashql-mir] + 24[hashql-syntax-jexpr] + 25[hash-status] + 26[hash-telemetry] + 27[hash-temporal-client] + 28[darwin-kperf] + 29[darwin-kperf-criterion] + 30[darwin-kperf-events] + 31[darwin-kperf-sys] + 32[error-stack] + 33[hash-graph-benches] + 34[hash-graph-test-data] 0 --> 4 - 1 --> 10 - 1 -.-> 33 + 1 --> 11 + 1 -.-> 34 2 -.-> 3 - 2 --> 14 - 4 --> 19 - 4 --> 23 + 2 --> 15 + 4 --> 20 + 4 --> 24 5 --> 1 - 6 --> 7 - 6 --> 25 - 8 -.-> 6 - 8 --> 12 - 8 --> 24 - 9 --> 5 - 9 --> 11 - 9 --> 26 - 10 --> 2 - 11 -.-> 33 - 12 -.-> 33 - 14 -.-> 13 - 14 --> 13 - 14 --> 31 - 15 -.-> 16 - 16 --> 19 - 16 --> 23 - 17 --> 2 - 17 --> 18 - 17 --> 21 - 17 -.-> 28 - 19 --> 8 - 19 --> 22 - 20 -.-> 16 - 22 --> 20 - 23 --> 15 - 23 --> 17 - 25 --> 31 - 26 --> 1 - 27 --> 29 - 27 --> 30 - 28 --> 27 - 32 -.-> 4 - 33 --> 9 + 6 --> 12 + 6 -.-> 29 + 7 --> 8 + 7 --> 26 + 9 --> 6 + 9 -.-> 7 + 9 --> 13 + 9 --> 25 + 10 --> 5 + 10 --> 12 + 10 --> 27 + 11 --> 2 + 12 -.-> 34 + 13 -.-> 34 + 15 -.-> 14 + 15 --> 14 + 15 --> 32 + 16 -.-> 17 + 17 --> 20 + 17 --> 24 + 18 --> 2 + 18 --> 19 + 18 --> 22 + 18 -.-> 29 + 20 --> 9 + 20 --> 23 + 21 -.-> 17 + 23 --> 21 + 24 --> 16 + 24 --> 18 + 26 --> 32 + 27 --> 1 + 28 --> 30 + 28 --> 31 + 29 --> 28 + 33 -.-> 4 + 34 --> 10 diff --git a/libs/@local/hashql/hir/docs/dependency-diagram.mmd b/libs/@local/hashql/hir/docs/dependency-diagram.mmd index 979a0dc9dc4..d0f49158af5 100644 --- a/libs/@local/hashql/hir/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/hir/docs/dependency-diagram.mmd @@ -14,74 +14,78 @@ graph TD 3[hash-codegen] 4[hash-graph-api] 5[hash-graph-authorization] - 6[hash-graph-migrations] - 7[hash-graph-migrations-macros] - 8[hash-graph-postgres-store] - 9[hash-graph-store] - 10[hash-graph-temporal-versioning] - 11[hash-graph-types] - 12[hash-graph-validation] - 13[harpc-types] - 14[harpc-wire-protocol] - 15[hashql-ast] - 16[hashql-compiletest] - 17[hashql-core] - 18[hashql-diagnostics] - 19[hashql-eval] - 20[hashql-hir] - class 20 root - 21[hashql-macros] - 22[hashql-mir] - 23[hashql-syntax-jexpr] - 24[hash-status] - 25[hash-telemetry] - 26[hash-temporal-client] - 27[darwin-kperf] - 28[darwin-kperf-criterion] - 29[darwin-kperf-events] - 30[darwin-kperf-sys] - 31[error-stack] - 32[hash-graph-benches] - 33[hash-graph-test-data] + 6[hash-graph-embeddings] + 7[hash-graph-migrations] + 8[hash-graph-migrations-macros] + 9[hash-graph-postgres-store] + 10[hash-graph-store] + 11[hash-graph-temporal-versioning] + 12[hash-graph-types] + 13[hash-graph-validation] + 14[harpc-types] + 15[harpc-wire-protocol] + 16[hashql-ast] + 17[hashql-compiletest] + 18[hashql-core] + 19[hashql-diagnostics] + 20[hashql-eval] + 21[hashql-hir] + class 21 root + 22[hashql-macros] + 23[hashql-mir] + 24[hashql-syntax-jexpr] + 25[hash-status] + 26[hash-telemetry] + 27[hash-temporal-client] + 28[darwin-kperf] + 29[darwin-kperf-criterion] + 30[darwin-kperf-events] + 31[darwin-kperf-sys] + 32[error-stack] + 33[hash-graph-benches] + 34[hash-graph-test-data] 0 --> 4 - 1 --> 10 - 1 -.-> 33 + 1 --> 11 + 1 -.-> 34 2 -.-> 3 - 2 --> 14 - 4 --> 19 - 4 --> 23 + 2 --> 15 + 4 --> 20 + 4 --> 24 5 --> 1 - 6 --> 7 - 6 --> 25 - 8 -.-> 6 - 8 --> 12 - 8 --> 24 - 9 --> 5 - 9 --> 11 - 9 --> 26 - 10 --> 2 - 11 -.-> 33 - 12 -.-> 33 - 14 -.-> 13 - 14 --> 13 - 14 --> 31 - 15 -.-> 16 - 16 --> 19 - 16 --> 23 - 17 --> 2 - 17 --> 18 - 17 --> 21 - 17 -.-> 28 - 19 --> 8 - 19 --> 22 - 20 -.-> 16 - 22 --> 20 - 23 --> 15 - 23 --> 17 - 25 --> 31 - 26 --> 1 - 27 --> 29 - 27 --> 30 - 28 --> 27 - 32 -.-> 4 - 33 --> 9 + 6 --> 12 + 6 -.-> 29 + 7 --> 8 + 7 --> 26 + 9 --> 6 + 9 -.-> 7 + 9 --> 13 + 9 --> 25 + 10 --> 5 + 10 --> 12 + 10 --> 27 + 11 --> 2 + 12 -.-> 34 + 13 -.-> 34 + 15 -.-> 14 + 15 --> 14 + 15 --> 32 + 16 -.-> 17 + 17 --> 20 + 17 --> 24 + 18 --> 2 + 18 --> 19 + 18 --> 22 + 18 -.-> 29 + 20 --> 9 + 20 --> 23 + 21 -.-> 17 + 23 --> 21 + 24 --> 16 + 24 --> 18 + 26 --> 32 + 27 --> 1 + 28 --> 30 + 28 --> 31 + 29 --> 28 + 33 -.-> 4 + 34 --> 10 diff --git a/libs/@local/hashql/mir/docs/dependency-diagram.mmd b/libs/@local/hashql/mir/docs/dependency-diagram.mmd index 58b887e6182..08cc3a23141 100644 --- a/libs/@local/hashql/mir/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/mir/docs/dependency-diagram.mmd @@ -14,74 +14,78 @@ graph TD 3[hash-codegen] 4[hash-graph-api] 5[hash-graph-authorization] - 6[hash-graph-migrations] - 7[hash-graph-migrations-macros] - 8[hash-graph-postgres-store] - 9[hash-graph-store] - 10[hash-graph-temporal-versioning] - 11[hash-graph-types] - 12[hash-graph-validation] - 13[harpc-types] - 14[harpc-wire-protocol] - 15[hashql-ast] - 16[hashql-compiletest] - 17[hashql-core] - 18[hashql-diagnostics] - 19[hashql-eval] - 20[hashql-hir] - 21[hashql-macros] - 22[hashql-mir] - class 22 root - 23[hashql-syntax-jexpr] - 24[hash-status] - 25[hash-telemetry] - 26[hash-temporal-client] - 27[darwin-kperf] - 28[darwin-kperf-criterion] - 29[darwin-kperf-events] - 30[darwin-kperf-sys] - 31[error-stack] - 32[hash-graph-benches] - 33[hash-graph-test-data] + 6[hash-graph-embeddings] + 7[hash-graph-migrations] + 8[hash-graph-migrations-macros] + 9[hash-graph-postgres-store] + 10[hash-graph-store] + 11[hash-graph-temporal-versioning] + 12[hash-graph-types] + 13[hash-graph-validation] + 14[harpc-types] + 15[harpc-wire-protocol] + 16[hashql-ast] + 17[hashql-compiletest] + 18[hashql-core] + 19[hashql-diagnostics] + 20[hashql-eval] + 21[hashql-hir] + 22[hashql-macros] + 23[hashql-mir] + class 23 root + 24[hashql-syntax-jexpr] + 25[hash-status] + 26[hash-telemetry] + 27[hash-temporal-client] + 28[darwin-kperf] + 29[darwin-kperf-criterion] + 30[darwin-kperf-events] + 31[darwin-kperf-sys] + 32[error-stack] + 33[hash-graph-benches] + 34[hash-graph-test-data] 0 --> 4 - 1 --> 10 - 1 -.-> 33 + 1 --> 11 + 1 -.-> 34 2 -.-> 3 - 2 --> 14 - 4 --> 19 - 4 --> 23 + 2 --> 15 + 4 --> 20 + 4 --> 24 5 --> 1 - 6 --> 7 - 6 --> 25 - 8 -.-> 6 - 8 --> 12 - 8 --> 24 - 9 --> 5 - 9 --> 11 - 9 --> 26 - 10 --> 2 - 11 -.-> 33 - 12 -.-> 33 - 14 -.-> 13 - 14 --> 13 - 14 --> 31 - 15 -.-> 16 - 16 --> 19 - 16 --> 23 - 17 --> 2 - 17 --> 18 - 17 --> 21 - 17 -.-> 28 - 19 --> 8 - 19 --> 22 - 20 -.-> 16 - 22 --> 20 - 23 --> 15 - 23 --> 17 - 25 --> 31 - 26 --> 1 - 27 --> 29 - 27 --> 30 - 28 --> 27 - 32 -.-> 4 - 33 --> 9 + 6 --> 12 + 6 -.-> 29 + 7 --> 8 + 7 --> 26 + 9 --> 6 + 9 -.-> 7 + 9 --> 13 + 9 --> 25 + 10 --> 5 + 10 --> 12 + 10 --> 27 + 11 --> 2 + 12 -.-> 34 + 13 -.-> 34 + 15 -.-> 14 + 15 --> 14 + 15 --> 32 + 16 -.-> 17 + 17 --> 20 + 17 --> 24 + 18 --> 2 + 18 --> 19 + 18 --> 22 + 18 -.-> 29 + 20 --> 9 + 20 --> 23 + 21 -.-> 17 + 23 --> 21 + 24 --> 16 + 24 --> 18 + 26 --> 32 + 27 --> 1 + 28 --> 30 + 28 --> 31 + 29 --> 28 + 33 -.-> 4 + 34 --> 10 diff --git a/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd b/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd index d4de1967175..659be40ce02 100644 --- a/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd @@ -14,74 +14,78 @@ graph TD 3[hash-codegen] 4[hash-graph-api] 5[hash-graph-authorization] - 6[hash-graph-migrations] - 7[hash-graph-migrations-macros] - 8[hash-graph-postgres-store] - 9[hash-graph-store] - 10[hash-graph-temporal-versioning] - 11[hash-graph-types] - 12[hash-graph-validation] - 13[harpc-types] - 14[harpc-wire-protocol] - 15[hashql-ast] - 16[hashql-compiletest] - 17[hashql-core] - 18[hashql-diagnostics] - 19[hashql-eval] - 20[hashql-hir] - 21[hashql-macros] - 22[hashql-mir] - 23[hashql-syntax-jexpr] - class 23 root - 24[hash-status] - 25[hash-telemetry] - 26[hash-temporal-client] - 27[darwin-kperf] - 28[darwin-kperf-criterion] - 29[darwin-kperf-events] - 30[darwin-kperf-sys] - 31[error-stack] - 32[hash-graph-benches] - 33[hash-graph-test-data] + 6[hash-graph-embeddings] + 7[hash-graph-migrations] + 8[hash-graph-migrations-macros] + 9[hash-graph-postgres-store] + 10[hash-graph-store] + 11[hash-graph-temporal-versioning] + 12[hash-graph-types] + 13[hash-graph-validation] + 14[harpc-types] + 15[harpc-wire-protocol] + 16[hashql-ast] + 17[hashql-compiletest] + 18[hashql-core] + 19[hashql-diagnostics] + 20[hashql-eval] + 21[hashql-hir] + 22[hashql-macros] + 23[hashql-mir] + 24[hashql-syntax-jexpr] + class 24 root + 25[hash-status] + 26[hash-telemetry] + 27[hash-temporal-client] + 28[darwin-kperf] + 29[darwin-kperf-criterion] + 30[darwin-kperf-events] + 31[darwin-kperf-sys] + 32[error-stack] + 33[hash-graph-benches] + 34[hash-graph-test-data] 0 --> 4 - 1 --> 10 - 1 -.-> 33 + 1 --> 11 + 1 -.-> 34 2 -.-> 3 - 2 --> 14 - 4 --> 19 - 4 --> 23 + 2 --> 15 + 4 --> 20 + 4 --> 24 5 --> 1 - 6 --> 7 - 6 --> 25 - 8 -.-> 6 - 8 --> 12 - 8 --> 24 - 9 --> 5 - 9 --> 11 - 9 --> 26 - 10 --> 2 - 11 -.-> 33 - 12 -.-> 33 - 14 -.-> 13 - 14 --> 13 - 14 --> 31 - 15 -.-> 16 - 16 --> 19 - 16 --> 23 - 17 --> 2 - 17 --> 18 - 17 --> 21 - 17 -.-> 28 - 19 --> 8 - 19 --> 22 - 20 -.-> 16 - 22 --> 20 - 23 --> 15 - 23 --> 17 - 25 --> 31 - 26 --> 1 - 27 --> 29 - 27 --> 30 - 28 --> 27 - 32 -.-> 4 - 33 --> 9 + 6 --> 12 + 6 -.-> 29 + 7 --> 8 + 7 --> 26 + 9 --> 6 + 9 -.-> 7 + 9 --> 13 + 9 --> 25 + 10 --> 5 + 10 --> 12 + 10 --> 27 + 11 --> 2 + 12 -.-> 34 + 13 -.-> 34 + 15 -.-> 14 + 15 --> 14 + 15 --> 32 + 16 -.-> 17 + 17 --> 20 + 17 --> 24 + 18 --> 2 + 18 --> 19 + 18 --> 22 + 18 -.-> 29 + 20 --> 9 + 20 --> 23 + 21 -.-> 17 + 23 --> 21 + 24 --> 16 + 24 --> 18 + 26 --> 32 + 27 --> 1 + 28 --> 30 + 28 --> 31 + 29 --> 28 + 33 -.-> 4 + 34 --> 10 diff --git a/libs/@local/temporal-client/docs/dependency-diagram.mmd b/libs/@local/temporal-client/docs/dependency-diagram.mmd index 65bcb84bf66..3a4af027246 100644 --- a/libs/@local/temporal-client/docs/dependency-diagram.mmd +++ b/libs/@local/temporal-client/docs/dependency-diagram.mmd @@ -41,13 +41,13 @@ graph TD 1 -.-> 26 2 -.-> 3 2 --> 15 - 4 --> 6 4 --> 10 4 --> 13 4 --> 18 4 --> 21 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 diff --git a/tests/graph/integration/Cargo.toml b/tests/graph/integration/Cargo.toml index 4e289457f43..da1d6be507f 100644 --- a/tests/graph/integration/Cargo.toml +++ b/tests/graph/integration/Cargo.toml @@ -15,6 +15,7 @@ hash-graph-postgres-store = { workspace = true } hash-graph-store = { workspace = true } hash-graph-temporal-versioning = { workspace = true } hash-graph-test-data = { workspace = true } +hash-graph-types = { workspace = true } hash-status = { workspace = true } hash-telemetry = { workspace = true } type-system = { workspace = true } diff --git a/tests/graph/integration/package.json b/tests/graph/integration/package.json index 8fd6b60ede6..0fdebc36434 100644 --- a/tests/graph/integration/package.json +++ b/tests/graph/integration/package.json @@ -18,6 +18,7 @@ "@rust/hash-graph-store": "workspace:*", "@rust/hash-graph-temporal-versioning": "workspace:*", "@rust/hash-graph-test-data": "workspace:*", + "@rust/hash-graph-types": "workspace:*", "@rust/hash-status": "workspace:*", "@rust/hash-telemetry": "workspace:*" } diff --git a/tests/graph/integration/postgres/clustering.rs b/tests/graph/integration/postgres/clustering.rs new file mode 100644 index 00000000000..da10b0a90cd --- /dev/null +++ b/tests/graph/integration/postgres/clustering.rs @@ -0,0 +1,377 @@ +use core::num::NonZero; +use std::collections::HashSet; + +use hash_graph_store::{ + entity::{ + ClusterEntitiesParams, CreateEntityParams, EntityStore as _, UpdateEntityEmbeddingsParams, + }, + error::ClusterError, +}; +use hash_graph_temporal_versioning::Timestamp; +use hash_graph_test_data::{data_type, entity, entity_type, property_type}; +use hash_graph_types::{Embedding, knowledge::entity::EntityEmbedding}; +use type_system::{ + knowledge::{ + entity::{EntityId, id::EntityUuid, provenance::ProvidedEntityEditionProvenance}, + property::{PropertyObject, PropertyObjectWithMetadata}, + }, + ontology::id::{BaseUrl, OntologyTypeVersion, VersionedUrl}, + principal::{actor::ActorType, actor_group::WebId}, + provenance::{OriginProvenance, OriginType}, +}; +use uuid::Uuid; + +use crate::{DatabaseApi, DatabaseTestWrapper}; + +/// Dimension used for clustering requests in these tests. +/// +/// Embeddings are stored as 3072-dimensional vectors but matryoshka-truncated +/// server-side, so only the first `CLUSTER_DIM` components carry signal here. +const CLUSTER_DIM: u16 = 8; + +async fn seed(database: &mut DatabaseTestWrapper) -> DatabaseApi<'_> { + database + .seed( + [ + data_type::VALUE_V1, + data_type::TEXT_V1, + data_type::NUMBER_V1, + ], + [ + property_type::NAME_V1, + property_type::AGE_V1, + property_type::FAVORITE_SONG_V1, + property_type::FAVORITE_FILM_V1, + property_type::HOBBY_V1, + property_type::INTERESTS_V1, + ], + [ + entity_type::LINK_V1, + entity_type::link::FRIEND_OF_V1, + entity_type::link::ACQUAINTANCE_OF_V1, + entity_type::PERSON_V1, + ], + ) + .await + .expect("could not seed database") +} + +fn person_entity_type_id() -> VersionedUrl { + VersionedUrl { + base_url: BaseUrl::new( + "https://blockprotocol.org/@alice/types/entity-type/person/".to_owned(), + ) + .expect("couldn't construct Base URL"), + version: OntologyTypeVersion { + major: 1, + pre_release: None, + }, + } +} + +async fn create_person(api: &mut DatabaseApi<'_>) -> EntityId { + let person: PropertyObject = + serde_json::from_str(entity::PERSON_ALICE_V1).expect("could not parse entity"); + + api.create_entity( + api.account_id, + CreateEntityParams { + web_id: WebId::new(api.account_id), + entity_uuid: None, + decision_time: None, + entity_type_ids: HashSet::from([person_entity_type_id()]), + properties: PropertyObjectWithMetadata::from_parts(person, None) + .expect("could not create property with metadata object"), + confidence: None, + link_data: None, + draft: false, + policies: Vec::new(), + provenance: ProvidedEntityEditionProvenance { + actor_type: ActorType::User, + origin: OriginProvenance::from_empty_type(OriginType::Api), + sources: Vec::new(), + }, + read_only: false, + }, + ) + .await + .expect("could not create entity") + .metadata + .record_id + .entity_id +} + +/// Builds a full-width stored embedding pointing along `axis` within the first +/// [`CLUSTER_DIM`] components, with a small per-entity `jitter` so vectors in +/// the same group are distinct but remain tightly clustered. +#[expect(clippy::indexing_slicing, clippy::float_arithmetic)] +fn embedding_along_axis(axis: usize, jitter_axis: usize, jitter: f32) -> Embedding<'static> { + assert!(axis < usize::from(CLUSTER_DIM)); + assert!(jitter_axis < usize::from(CLUSTER_DIM)); + + let mut vector = vec![0.0_f32; Embedding::DIM]; + vector[axis] = 1.0; + vector[jitter_axis] += jitter; + Embedding::from(vector) +} + +async fn insert_embedding( + api: &mut DatabaseApi<'_>, + entity_id: EntityId, + embedding: Embedding<'static>, +) { + api.update_entity_embeddings( + api.account_id, + UpdateEntityEmbeddingsParams { + entity_id, + embeddings: vec![EntityEmbedding { + property: None, + embedding, + }], + updated_at_transaction_time: Timestamp::now(), + updated_at_decision_time: Timestamp::now(), + reset: false, + }, + ) + .await + .expect("could not insert entity embedding"); +} + +const fn cluster_params(entity_ids: Vec, cluster_count: u16) -> ClusterEntitiesParams { + ClusterEntitiesParams { + entity_ids, + cluster_count, + dimension: NonZero::new(CLUSTER_DIM).expect("dimension should be non-zero"), + seed: Some(0), + } +} + +#[tokio::test] +async fn clusters_entities_by_embedding_direction() { + let mut database = DatabaseTestWrapper::new().await; + let mut api = seed(&mut database).await; + + // Two well-separated groups: group A points along axis 0, group B along + // axis 1. Spherical k-means with `k = 2` must separate them. + let mut group_a = Vec::new(); + for index in 0..3_usize { + let entity_id = create_person(&mut api).await; + #[expect(clippy::cast_precision_loss, reason = "tiny test-only jitter values")] + insert_embedding( + &mut api, + entity_id, + embedding_along_axis(0, 2, 0.01 * index as f32), + ) + .await; + group_a.push(entity_id); + } + + let mut group_b = Vec::new(); + for index in 0..2_usize { + let entity_id = create_person(&mut api).await; + #[expect(clippy::cast_precision_loss, reason = "tiny test-only jitter values")] + insert_embedding( + &mut api, + entity_id, + embedding_along_axis(1, 3, 0.01 * index as f32), + ) + .await; + group_b.push(entity_id); + } + + // One existing entity without any stored embedding ... + let entity_without_embedding = create_person(&mut api).await; + // ... and one entity ID that does not exist at all. + let nonexistent_entity = EntityId { + web_id: WebId::new(api.account_id), + entity_uuid: EntityUuid::new(Uuid::new_v4()), + draft_id: None, + }; + + let mut requested = group_a.clone(); + requested.extend(&group_b); + requested.push(entity_without_embedding); + requested.push(nonexistent_entity); + + let response = api + .cluster_entities(api.account_id, cluster_params(requested, 2)) + .await + .expect("could not cluster entities"); + + assert_eq!( + response + .missing_embeddings + .iter() + .copied() + .collect::>(), + HashSet::from([entity_without_embedding, nonexistent_entity]), + "entities without embeddings and unknown entities should be reported as missing" + ); + + assert_eq!(response.clusters.len(), 2, "expected exactly two clusters"); + + let group_a_set: HashSet = group_a.iter().copied().collect(); + let group_b_set: HashSet = group_b.iter().copied().collect(); + + let cluster_with_a = response + .clusters + .iter() + .find(|cluster| cluster.entity_ids.contains(&group_a[0])) + .expect("group A should be assigned to a cluster"); + let cluster_with_b = response + .clusters + .iter() + .find(|cluster| cluster.entity_ids.contains(&group_b[0])) + .expect("group B should be assigned to a cluster"); + + assert_eq!( + cluster_with_a + .entity_ids + .iter() + .copied() + .collect::>(), + group_a_set, + "group A should form one cluster" + ); + assert_eq!( + cluster_with_b + .entity_ids + .iter() + .copied() + .collect::>(), + group_b_set, + "group B should form the other cluster" + ); + + for cluster in &response.clusters { + assert_eq!( + cluster.centroid.len(), + usize::from(CLUSTER_DIM), + "centroid length should match the requested dimension" + ); + } + + assert!( + response.inertia < 0.01, + "tightly clustered groups should have near-zero inertia, got {}", + response.inertia + ); +} + +#[tokio::test] +async fn permission_denied_is_reported_as_missing() { + let mut database = DatabaseTestWrapper::new().await; + let mut api = seed(&mut database).await; + + let entity_id = create_person(&mut api).await; + insert_embedding(&mut api, entity_id, embedding_along_axis(0, 1, 0.0)).await; + + // The owning actor can cluster the entity. + let response = api + .cluster_entities(api.account_id, cluster_params(vec![entity_id], 1)) + .await + .expect("could not cluster entities"); + assert_eq!(response.clusters.len(), 1); + assert!(response.missing_embeddings.is_empty()); + + // A machine actor without access to the web must not see the entity. To + // avoid leaking permission information, the entity is reported exactly as + // if it had no embedding. + let machine_id = api.create_machine("clustering-outsider").await; + let response = api + .cluster_entities(machine_id.into(), cluster_params(vec![entity_id], 1)) + .await + .expect("could not cluster entities"); + + assert!( + response.clusters.is_empty(), + "unauthorized entities should not be clustered" + ); + assert_eq!( + response.missing_embeddings, + HashSet::from([entity_id]), + "unauthorized entities should be indistinguishable from missing embeddings" + ); + assert!(response.inertia.abs() < f32::EPSILON); +} + +#[tokio::test] +async fn zero_cluster_count_returns_no_clusters() { + let mut database = DatabaseTestWrapper::new().await; + let mut api = seed(&mut database).await; + + let entity_id = create_person(&mut api).await; + insert_embedding(&mut api, entity_id, embedding_along_axis(0, 1, 0.0)).await; + + let response = api + .cluster_entities(api.account_id, cluster_params(vec![entity_id], 0)) + .await + .expect("could not cluster entities"); + + assert!(response.clusters.is_empty()); + assert!( + response.missing_embeddings.is_empty(), + "entities with embeddings should not be reported as missing even when `k = 0`" + ); + assert!(response.inertia.abs() < f32::EPSILON); +} + +#[tokio::test] +async fn rejects_invalid_parameters() { + let mut database = DatabaseTestWrapper::new().await; + let api = seed(&mut database).await; + + // Dimension must be a multiple of 8. + let report = api + .cluster_entities( + api.account_id, + ClusterEntitiesParams { + entity_ids: Vec::new(), + cluster_count: 2, + dimension: NonZero::new(7).expect("dimension should be non-zero"), + seed: Some(0), + }, + ) + .await + .expect_err("dimension not a multiple of 8 should be rejected"); + assert!(matches!( + report.current_context(), + ClusterError::InvalidDimension { .. } + )); + + // Dimension must not exceed 512. + let report = api + .cluster_entities( + api.account_id, + ClusterEntitiesParams { + entity_ids: Vec::new(), + cluster_count: 2, + dimension: NonZero::new(520).expect("dimension should be non-zero"), + seed: Some(0), + }, + ) + .await + .expect_err("dimension above 512 should be rejected"); + assert!(matches!( + report.current_context(), + ClusterError::DimensionTooLarge { .. } + )); + + // Cluster count must not exceed 64. + let report = api + .cluster_entities( + api.account_id, + ClusterEntitiesParams { + entity_ids: Vec::new(), + cluster_count: 65, + dimension: NonZero::new(CLUSTER_DIM).expect("dimension should be non-zero"), + seed: Some(0), + }, + ) + .await + .expect_err("cluster count above 64 should be rejected"); + assert!(matches!( + report.current_context(), + ClusterError::KTooLarge { .. } + )); +} diff --git a/tests/graph/integration/postgres/lib.rs b/tests/graph/integration/postgres/lib.rs index 5c2ab899e3f..59053b59abb 100644 --- a/tests/graph/integration/postgres/lib.rs +++ b/tests/graph/integration/postgres/lib.rs @@ -6,6 +6,7 @@ extern crate alloc; +mod clustering; mod data_type; mod drafts; mod email_filter_protection; @@ -890,6 +891,17 @@ impl EntityStore for DatabaseApi<'_> { self.store.reindex_entity_cache().await } + async fn cluster_entities( + &self, + actor_id: ActorEntityUuid, + params: hash_graph_store::entity::ClusterEntitiesParams, + ) -> Result< + hash_graph_store::entity::ClusterEntitiesResponse, + Report, + > { + self.store.cluster_entities(actor_id, params).await + } + async fn has_permission_for_entities( &self, authenticated_actor: AuthenticatedActor, diff --git a/tests/graph/test-data/rust/docs/dependency-diagram.mmd b/tests/graph/test-data/rust/docs/dependency-diagram.mmd index 8418e7f6ed1..0390665dcd2 100644 --- a/tests/graph/test-data/rust/docs/dependency-diagram.mmd +++ b/tests/graph/test-data/rust/docs/dependency-diagram.mmd @@ -41,13 +41,13 @@ graph TD 1 -.-> 26 2 -.-> 3 2 --> 15 - 4 --> 6 4 --> 10 4 --> 13 4 --> 18 4 --> 21 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 diff --git a/yarn.lock b/yarn.lock index 8e112e18814..1f7fc42f09f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -578,6 +578,11 @@ __metadata: "@blockprotocol/hook": "npm:0.1.8" "@blockprotocol/service": "npm:0.1.5" "@blockprotocol/type-system": "workspace:*" + "@deck.gl/core": "npm:9.3.4" + "@deck.gl/extensions": "npm:9.3.4" + "@deck.gl/layers": "npm:9.3.4" + "@deck.gl/react": "npm:9.3.4" + "@deck.gl/widgets": "npm:9.3.4" "@dnd-kit/core": "npm:6.3.1" "@dnd-kit/sortable": "npm:7.0.2" "@dnd-kit/utilities": "npm:3.2.2" @@ -608,6 +613,8 @@ __metadata: "@local/hash-isomorphic-utils": "workspace:*" "@local/status": "workspace:*" "@local/tsconfig": "workspace:*" + "@luma.gl/core": "npm:9.3.5" + "@luma.gl/engine": "npm:9.3.5" "@mantine/hooks": "npm:8.3.5" "@mui/icons-material": "npm:5.18.0" "@mui/material": "npm:5.18.0" @@ -617,19 +624,16 @@ __metadata: "@ory/integrations": "npm:1.3.1" "@pandacss/dev": "npm:1.11.1" "@popperjs/core": "npm:2.11.8" - "@react-sigma/core": "npm:4.0.3" "@sentry/nextjs": "npm:10.54.0" "@sentry/react": "npm:10.54.0" "@sentry/webpack-plugin": "npm:5.3.0" - "@sigma/edge-curve": "patch:@sigma/edge-curve@npm%3A3.0.0-beta.16#~/.yarn/patches/@sigma-edge-curve-npm-3.0.0-beta.16-3d8b985284.patch" - "@sigma/node-border": "npm:3.0.0" - "@sigma/node-image": "npm:3.0.0" "@svgr/webpack": "npm:8.1.0" "@tanstack/react-table": "npm:8.21.3" "@tldraw/editor": "patch:@tldraw/editor@npm%3A2.0.0-alpha.12#~/.yarn/patches/@tldraw-editor-npm-2.0.0-alpha.12-ba59bf001c.patch" "@tldraw/primitives": "npm:2.0.0-alpha.12" "@tldraw/tldraw": "npm:2.0.0-alpha.12" "@tldraw/tlvalidate": "npm:2.0.0-alpha.12" + "@types/d3-force": "npm:3.0.10" "@types/dotenv-flow": "npm:3.3.3" "@types/gapi": "npm:0.0.47" "@types/google.accounts": "npm:0.0.18" @@ -651,6 +655,7 @@ __metadata: axios: "npm:1.16.1" buffer: "npm:6.0.3" clsx: "npm:2.1.1" + d3-force: "npm:3.0.0" date-fns: "npm:4.1.0" dompurify: "npm:3.4.11" dotenv-flow: "npm:3.3.0" @@ -661,10 +666,7 @@ __metadata: fractional-indexing: "npm:3.2.0" framer-motion: "npm:11.18.2" graphology: "npm:0.26.0" - graphology-layout: "npm:0.6.1" - graphology-layout-forceatlas2: "npm:0.10.1" - graphology-shortest-path: "npm:2.1.0" - graphology-simple-path: "npm:0.2.0" + graphology-communities-louvain: "npm:2.0.2" graphology-types: "npm:0.24.8" graphql: "npm:16.11.0" iframe-resizer: "npm:4.4.5" @@ -709,16 +711,16 @@ __metadata: safe-stable-stringify: "npm:2.5.0" sass: "npm:1.93.2" setimmediate: "npm:1.0.5" - sigma: "npm:3.0.2" signia: "npm:0.1.5" signia-react: "npm:0.1.5" typescript: "npm:5.9.3" url-regex-safe: "npm:4.0.0" use-font-face-observer: "npm:1.3.0" uuid: "npm:14.0.0" - vitest: "npm:4.1.8" + vitest: "npm:4.1.9" wait-on: "npm:9.0.1" web-worker: "npm:1.4.1" + webcola: "npm:3.4.0" webpack: "npm:5.104.1" zod: "npm:4.4.3" languageName: unknown @@ -4505,6 +4507,91 @@ __metadata: languageName: node linkType: hard +"@deck.gl/core@npm:9.3.4": + version: 9.3.4 + resolution: "@deck.gl/core@npm:9.3.4" + dependencies: + "@loaders.gl/core": "npm:^4.4.1" + "@loaders.gl/images": "npm:^4.4.1" + "@luma.gl/core": "npm:^9.3.3" + "@luma.gl/engine": "npm:^9.3.3" + "@luma.gl/shadertools": "npm:^9.3.3" + "@luma.gl/webgl": "npm:^9.3.3" + "@math.gl/core": "npm:^4.1.0" + "@math.gl/sun": "npm:^4.1.0" + "@math.gl/types": "npm:^4.1.0" + "@math.gl/web-mercator": "npm:^4.1.0" + "@probe.gl/env": "npm:^4.1.1" + "@probe.gl/log": "npm:^4.1.1" + "@probe.gl/stats": "npm:^4.1.1" + "@types/offscreencanvas": "npm:^2019.6.4" + gl-matrix: "npm:^3.0.0" + mjolnir.js: "npm:^3.0.0" + checksum: 10c0/ecc4200d6ee6a7daef59c80c7e850dc8c042f11b7729d748987da3fce85d13e1a4bfef33d3bea0e53fa43e86b2216b595be9bd35fdec5171924fbb3f72d5312e + languageName: node + linkType: hard + +"@deck.gl/extensions@npm:9.3.4": + version: 9.3.4 + resolution: "@deck.gl/extensions@npm:9.3.4" + dependencies: + "@luma.gl/shadertools": "npm:^9.3.3" + "@luma.gl/webgl": "npm:^9.3.3" + "@math.gl/core": "npm:^4.1.0" + peerDependencies: + "@deck.gl/core": ~9.3.0 + "@luma.gl/core": ~9.3.3 + "@luma.gl/engine": ~9.3.3 + checksum: 10c0/6eadf761300e9062f435a67e6a4504578e37e5ccf636de40d152ff7c138c1ad52c81c1aa64eb5dff022082dda6116fb401bea83854baf5c3c8a376393ca7d498 + languageName: node + linkType: hard + +"@deck.gl/layers@npm:9.3.4": + version: 9.3.4 + resolution: "@deck.gl/layers@npm:9.3.4" + dependencies: + "@loaders.gl/images": "npm:^4.4.1" + "@loaders.gl/schema": "npm:^4.4.1" + "@luma.gl/shadertools": "npm:^9.3.3" + "@mapbox/tiny-sdf": "npm:^2.0.5" + "@math.gl/core": "npm:^4.1.0" + "@math.gl/polygon": "npm:^4.1.0" + "@math.gl/web-mercator": "npm:^4.1.0" + earcut: "npm:^2.2.4" + peerDependencies: + "@deck.gl/core": ~9.3.0 + "@loaders.gl/core": ^4.4.1 + "@luma.gl/core": ~9.3.3 + "@luma.gl/engine": ~9.3.3 + checksum: 10c0/e96cdb092ad77e9f1c1ca1db02a2e648129f2d7a2a0235c4779c3b1818b9735724cd64968866a1d507b60ed960382e8c2ce122aa781fbabc29616f3c8d26fd1b + languageName: node + linkType: hard + +"@deck.gl/react@npm:9.3.4": + version: 9.3.4 + resolution: "@deck.gl/react@npm:9.3.4" + peerDependencies: + "@deck.gl/core": ~9.3.0 + "@deck.gl/widgets": ~9.3.0 + react: ">=16.3.0" + react-dom: ">=16.3.0" + checksum: 10c0/b6abd804908680d46fa3c2109b277e311a434bda06a577ec25bf6ec02993e68405d48c776dd738317d6a81db7d582b593cb56187357517c5c063b5de7dfcc5e5 + languageName: node + linkType: hard + +"@deck.gl/widgets@npm:9.3.4": + version: 9.3.4 + resolution: "@deck.gl/widgets@npm:9.3.4" + dependencies: + "@floating-ui/dom": "npm:^1.7.5" + preact: "npm:^10.17.0" + peerDependencies: + "@deck.gl/core": ~9.3.0 + "@luma.gl/core": ~9.3.3 + checksum: 10c0/68669747a4ebb8c979a414ef5b47878b22065d4653ac4d72c20a2e336119210d2f589e43fe95e7b3662386442ffdfefe323b7a08f20aefd90c3f6ec18bf99e75 + languageName: node + linkType: hard + "@discoveryjs/json-ext@npm:0.5.7": version: 0.5.7 resolution: "@discoveryjs/json-ext@npm:0.5.7" @@ -5601,7 +5688,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/dom@npm:^1.0.0, @floating-ui/dom@npm:^1.7.6": +"@floating-ui/dom@npm:^1.0.0, @floating-ui/dom@npm:^1.7.5, @floating-ui/dom@npm:^1.7.6": version: 1.7.6 resolution: "@floating-ui/dom@npm:1.7.6" dependencies: @@ -8096,6 +8183,74 @@ __metadata: languageName: node linkType: hard +"@loaders.gl/core@npm:^4.4.1": + version: 4.4.3 + resolution: "@loaders.gl/core@npm:4.4.3" + dependencies: + "@loaders.gl/loader-utils": "npm:4.4.3" + "@loaders.gl/schema": "npm:4.4.3" + "@loaders.gl/schema-utils": "npm:4.4.3" + "@loaders.gl/worker-utils": "npm:4.4.3" + "@probe.gl/log": "npm:^4.1.1" + checksum: 10c0/dcb412017844bc453918e2c2cf59910c5f1a2fec505cbc346db35a147dd562bde913604050a641f1891557c23ea936ad28102d3f1a3d01ba4146788ba805067b + languageName: node + linkType: hard + +"@loaders.gl/images@npm:^4.4.1": + version: 4.4.3 + resolution: "@loaders.gl/images@npm:4.4.3" + dependencies: + "@loaders.gl/loader-utils": "npm:4.4.3" + peerDependencies: + "@loaders.gl/core": ~4.4.0 + checksum: 10c0/d366127988a4a23f38354ed718ce3ccb817e7a3b5599758068ceeec7ae30d94614e31c05cc079761c2115137fb77c141cf09044e4270615ffc8492d762da96a8 + languageName: node + linkType: hard + +"@loaders.gl/loader-utils@npm:4.4.3": + version: 4.4.3 + resolution: "@loaders.gl/loader-utils@npm:4.4.3" + dependencies: + "@loaders.gl/schema": "npm:4.4.3" + "@loaders.gl/worker-utils": "npm:4.4.3" + "@probe.gl/log": "npm:^4.1.1" + "@probe.gl/stats": "npm:^4.1.1" + checksum: 10c0/82fd3a3d7b130d9d3794ec11f64cd6210e347732d732022b60f12f8560754043e1eae16126c689342436a84fdf7cfeeb828d8a9c76a1c3c84728f4bb304cc71a + languageName: node + linkType: hard + +"@loaders.gl/schema-utils@npm:4.4.3": + version: 4.4.3 + resolution: "@loaders.gl/schema-utils@npm:4.4.3" + dependencies: + "@loaders.gl/schema": "npm:4.4.3" + "@types/geojson": "npm:^7946.0.7" + apache-arrow: "npm:>= 17.0.0" + peerDependencies: + "@loaders.gl/core": ~4.4.0 + checksum: 10c0/bf03c6725ba85fba55bc74282d6660a914b7f33e9e69bbed5918a5793302b87e1c956dabe4e24a10f57c490aa1eb78441cbeb05f3f76ca88026c64162c120734 + languageName: node + linkType: hard + +"@loaders.gl/schema@npm:4.4.3, @loaders.gl/schema@npm:^4.4.1": + version: 4.4.3 + resolution: "@loaders.gl/schema@npm:4.4.3" + dependencies: + "@types/geojson": "npm:^7946.0.7" + apache-arrow: "npm:>= 17.0.0" + checksum: 10c0/59a9fd6047398a3eebb67fdd11fb2418feb91767955911573656d486718af06643e3b4e5ccd860612356031081f826197c876e61a3f0426fb20e6137a7498d4a + languageName: node + linkType: hard + +"@loaders.gl/worker-utils@npm:4.4.3": + version: 4.4.3 + resolution: "@loaders.gl/worker-utils@npm:4.4.3" + peerDependencies: + "@loaders.gl/core": ~4.4.0 + checksum: 10c0/b82093c822725c6a076dba9a133df5cbb31b646ec523ea3b2a5bda6443e0919d65a6b97e774482b3976621ea1b1d257139a6319acdfa6d5163f8408d0c78334a + languageName: node + linkType: hard + "@local/advanced-types@workspace:*, @local/advanced-types@workspace:libs/@local/advanced-types": version: 0.0.0-use.local resolution: "@local/advanced-types@workspace:libs/@local/advanced-types" @@ -8457,6 +8612,58 @@ __metadata: languageName: node linkType: hard +"@luma.gl/core@npm:9.3.5, @luma.gl/core@npm:^9.3.3": + version: 9.3.5 + resolution: "@luma.gl/core@npm:9.3.5" + dependencies: + "@math.gl/types": "npm:^4.1.0" + "@probe.gl/env": "npm:^4.1.1" + "@probe.gl/log": "npm:^4.1.1" + "@probe.gl/stats": "npm:^4.1.1" + "@types/offscreencanvas": "npm:^2019.7.3" + checksum: 10c0/a43d764973463d9ee761358755dfaae85d12b5ffa955746bb6eb72fe285d1cb67af575dcdade7464005c8fbedec7d0b8af98284101cffea2d96ccbd8c7ec3bde + languageName: node + linkType: hard + +"@luma.gl/engine@npm:9.3.5, @luma.gl/engine@npm:^9.3.3": + version: 9.3.5 + resolution: "@luma.gl/engine@npm:9.3.5" + dependencies: + "@math.gl/core": "npm:^4.1.0" + "@math.gl/types": "npm:^4.1.0" + "@probe.gl/log": "npm:^4.1.1" + "@probe.gl/stats": "npm:^4.1.1" + peerDependencies: + "@luma.gl/core": ~9.3.0 + "@luma.gl/shadertools": ~9.3.0 + checksum: 10c0/7e1404e079adbdd3c7b1787a22a2e1791bdf1f4796cbc230f8ab3e437a854f88d5bfb32792907d03bf05de3f3472f2324c300e3d99a10e5ded1e612fe21a4d53 + languageName: node + linkType: hard + +"@luma.gl/shadertools@npm:^9.3.3": + version: 9.3.5 + resolution: "@luma.gl/shadertools@npm:9.3.5" + dependencies: + "@math.gl/core": "npm:^4.1.0" + "@math.gl/types": "npm:^4.1.0" + peerDependencies: + "@luma.gl/core": ~9.3.0 + checksum: 10c0/7e00b21b8c08d7862a2866ef08ca21ffd06560b4ba80ec231c7ab76182f5faa4c74dfc3f96ceef480a4ea205579c1ff882b3a95c77432a97dc2d3e7a54fe4ce0 + languageName: node + linkType: hard + +"@luma.gl/webgl@npm:^9.3.3": + version: 9.3.5 + resolution: "@luma.gl/webgl@npm:9.3.5" + dependencies: + "@math.gl/types": "npm:^4.1.0" + "@probe.gl/env": "npm:^4.1.1" + peerDependencies: + "@luma.gl/core": ~9.3.0 + checksum: 10c0/5a753f754098622191aabc1ae122e8f644d20a4d2068654b19dc2976c508cefaea73a7b1f0198ced726b836cb06e7ccb168897eaafcd8e4f5dba24e4e45ad89b + languageName: node + linkType: hard + "@mailchimp/mailchimp_marketing@npm:3.0.80": version: 3.0.80 resolution: "@mailchimp/mailchimp_marketing@npm:3.0.80" @@ -8502,6 +8709,13 @@ __metadata: languageName: node linkType: hard +"@mapbox/tiny-sdf@npm:^2.0.5": + version: 2.2.0 + resolution: "@mapbox/tiny-sdf@npm:2.2.0" + checksum: 10c0/9cfddf94bcdbedb047cd5e1bf17b8d90cdfe3e5d0286282769f697d8972955be33cc3e49c23f60d977c99439f3de80ed429b4bf73599cec441cb493ba3c6c8ed + languageName: node + linkType: hard + "@mark.probst/typescript-json-schema@npm:~0.32.0": version: 0.32.0 resolution: "@mark.probst/typescript-json-schema@npm:0.32.0" @@ -8516,6 +8730,47 @@ __metadata: languageName: node linkType: hard +"@math.gl/core@npm:4.1.0, @math.gl/core@npm:^4.1.0": + version: 4.1.0 + resolution: "@math.gl/core@npm:4.1.0" + dependencies: + "@math.gl/types": "npm:4.1.0" + checksum: 10c0/495934dc2be0b60cd6ff2cc16a0215608c9254919db741a0074b6b41cef9a0543c7f790eda7d529afa102d2937490608ef75fcc64c789ef2876ae750fd0ed3d6 + languageName: node + linkType: hard + +"@math.gl/polygon@npm:^4.1.0": + version: 4.1.0 + resolution: "@math.gl/polygon@npm:4.1.0" + dependencies: + "@math.gl/core": "npm:4.1.0" + checksum: 10c0/0fcfb489c5613ddff6dd0cbea65084e10fa9a3523fb87a36a4fdf10057d12d2a99f1ebd93da6e72b45db0783fb8cd9cff704765473372a8db580faaf50c85ab5 + languageName: node + linkType: hard + +"@math.gl/sun@npm:^4.1.0": + version: 4.1.0 + resolution: "@math.gl/sun@npm:4.1.0" + checksum: 10c0/baf813f3134124a1701c5f64cc8725fdb1d8d4c9e47c7fbd0d2e960d823f776d35312f82c62a09dcb140ea31d42b3945aa781066495291df190566ae17b5857b + languageName: node + linkType: hard + +"@math.gl/types@npm:4.1.0, @math.gl/types@npm:^4.1.0": + version: 4.1.0 + resolution: "@math.gl/types@npm:4.1.0" + checksum: 10c0/3c4dfa5ac5c9e2cef24d31f56b89c1dde785a5d70fd1a7030386346cb7dd4fa2cce5ba983b89842c1971492e30870dd22a078d64893f9c66887e38367bf992fa + languageName: node + linkType: hard + +"@math.gl/web-mercator@npm:^4.1.0": + version: 4.1.0 + resolution: "@math.gl/web-mercator@npm:4.1.0" + dependencies: + "@math.gl/core": "npm:4.1.0" + checksum: 10c0/7aa4921b9442da75664ef517f41de65b1eae9970d7eee61d14d2eb0b332e1af6203785e8d198376a8ab5924d62b24856d648ff6478cca95b14d3a8d82822ef93 + languageName: node + linkType: hard + "@mdx-js/mdx@npm:^3.1.0": version: 3.1.1 resolution: "@mdx-js/mdx@npm:3.1.1" @@ -10791,6 +11046,29 @@ __metadata: languageName: node linkType: hard +"@probe.gl/env@npm:4.1.1, @probe.gl/env@npm:^4.1.1": + version: 4.1.1 + resolution: "@probe.gl/env@npm:4.1.1" + checksum: 10c0/431f53d95936ba1ee08e8d4c155cc3c49d80d528acaff933ae8f32a4725f04d88ee634707e20baa885a505d6f1401f895bdfcfa42c259b3220dd26463d2cfb2d + languageName: node + linkType: hard + +"@probe.gl/log@npm:^4.1.1": + version: 4.1.1 + resolution: "@probe.gl/log@npm:4.1.1" + dependencies: + "@probe.gl/env": "npm:4.1.1" + checksum: 10c0/713043aea095dbe6e97525a5d254f2563e9e0b6c64fdb888603f41342ed0f0a76a64e6996d2d461c7f1f9c6271a931b2e922d9e47f8c43f3d4116867f4cd5d0a + languageName: node + linkType: hard + +"@probe.gl/stats@npm:^4.1.1": + version: 4.1.1 + resolution: "@probe.gl/stats@npm:4.1.1" + checksum: 10c0/7fd49d891e5ecfc82330c0db9180cc6a3fae0f78062570fabdc1860f79f09b612c53af08b9d6e2bb701b79fb7156ec2c9c0ddf53e5dba12b0560c7bbc957afc7 + languageName: node + linkType: hard + "@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2": version: 1.1.2 resolution: "@protobufjs/aspromise@npm:1.1.2" @@ -12000,17 +12278,6 @@ __metadata: languageName: node linkType: hard -"@react-sigma/core@npm:4.0.3": - version: 4.0.3 - resolution: "@react-sigma/core@npm:4.0.3" - peerDependencies: - graphology: ^0.25.4 - react: ^18.0.0 - sigma: ^3.0.0-beta.24 - checksum: 10c0/0b8d5c57987fb0ac391da18515e570c379aea58f9063d2e2d3721d4015946be7a624f2f5366a41423fad90b70d6c65ad733fcea750976d5d05432d52b433876f - languageName: node - linkType: hard - "@reactflow/background@npm:11.3.14": version: 11.3.14 resolution: "@reactflow/background@npm:11.3.14" @@ -13141,6 +13408,7 @@ __metadata: version: 0.0.0-use.local resolution: "@rust/hash-graph-embeddings@workspace:libs/@local/graph/embeddings" dependencies: + "@rust/darwin-kperf-criterion": "workspace:*" "@rust/error-stack": "workspace:*" "@rust/hash-graph-types": "workspace:*" languageName: unknown @@ -13168,6 +13436,7 @@ __metadata: "@rust/hash-graph-store": "workspace:*" "@rust/hash-graph-temporal-versioning": "workspace:*" "@rust/hash-graph-test-data": "workspace:*" + "@rust/hash-graph-types": "workspace:*" "@rust/hash-status": "workspace:*" "@rust/hash-telemetry": "workspace:*" languageName: unknown @@ -13197,6 +13466,7 @@ __metadata: "@rust/error-stack": "workspace:*" "@rust/hash-codec": "workspace:*" "@rust/hash-graph-authorization": "workspace:*" + "@rust/hash-graph-embeddings": "workspace:*" "@rust/hash-graph-migrations": "workspace:*" "@rust/hash-graph-store": "workspace:*" "@rust/hash-graph-temporal-versioning": "workspace:*" @@ -13747,42 +14017,6 @@ __metadata: languageName: node linkType: hard -"@sigma/edge-curve@npm:3.0.0-beta.16": - version: 3.0.0-beta.16 - resolution: "@sigma/edge-curve@npm:3.0.0-beta.16" - peerDependencies: - sigma: ">=3.0.0-beta.10" - checksum: 10c0/71c7547a0138c0ed508e179cb2d2f8d82a8d34578b02246a43d073d84007e740e05c20d6009c7ea5f5ddde77e0a470ea719b75ebf18cda204526dcb4d36dc8b3 - languageName: node - linkType: hard - -"@sigma/edge-curve@patch:@sigma/edge-curve@npm%3A3.0.0-beta.16#~/.yarn/patches/@sigma-edge-curve-npm-3.0.0-beta.16-3d8b985284.patch": - version: 3.0.0-beta.16 - resolution: "@sigma/edge-curve@patch:@sigma/edge-curve@npm%3A3.0.0-beta.16#~/.yarn/patches/@sigma-edge-curve-npm-3.0.0-beta.16-3d8b985284.patch::version=3.0.0-beta.16&hash=8004c8" - peerDependencies: - sigma: ">=3.0.0-beta.10" - checksum: 10c0/39ec1037a91b7bd1f2d0321c511d283b3b4ced4e1ce8341d2d5f6ee8f0a7bd4308d6b008bc4d787e0beabf3de8ee7d513b1da9dace65bfc9d0e9d9206eec4278 - languageName: node - linkType: hard - -"@sigma/node-border@npm:3.0.0": - version: 3.0.0 - resolution: "@sigma/node-border@npm:3.0.0" - peerDependencies: - sigma: ">=3.0.0-beta.17" - checksum: 10c0/aa5ae48a30699ed8947c0ae85750a4a5be7f415849b08a2e664b04a0636f9ca9cbdbdc72f14a5438016e99a1a18599222b8501e7fd8db531fbd176c3167a454d - languageName: node - linkType: hard - -"@sigma/node-image@npm:3.0.0": - version: 3.0.0 - resolution: "@sigma/node-image@npm:3.0.0" - peerDependencies: - sigma: ">=3.0.0-beta.10" - checksum: 10c0/1af4bf4805011a4713db0eaa48424ef74afde837c819471f18b154e7bd64cd7ebecae49b9ba041ac6183c81d8f55b30107b0150a2a32a5da6524faf73ebe12b2 - languageName: node - linkType: hard - "@sindresorhus/fnv1a@npm:^3.1.0": version: 3.1.0 resolution: "@sindresorhus/fnv1a@npm:3.1.0" @@ -15233,6 +15467,15 @@ __metadata: languageName: node linkType: hard +"@swc/helpers@npm:^0.5.11": + version: 0.5.23 + resolution: "@swc/helpers@npm:0.5.23" + dependencies: + tslib: "npm:^2.8.0" + checksum: 10c0/02da7b4df465693933ecd4851cc193ec729c309939c8a84eccae5ec0010aafc3894e713b8ef8d13a6ba401759f0e900c88e2dcfef5872c27bb91e70f73275cce + languageName: node + linkType: hard + "@swc/types@npm:^0.1.25": version: 0.1.25 resolution: "@swc/types@npm:0.1.25" @@ -15996,6 +16239,20 @@ __metadata: languageName: node linkType: hard +"@types/command-line-args@npm:^5.2.3": + version: 5.2.3 + resolution: "@types/command-line-args@npm:5.2.3" + checksum: 10c0/3a9bc58fd26e546391f6369dd28c03d59349dc4ac39eada1a5c39cc3578e02e4aac222615170e0db79b198ffba2af84fdbdda46e08c6edc4da42bc17ea85200f + languageName: node + linkType: hard + +"@types/command-line-usage@npm:^5.0.4": + version: 5.0.4 + resolution: "@types/command-line-usage@npm:5.0.4" + checksum: 10c0/67840ebf4bcfee200c07d978669ad596fe2adc350fd5c19d44ec2248623575d96ec917f513d1d59453f8f57e879133861a4cc41c20045c07f6c959f1fcaac7ad + languageName: node + linkType: hard + "@types/connect-history-api-fallback@npm:^1.5.4": version: 1.5.4 resolution: "@types/connect-history-api-fallback@npm:1.5.4" @@ -16119,7 +16376,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-force@npm:*": +"@types/d3-force@npm:*, @types/d3-force@npm:3.0.10": version: 3.0.10 resolution: "@types/d3-force@npm:3.0.10" checksum: 10c0/c82b459079a106b50e346c9b79b141f599f2fc4f598985a5211e72c7a2e20d35bd5dc6e91f306b323c8bfa325c02c629b1645f5243f1c6a55bd51bc85cccfa92 @@ -16456,6 +16713,13 @@ __metadata: languageName: node linkType: hard +"@types/geojson@npm:^7946.0.7": + version: 7946.0.16 + resolution: "@types/geojson@npm:7946.0.16" + checksum: 10c0/1ff24a288bd5860b766b073ead337d31d73bdc715e5b50a2cee5cb0af57a1ed02cc04ef295f5fa68dc40fe3e4f104dd31282b2b818a5ba3231bc1001ba084e3c + languageName: node + linkType: hard + "@types/glob@npm:^7.1.1, @types/glob@npm:^7.1.3": version: 7.2.0 resolution: "@types/glob@npm:7.2.0" @@ -16807,6 +17071,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^24.0.3": + version: 24.13.2 + resolution: "@types/node@npm:24.13.2" + dependencies: + undici-types: "npm:~7.18.0" + checksum: 10c0/d7d48a88a4feb0a6aac3cbfaf9ef3b12752b4b09447f88dd0b4c77c03b281e3d4330fe6982a99aedcd63fc16c7540a0c248b91eb2abb0b3edd884d7fe684e9ea + languageName: node + linkType: hard + "@types/nodemailer@npm:6.4.17": version: 6.4.17 resolution: "@types/nodemailer@npm:6.4.17" @@ -16830,6 +17103,13 @@ __metadata: languageName: node linkType: hard +"@types/offscreencanvas@npm:^2019.6.4, @types/offscreencanvas@npm:^2019.7.3": + version: 2019.7.3 + resolution: "@types/offscreencanvas@npm:2019.7.3" + checksum: 10c0/6d1dfae721d321cd2b5435f347a0e53b09f33b2f9e9333396480f592823bc323847b8169f7d251d2285cb93dbc1ba2e30741ac5cf4b1c003d660fd4c24526963 + languageName: node + linkType: hard + "@types/papaparse@npm:5.3.16": version: 5.3.16 resolution: "@types/papaparse@npm:5.3.16" @@ -18029,6 +18309,20 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/expect@npm:4.1.9" + dependencies: + "@standard-schema/spec": "npm:^1.1.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + chai: "npm:^6.2.2" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/243bacaed2cba5e0ea4ec7465662fcec465a358a0e06381e337fac49426aa67a73b104fbb9d65d8bccadfba8f70e27f57ffb897aacfa140f579a556367357875 + languageName: node + linkType: hard + "@vitest/mocker@npm:3.2.4": version: 3.2.4 resolution: "@vitest/mocker@npm:3.2.4" @@ -18067,6 +18361,25 @@ __metadata: languageName: node linkType: hard +"@vitest/mocker@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/mocker@npm:4.1.9" + dependencies: + "@vitest/spy": "npm:4.1.9" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/707353b7435bbfd441cc754e4ee7bc5921b70d07b051c6e414b6bbe4ca369154702b0ddeb603389469fe87ca1983e002eb2d55044582661f54a1945dd27e5c82 + languageName: node + linkType: hard + "@vitest/pretty-format@npm:3.2.4": version: 3.2.4 resolution: "@vitest/pretty-format@npm:3.2.4" @@ -18085,6 +18398,15 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/pretty-format@npm:4.1.9" + dependencies: + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/5b96295f25ab885616230ad1355fc82f490bebb39cc707688d7c8969c08270d7e076ed8a10af4e762ed57145193c6061a1f549f136f0ded344f8db0c2b3fb3de + languageName: node + linkType: hard + "@vitest/runner@npm:4.1.8": version: 4.1.8 resolution: "@vitest/runner@npm:4.1.8" @@ -18095,6 +18417,16 @@ __metadata: languageName: node linkType: hard +"@vitest/runner@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/runner@npm:4.1.9" + dependencies: + "@vitest/utils": "npm:4.1.9" + pathe: "npm:^2.0.3" + checksum: 10c0/d206b4891a64b1f55c346f832b0a7b489108094d8ae34438d3b53e78be7b45b139fa95ffa027c98c357bd532268ee573168de1943235b7eed32a9236ed5978bb + languageName: node + linkType: hard + "@vitest/snapshot@npm:4.1.8": version: 4.1.8 resolution: "@vitest/snapshot@npm:4.1.8" @@ -18107,6 +18439,18 @@ __metadata: languageName: node linkType: hard +"@vitest/snapshot@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/snapshot@npm:4.1.9" + dependencies: + "@vitest/pretty-format": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + magic-string: "npm:^0.30.21" + pathe: "npm:^2.0.3" + checksum: 10c0/c3099df12ad1f9c1e180441856c9eb82f1990f87ff16aafedd6fa19978eaff20bc59220b692a99fcc822daef86eab256ba3dadb49544b7bd625b57c49cd9d995 + languageName: node + linkType: hard + "@vitest/spy@npm:3.2.4": version: 3.2.4 resolution: "@vitest/spy@npm:3.2.4" @@ -18123,6 +18467,13 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/spy@npm:4.1.9" + checksum: 10c0/e51f328f55b76e8ba66e5e18f183484a8dc0a092685b101112d3e9fb8e989ddca162c98ddf00254476502c25bc05c4ec1e277fd6ad8bfc702464c08f6b5dd115 + languageName: node + linkType: hard + "@vitest/utils@npm:3.2.4": version: 3.2.4 resolution: "@vitest/utils@npm:3.2.4" @@ -18145,6 +18496,17 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/utils@npm:4.1.9" + dependencies: + "@vitest/pretty-format": "npm:4.1.9" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/d55506c077fd72c091eb66f02926f0abf72801c87a085f565698289562f47befa114ae2c680ab8736dfe46abab0cfd6b8031f2ac519bafeb37578aa6e5ad03c5 + languageName: node + linkType: hard + "@volar/language-core@npm:2.4.23, @volar/language-core@npm:~2.4.11": version: 2.4.23 resolution: "@volar/language-core@npm:2.4.23" @@ -18620,13 +18982,6 @@ __metadata: languageName: node linkType: hard -"@yomguithereal/helpers@npm:^1.1.1": - version: 1.1.1 - resolution: "@yomguithereal/helpers@npm:1.1.1" - checksum: 10c0/a521e8e02a3902f5a33b970397158d70520bf1b79bca67db3990cb90703adaa9fc67e45c6cabfa98021202534002530e97e617adc19afa62f9e282d996513934 - languageName: node - linkType: hard - "@zag-js/accordion@npm:1.41.2": version: 1.41.2 resolution: "@zag-js/accordion@npm:1.41.2" @@ -19959,6 +20314,25 @@ __metadata: languageName: node linkType: hard +"apache-arrow@npm:>= 17.0.0": + version: 21.1.0 + resolution: "apache-arrow@npm:21.1.0" + dependencies: + "@swc/helpers": "npm:^0.5.11" + "@types/command-line-args": "npm:^5.2.3" + "@types/command-line-usage": "npm:^5.0.4" + "@types/node": "npm:^24.0.3" + command-line-args: "npm:^6.0.1" + command-line-usage: "npm:^7.0.1" + flatbuffers: "npm:^25.1.24" + json-bignum: "npm:^0.0.3" + tslib: "npm:^2.6.2" + bin: + arrow2csv: bin/arrow2csv.js + checksum: 10c0/d689b433d0a07bf8741870bd9ae363a64efe73394f6b0a59eb0732eb33c388d657273a4e2a7f89827add7ad7dcb3680b0a9a34f6f1818b370b3b22b43e06d6e1 + languageName: node + linkType: hard + "app-root-path@npm:*, app-root-path@npm:3.1.0": version: 3.1.0 resolution: "app-root-path@npm:3.1.0" @@ -20041,6 +20415,13 @@ __metadata: languageName: node linkType: hard +"array-back@npm:^6.2.2, array-back@npm:^6.2.3": + version: 6.2.3 + resolution: "array-back@npm:6.2.3" + checksum: 10c0/921bd7857112b8fbe3bc72f3498ca7c1c1652406e966a2468dcb8f67672c43e8b58e734138141c22fc21c357e59eaccc44b7727738a666f1056fc1f646b335df + languageName: node + linkType: hard + "array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": version: 1.0.2 resolution: "array-buffer-byte-length@npm:1.0.2" @@ -21312,6 +21693,15 @@ __metadata: languageName: node linkType: hard +"chalk-template@npm:^0.4.0": + version: 0.4.0 + resolution: "chalk-template@npm:0.4.0" + dependencies: + chalk: "npm:^4.1.2" + checksum: 10c0/6a4cb4252966475f0bd3ee1cd8780146e1ba69f445e59c565cab891ac18708c8143515d23e2b0fb7e192574fb7608d429ea5b28f3b7b9507770ad6fccd3467e3 + languageName: node + linkType: hard + "chalk@npm:4.1.2, chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -21907,6 +22297,23 @@ __metadata: languageName: node linkType: hard +"command-line-args@npm:^6.0.1": + version: 6.0.2 + resolution: "command-line-args@npm:6.0.2" + dependencies: + array-back: "npm:^6.2.3" + find-replace: "npm:^5.0.2" + lodash.camelcase: "npm:^4.3.0" + typical: "npm:^7.3.0" + peerDependencies: + "@75lb/nature": "*" + peerDependenciesMeta: + "@75lb/nature": + optional: true + checksum: 10c0/4d66a1c4ae86bd86d9934ae85410bdedbe8796a060ad4e7cf7b9ec8275044f114b30965436a2c9a91f86d7a4279fb1ae825019f5861ddf048f275c6a0f24aa02 + languageName: node + linkType: hard + "command-line-usage@npm:^5.0.5": version: 5.0.5 resolution: "command-line-usage@npm:5.0.5" @@ -21919,6 +22326,18 @@ __metadata: languageName: node linkType: hard +"command-line-usage@npm:^7.0.1": + version: 7.0.4 + resolution: "command-line-usage@npm:7.0.4" + dependencies: + array-back: "npm:^6.2.2" + chalk-template: "npm:^0.4.0" + table-layout: "npm:^4.1.1" + typical: "npm:^7.3.0" + checksum: 10c0/0ca2118342f0a587b8509ddb47436e2ce566fddbfbdf6ceb470b65f3da2a4f3af03fd4d9768a867410b2741cb1b0bc6fdab7f33306c09af92d699dc1a87b6818 + languageName: node + linkType: hard + "commander@npm:8.3.0, commander@npm:^8.3.0": version: 8.3.0 resolution: "commander@npm:8.3.0" @@ -22566,6 +22985,13 @@ __metadata: languageName: node linkType: hard +"d3-dispatch@npm:1, d3-dispatch@npm:^1.0.3": + version: 1.0.6 + resolution: "d3-dispatch@npm:1.0.6" + checksum: 10c0/6302554a019e2d75d4e3dc7e8757a00b4b12ac2a2952bccc66e4478ccd170f425e2b6a9443118d5feadcd2439f33582b63c7925e832104ff1978cadea2a30dc2 + languageName: node + linkType: hard + "d3-drag@npm:2 - 3, d3-drag@npm:^3.0.0": version: 3.0.0 resolution: "d3-drag@npm:3.0.0" @@ -22576,6 +23002,16 @@ __metadata: languageName: node linkType: hard +"d3-drag@npm:^1.0.4": + version: 1.2.5 + resolution: "d3-drag@npm:1.2.5" + dependencies: + d3-dispatch: "npm:1" + d3-selection: "npm:1" + checksum: 10c0/749cbeaab1867a10086c90467218365ab1967645878463074a63383d9de77fe8b6a7ebe6c5be8fd4e257575db87c9b99a2d76b4d3ebb8b937f3523414fbc9c2b + languageName: node + linkType: hard + "d3-ease@npm:1 - 3, d3-ease@npm:^3.0.1": version: 3.0.1 resolution: "d3-ease@npm:3.0.1" @@ -22583,6 +23019,17 @@ __metadata: languageName: node linkType: hard +"d3-force@npm:3.0.0": + version: 3.0.0 + resolution: "d3-force@npm:3.0.0" + dependencies: + d3-dispatch: "npm:1 - 3" + d3-quadtree: "npm:1 - 3" + d3-timer: "npm:1 - 3" + checksum: 10c0/220a16a1a1ac62ba56df61028896e4b52be89c81040d20229c876efc8852191482c233f8a52bb5a4e0875c321b8e5cb6413ef3dfa4d8fe79eeb7d52c587f52cf + languageName: node + linkType: hard + "d3-format@npm:1 - 3": version: 3.1.2 resolution: "d3-format@npm:3.1.2" @@ -22599,6 +23046,13 @@ __metadata: languageName: node linkType: hard +"d3-path@npm:1": + version: 1.0.9 + resolution: "d3-path@npm:1.0.9" + checksum: 10c0/e35e84df5abc18091f585725b8235e1fa97efc287571585427d3a3597301e6c506dea56b11dfb3c06ca5858b3eb7f02c1bf4f6a716aa9eade01c41b92d497eb5 + languageName: node + linkType: hard + "d3-path@npm:^3.1.0": version: 3.1.0 resolution: "d3-path@npm:3.1.0" @@ -22606,6 +23060,13 @@ __metadata: languageName: node linkType: hard +"d3-quadtree@npm:1 - 3": + version: 3.0.1 + resolution: "d3-quadtree@npm:3.0.1" + checksum: 10c0/18302d2548bfecaef788152397edec95a76400fd97d9d7f42a089ceb68d910f685c96579d74e3712d57477ed042b056881b47cd836a521de683c66f47ce89090 + languageName: node + linkType: hard + "d3-scale@npm:^4.0.2": version: 4.0.2 resolution: "d3-scale@npm:4.0.2" @@ -22619,6 +23080,13 @@ __metadata: languageName: node linkType: hard +"d3-selection@npm:1": + version: 1.4.2 + resolution: "d3-selection@npm:1.4.2" + checksum: 10c0/e755b6b62d794d0b968cc6264f37109e425de0d9fd306ce94414b07e46a2c6830d21c1fe0821a660d07e82069b6fe3b67da1b3b909e8f6af8f16f020cd25cae0 + languageName: node + linkType: hard + "d3-selection@npm:2 - 3, d3-selection@npm:3, d3-selection@npm:^3.0.0": version: 3.0.0 resolution: "d3-selection@npm:3.0.0" @@ -22626,6 +23094,15 @@ __metadata: languageName: node linkType: hard +"d3-shape@npm:^1.3.5": + version: 1.3.7 + resolution: "d3-shape@npm:1.3.7" + dependencies: + d3-path: "npm:1" + checksum: 10c0/548057ce59959815decb449f15632b08e2a1bdce208f9a37b5f98ec7629dda986c2356bc7582308405ce68aedae7d47b324df41507404df42afaf352907577ae + languageName: node + linkType: hard + "d3-shape@npm:^3.1.0": version: 3.2.0 resolution: "d3-shape@npm:3.2.0" @@ -22660,6 +23137,13 @@ __metadata: languageName: node linkType: hard +"d3-timer@npm:^1.0.5": + version: 1.0.10 + resolution: "d3-timer@npm:1.0.10" + checksum: 10c0/7e77030a206861e4e626754c689795d43f036fb07a7f8ca6360eb8b7cbe6f52bf43c9c4297ae9a9a906e4de594212702f83c0cde23d4e20d8689a4211e438155 + languageName: node + linkType: hard + "d3-transition@npm:2 - 3": version: 3.0.1 resolution: "d3-transition@npm:3.0.1" @@ -23507,6 +23991,13 @@ __metadata: languageName: node linkType: hard +"earcut@npm:^2.2.4": + version: 2.2.4 + resolution: "earcut@npm:2.2.4" + checksum: 10c0/01ca51830edd2787819f904ae580087d37351f6048b4565e7add4b3da8a86b7bc19262ab2aa7fdc64129ab03af2d9cec8cccee4d230c82275f97ef285c79aafb + languageName: node + linkType: hard + "easy-table@npm:1.1.0": version: 1.1.0 resolution: "easy-table@npm:1.1.0" @@ -25966,6 +26457,18 @@ __metadata: languageName: node linkType: hard +"find-replace@npm:^5.0.2": + version: 5.0.2 + resolution: "find-replace@npm:5.0.2" + peerDependencies: + "@75lb/nature": "*" + peerDependenciesMeta: + "@75lb/nature": + optional: true + checksum: 10c0/25db7167e8767b0683251a985af82e29b0ac26003fe2cd6ddd86e4f2c83fc10d97a81c6f6686034d8c2b9ee88bc53547bf86cc103479a7323342da5ace9f6504 + languageName: node + linkType: hard + "find-root@npm:^1.1.0": version: 1.1.0 resolution: "find-root@npm:1.1.0" @@ -26050,6 +26553,13 @@ __metadata: languageName: node linkType: hard +"flatbuffers@npm:^25.1.24": + version: 25.9.23 + resolution: "flatbuffers@npm:25.9.23" + checksum: 10c0/957c4ae2a02be1703c98b36b4dc8ceb81613cf8e2333026afc95a7b68b088bed5458056dc29d0ab7ce8bc403b8c003732b0968d24aba46f5e7c8f71789a6bd9e + languageName: node + linkType: hard + "flatted@npm:^3.2.9": version: 3.4.2 resolution: "flatted@npm:3.4.2" @@ -26612,6 +27122,13 @@ __metadata: languageName: node linkType: hard +"gl-matrix@npm:^3.0.0": + version: 3.4.4 + resolution: "gl-matrix@npm:3.4.4" + checksum: 10c0/9aa022ffac0d158212ad0cd29939864ad919ac31cd5dc5a5d35e9d66bb62679ddf152ff7b2173ded20131045e40572b87f31b26a920be2a7583a1516b13b5b4b + languageName: node + linkType: hard + "glob-parent@npm:6.0.2, glob-parent@npm:^6.0.1, glob-parent@npm:^6.0.2": version: 6.0.2 resolution: "glob-parent@npm:6.0.2" @@ -26938,64 +27455,29 @@ __metadata: languageName: node linkType: hard -"graphology-indices@npm:^0.17.0": - version: 0.17.0 - resolution: "graphology-indices@npm:0.17.0" - dependencies: - graphology-utils: "npm:^2.4.2" - mnemonist: "npm:^0.39.0" - peerDependencies: - graphology-types: ">=0.20.0" - checksum: 10c0/e951b3c338539080567859b9e6cc0f9a35d3d0ed5597e3331fa84469d8a1ba629adf46a81f48d883a904804199f031a2abd1973b7e67fed59cc2f7987fd5e467 - languageName: node - linkType: hard - -"graphology-layout-forceatlas2@npm:0.10.1": - version: 0.10.1 - resolution: "graphology-layout-forceatlas2@npm:0.10.1" - dependencies: - graphology-utils: "npm:^2.1.0" - peerDependencies: - graphology-types: ">=0.19.0" - checksum: 10c0/a5d5798503368322ce0b803f2f1528a99be9ec9cf2953f53a6f271e28c67ea3e190812e0271441138c61955935442a967d3d320b38825237c183f208b4d7907e - languageName: node - linkType: hard - -"graphology-layout@npm:0.6.1": - version: 0.6.1 - resolution: "graphology-layout@npm:0.6.1" - dependencies: - graphology-utils: "npm:^2.3.0" - pandemonium: "npm:^2.4.0" - peerDependencies: - graphology-types: ">=0.19.0" - checksum: 10c0/006f5ef849ff8daf2e8f321f75b16a4a9217df0642750d09926cc2206d6f0b896f08fbe9c2ae2f496b67f34462028a65387ed8ca2ba247acfca718c6901830c6 - languageName: node - linkType: hard - -"graphology-shortest-path@npm:2.1.0": - version: 2.1.0 - resolution: "graphology-shortest-path@npm:2.1.0" +"graphology-communities-louvain@npm:2.0.2": + version: 2.0.2 + resolution: "graphology-communities-louvain@npm:2.0.2" dependencies: - "@yomguithereal/helpers": "npm:^1.1.1" graphology-indices: "npm:^0.17.0" - graphology-utils: "npm:^2.4.3" + graphology-utils: "npm:^2.4.4" mnemonist: "npm:^0.39.0" + pandemonium: "npm:^2.4.1" peerDependencies: - graphology-types: ">=0.20.0" - checksum: 10c0/7ede7a89902c6bf84c854902b076ae90c6a1e8c2535ecf19fdeac17169795b3cf367f72ae721fdf360cee501bd9ee4684dc525ae467e77babc747c0a218e05d9 + graphology-types: ">=0.19.0" + checksum: 10c0/efc2007dfcc8ace26b917fbc50feda1914143ae77476498570e68cc1d831a4ee83cc74c9311eec84488eaa6ce5e245b5a84fe5fdb072764edcd64894a9d9ecfe languageName: node linkType: hard -"graphology-simple-path@npm:0.2.0": - version: 0.2.0 - resolution: "graphology-simple-path@npm:0.2.0" +"graphology-indices@npm:^0.17.0": + version: 0.17.0 + resolution: "graphology-indices@npm:0.17.0" dependencies: - graphology-utils: "npm:^1.8.0" + graphology-utils: "npm:^2.4.2" mnemonist: "npm:^0.39.0" peerDependencies: graphology-types: ">=0.20.0" - checksum: 10c0/6e4dcb9447ba4e75fd63be7da2bcc942fdf4b3ad5a346f5b38a801ac24e593f15e4e3a5c41ecdd69cff2b15b993633dff6a667bc57b1f0de1daf7af14128b289 + checksum: 10c0/e951b3c338539080567859b9e6cc0f9a35d3d0ed5597e3331fa84469d8a1ba629adf46a81f48d883a904804199f031a2abd1973b7e67fed59cc2f7987fd5e467 languageName: node linkType: hard @@ -27006,16 +27488,7 @@ __metadata: languageName: node linkType: hard -"graphology-utils@npm:^1.8.0": - version: 1.8.0 - resolution: "graphology-utils@npm:1.8.0" - peerDependencies: - graphology-types: ">=0.19.0" - checksum: 10c0/7737be19f260585368bba5e96377123be7e5e243de561479a59ae97f037be2a6301001fd75306728955cca26a904be27a560ca182cbad0cefc2229cc8dbfb58c - languageName: node - linkType: hard - -"graphology-utils@npm:^2.1.0, graphology-utils@npm:^2.3.0, graphology-utils@npm:^2.4.2, graphology-utils@npm:^2.4.3, graphology-utils@npm:^2.5.2": +"graphology-utils@npm:^2.4.2, graphology-utils@npm:^2.4.4": version: 2.5.2 resolution: "graphology-utils@npm:2.5.2" peerDependencies: @@ -29714,6 +30187,13 @@ __metadata: languageName: node linkType: hard +"json-bignum@npm:^0.0.3": + version: 0.0.3 + resolution: "json-bignum@npm:0.0.3" + checksum: 10c0/f9f9312d57a68f72676802fa087da4ed60241d73b6cc0e3fb9f587ca0de7364efb62612a14414ccfbedc0b77ce3c320adca21834a5673c99eb3375aef9f561db + languageName: node + linkType: hard + "json-buffer@npm:3.0.1": version: 3.0.1 resolution: "json-buffer@npm:3.0.1" @@ -32344,6 +32824,13 @@ __metadata: languageName: node linkType: hard +"mjolnir.js@npm:^3.0.0": + version: 3.0.0 + resolution: "mjolnir.js@npm:3.0.0" + checksum: 10c0/bdbe0b6ab1420917a68fa0faa47351295b189816ad67c9ace36c24ff341f873a7e4d6ea704e33f2a6f9b8fc0ff6b8d17e30ad74aed455da38d97ba52198a6274 + languageName: node + linkType: hard + "mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3": version: 0.5.3 resolution: "mkdirp-classic@npm:0.5.3" @@ -34147,7 +34634,7 @@ __metadata: languageName: node linkType: hard -"pandemonium@npm:^2.4.0": +"pandemonium@npm:^2.4.1": version: 2.4.1 resolution: "pandemonium@npm:2.4.1" dependencies: @@ -35034,6 +35521,13 @@ __metadata: languageName: node linkType: hard +"preact@npm:^10.17.0": + version: 10.29.3 + resolution: "preact@npm:10.29.3" + checksum: 10c0/deede846649c6f31888d36b60de2d2433a85c533268b328af82da11a5c1d9b6f12439bdab6dca3cf7a20720b4137c2f1644c12db21b30b18360766ab70c8fe06 + languageName: node + linkType: hard + "prebuild-install@npm:^7.1.3": version: 7.1.3 resolution: "prebuild-install@npm:7.1.3" @@ -38174,16 +38668,6 @@ __metadata: languageName: node linkType: hard -"sigma@npm:3.0.2": - version: 3.0.2 - resolution: "sigma@npm:3.0.2" - dependencies: - events: "npm:^3.3.0" - graphology-utils: "npm:^2.5.2" - checksum: 10c0/925a8e75248020797988bf70ee4c18ae311164bfdd4ef228549736909732d11c3f338ee0c68a52de7149997817189a6bdc539905d8631bba08baf85bd3ec2517 - languageName: node - linkType: hard - "signal-exit@npm:^3.0.0": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" @@ -39425,6 +39909,16 @@ __metadata: languageName: node linkType: hard +"table-layout@npm:^4.1.1": + version: 4.1.1 + resolution: "table-layout@npm:4.1.1" + dependencies: + array-back: "npm:^6.2.2" + wordwrapjs: "npm:^5.1.0" + checksum: 10c0/26d8e54a55ddb4de447c8f02a8d7fcbb66a9580375e406a3bc7717ab223a413f6dfbded6710f288b3dfd277991813a0bd5a17419a0dc6db54d9a36d883d868dc + languageName: node + linkType: hard + "table@npm:6.9.0": version: 6.9.0 resolution: "table@npm:6.9.0" @@ -40609,6 +41103,13 @@ __metadata: languageName: node linkType: hard +"typical@npm:^7.3.0": + version: 7.3.0 + resolution: "typical@npm:7.3.0" + checksum: 10c0/bee697a88e1dd0447bc1cf7f6e875eaa2b0fb2cccb338b7b261e315f7df84a66402864bfc326d6b3117c50475afd1d49eda03d846a6299ad25f211035bfab3b1 + languageName: node + linkType: hard + "ufo@npm:^1.6.1": version: 1.6.1 resolution: "ufo@npm:1.6.1" @@ -40719,6 +41220,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.18.0": + version: 7.18.2 + resolution: "undici-types@npm:7.18.2" + checksum: 10c0/85a79189113a238959d7a647368e4f7c5559c3a404ebdb8fc4488145ce9426fcd82252a844a302798dfc0e37e6fb178ff481ed03bc4caf634c5757d9ef43521d + languageName: node + linkType: hard + "undici@npm:^7.10.0": version: 7.28.0 resolution: "undici@npm:7.28.0" @@ -41679,6 +42187,74 @@ __metadata: languageName: node linkType: hard +"vitest@npm:4.1.9": + version: 4.1.9 + resolution: "vitest@npm:4.1.9" + dependencies: + "@vitest/expect": "npm:4.1.9" + "@vitest/mocker": "npm:4.1.9" + "@vitest/pretty-format": "npm:4.1.9" + "@vitest/runner": "npm:4.1.9" + "@vitest/snapshot": "npm:4.1.9" + "@vitest/spy": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + es-module-lexer: "npm:^2.0.0" + expect-type: "npm:^1.3.0" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + std-env: "npm:^4.0.0-rc.1" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.1.0" + vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.1.9 + "@vitest/browser-preview": 4.1.9 + "@vitest/browser-webdriverio": 4.1.9 + "@vitest/coverage-istanbul": 4.1.9 + "@vitest/coverage-v8": 4.1.9 + "@vitest/ui": 4.1.9 + happy-dom: "*" + jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/coverage-istanbul": + optional: true + "@vitest/coverage-v8": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vite: + optional: false + bin: + vitest: ./vitest.mjs + checksum: 10c0/1ac80ef4991be82822a52aea48415f1bc64ddf8fd88ee24c172ec368f1d480fefacbde622c3c951982f7961a1d07313e18deaafc774d29e42ad6f6ffa63334a7 + languageName: node + linkType: hard + "vscode-languageserver-types@npm:3.17.5": version: 3.17.5 resolution: "vscode-languageserver-types@npm:3.17.5" @@ -41798,6 +42374,18 @@ __metadata: languageName: node linkType: hard +"webcola@npm:3.4.0": + version: 3.4.0 + resolution: "webcola@npm:3.4.0" + dependencies: + d3-dispatch: "npm:^1.0.3" + d3-drag: "npm:^1.0.4" + d3-shape: "npm:^1.3.5" + d3-timer: "npm:^1.0.5" + checksum: 10c0/509ebbe30f69465ad0df1baf041cebf9ddab999b1edc3c30ab4fbb1a3b0a0cdbb5a759aeb55e42c3a01da3758d3db4b19bd43368c3b1fb959526beabf03a0c6e + languageName: node + linkType: hard + "webextension-polyfill@npm:0.12.0": version: 0.12.0 resolution: "webextension-polyfill@npm:0.12.0" @@ -42296,7 +42884,7 @@ __metadata: languageName: node linkType: hard -"wordwrapjs@npm:5.1.1": +"wordwrapjs@npm:5.1.1, wordwrapjs@npm:^5.1.0": version: 5.1.1 resolution: "wordwrapjs@npm:5.1.1" checksum: 10c0/8e2000ab679e583c119b92f1740b7e55d9eccae0588a449737a974920445c579db14ddcb36efb0e7a52d4c9877adad9e2ea2a416af69626a9a5d22ba06efb62a