From 200c966ab01abec8c943b17882f9c2bead42d503 Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Mon, 6 Jul 2026 07:52:53 -0700 Subject: [PATCH 01/14] fix(points): avoid NaN positions when rescaling zero-extent data rescaleInitialNodePositions divided by the coordinate range without guarding range === 0. A single point, or a dataset where every point shares the same position, produced scaleFactor = Infinity and NaN offsets, corrupting pointPositions in place and rendering nothing. Rescaling is enabled by default when the simulation is disabled, so a one-point scatter plot hit this out of the box. Zero-range data is now translated to the center of the space instead. Co-Authored-By: Claude Fable 5 --- src/modules/Points/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/modules/Points/index.ts b/src/modules/Points/index.ts index 181be1a2..07d782c6 100644 --- a/src/modules/Points/index.ts +++ b/src/modules/Points/index.ts @@ -2630,10 +2630,11 @@ export class Points extends CoreModule { // For sparse datasets: use 10% of space to cluster points closer : spaceSize * 0.1 - // Calculate uniform scale factor to fit data within effective space - const scaleFactor = effectiveSpaceSize / range + // A zero range (single point, or all points sharing one position) would make + // scaleFactor Infinity and every position NaN — translate to the space center instead + const scaleFactor = range > 0 ? effectiveSpaceSize / range : 1 // Shift to center the scaled data within the full [0, spaceSize] space - const centerOffset = (spaceSize - effectiveSpaceSize) / 2 + const centerOffset = range > 0 ? (spaceSize - effectiveSpaceSize) / 2 : spaceSize / 2 // Pad the shorter axis so both axes are centered within the square bounding box const offsetX = ((range - w) / 2) * scaleFactor + centerOffset const offsetY = ((range - h) / 2) * scaleFactor + centerOffset From 28069142319bed2f8958cb9e32124d18b83f34a7 Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Mon, 6 Jul 2026 07:54:12 -0700 Subject: [PATCH 02/14] fix(simulation): run same-cell repulsion for non-power-of-two space sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The many-body force's deepest-level pass (repulsion between points sharing the finest quadtree cell, including the jitter that separates co-located points) was gated on `level === this.levels - 1`, but `levels = log2(spaceSize)` is fractional whenever spaceSize is not a power of two, so the comparison never matched and the pass was silently skipped — co-located points never repelled each other. Fixing the gate alone would have activated a second latent bug: force-centermass.frag sampled the level texture at pos/levelTextureSize, which addresses the correct cell only when the deepest cell size is exactly 1 (power-of-two spaceSize). The shader now receives the cell size and samples the containing cell's center. For power-of-two space sizes this resolves to the same texel as before. Co-Authored-By: Claude Fable 5 --- src/modules/ForceManyBody/force-centermass.frag | 11 +++++++++-- src/modules/ForceManyBody/index.ts | 9 ++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/modules/ForceManyBody/force-centermass.frag b/src/modules/ForceManyBody/force-centermass.frag index eb4a4bd3..f85f5123 100644 --- a/src/modules/ForceManyBody/force-centermass.frag +++ b/src/modules/ForceManyBody/force-centermass.frag @@ -10,15 +10,18 @@ layout(std140) uniform forceCenterUniforms { float levelTextureSize; float alpha; float repulsion; + float cellSize; } forceCenter; #define levelTextureSize forceCenter.levelTextureSize #define repulsion forceCenter.repulsion #define alpha forceCenter.alpha +#define cellSize forceCenter.cellSize #else uniform float levelTextureSize; uniform float alpha; uniform float repulsion; +uniform float cellSize; #endif in vec2 textureCoords; @@ -52,8 +55,12 @@ void main() { vec4 velocity = vec4(0.0); - // Calculate additional velocity based on the point position - velocity.xy += calculateAdditionalVelocity(pointPosition.xy / levelTextureSize, pointPosition.xy); + // Sample the centermass of the cell containing this point. The cell index is + // pos / cellSize; +0.5 targets the texel center (cellSize is 1 only when the + // space size is a power of two, so pos / levelTextureSize would read the + // wrong cell otherwise). + vec2 cellIndex = floor(pointPosition.xy / cellSize); + velocity.xy += calculateAdditionalVelocity((cellIndex + 0.5) / levelTextureSize, pointPosition.xy); // Apply random factor to the velocity velocity.xy += velocity.xy * random.rg; diff --git a/src/modules/ForceManyBody/index.ts b/src/modules/ForceManyBody/index.ts index 8148f7e5..c917c5d4 100644 --- a/src/modules/ForceManyBody/index.ts +++ b/src/modules/ForceManyBody/index.ts @@ -52,6 +52,7 @@ export class ForceManyBody extends CoreModule { levelTextureSize: number; alpha: number; repulsion: number; + cellSize: number; }; }> | undefined @@ -294,11 +295,13 @@ export class ForceManyBody extends CoreModule { levelTextureSize: 'f32', alpha: 'f32', repulsion: 'f32', + cellSize: 'f32', }, defaultUniforms: { levelTextureSize: 0, alpha: store.alpha, repulsion: this.config.simulationRepulsion, + cellSize: 1, }, }, }) @@ -453,6 +456,9 @@ export class ForceManyBody extends CoreModule { clearColor: [0, 0, 0, 0], }) + // `this.levels` is fractional for non-power-of-two space sizes, so the + // deepest level actually iterated is ceil(levels) - 1, not levels - 1. + const deepestLevel = Math.ceil(this.levels) - 1 for (let level = 0; level < this.levels; level += 1) { const target = this.levelTargets.get(level) if (!target || target.texture.destroyed) continue @@ -479,12 +485,13 @@ export class ForceManyBody extends CoreModule { this.forceCommand.draw(drawPass) // Only the deepest level uses the centermass fallback - if (level === this.levels - 1) { + if (level === deepestLevel) { this.forceCenterUniformStore.setUniforms({ forceCenterUniforms: { levelTextureSize, alpha: store.alpha, repulsion: this.config.simulationRepulsion, + cellSize: store.adjustedSpaceSize / levelTextureSize, }, }) From 195943a3f3ad917fc47ba1b3814d4b5b257aab79 Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Mon, 6 Jul 2026 07:56:28 -0700 Subject: [PATCH 03/14] fix(data): validate link endpoint indices Link endpoints were never checked against the point count. Out-of-range indices extended the adjacency arrays past the point count, caused silently-dropped out-of-bounds writes in ForceLink (leaving a valid endpoint pulling toward an unrelated point via the modulo texture lookup), and NaN endpoints flowed into the Lines vertex buffer as garbage texture coordinates. updateLinks() now drops links whose endpoints are not integers in [0, pointsNumber), warns once with the dropped count, and ignores a trailing unpaired value in odd-length arrays. Valid input is still passed through without copying. Co-Authored-By: Claude Fable 5 --- src/modules/GraphData/index.ts | 51 +++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/modules/GraphData/index.ts b/src/modules/GraphData/index.ts index adf0804c..8bb0e528 100644 --- a/src/modules/GraphData/index.ts +++ b/src/modules/GraphData/index.ts @@ -228,7 +228,49 @@ export class GraphData { } public updateLinks (): void { - this.links = this.inputLinks + const input = this.inputLinks + const pointsNumber = this.pointsNumber + if (input === undefined || pointsNumber === undefined) { + this.links = input + return + } + + // Drop links whose endpoints are not valid point indices — out-of-range or + // non-integer values silently corrupt the adjacency lists, cause out-of-bounds + // writes in the link force, and reach the GPU as garbage texture coordinates. + const inputLinksNumber = Math.floor(input.length / 2) + let validLinksNumber = 0 + for (let i = 0; i < inputLinksNumber; i++) { + if (this._isValidLink(input[i * 2], input[i * 2 + 1], pointsNumber)) validLinksNumber += 1 + } + + if (validLinksNumber === inputLinksNumber && input.length % 2 === 0) { + this.links = input + return + } + + if (input.length % 2 !== 0) { + console.warn('cosmos.gl: The links array has an odd length; the trailing value was ignored') + } + if (validLinksNumber !== inputLinksNumber) { + console.warn( + `cosmos.gl: Dropped ${inputLinksNumber - validLinksNumber} of ${inputLinksNumber} links ` + + `whose endpoints are not valid point indices (expected integers in [0, ${pointsNumber}))` + ) + } + + const links = new Float32Array(validLinksNumber * 2) + let j = 0 + for (let i = 0; i < inputLinksNumber; i++) { + const source = input[i * 2] + const target = input[i * 2 + 1] + if (this._isValidLink(source, target, pointsNumber)) { + links[j] = source as number + links[j + 1] = target as number + j += 2 + } + } + this.links = links } /** @@ -438,6 +480,13 @@ export class GraphData { } } + private _isValidLink (source: number | undefined, target: number | undefined, pointsNumber: number): boolean { + return source !== undefined && target !== undefined && + Number.isInteger(source) && Number.isInteger(target) && + source >= 0 && source < pointsNumber && + target >= 0 && target < pointsNumber + } + private _calculateDegrees (): void { if (this.pointsNumber === undefined) { this.degree = undefined From a9df20f7b572643d7609ee7bda2e8376286c16ee Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Mon, 6 Jul 2026 08:00:54 -0700 Subject: [PATCH 04/14] fix: stop multiple Graph instances from clobbering each other Three shared-global collisions broke pages hosting more than one Graph: - The space-key handlers were registered on `document` under the fixed d3 namespace `.cosmos`, so a second instance silently replaced the first instance's handlers, and either instance's destroy() removed the survivor's. Each instance now uses its own `.cosmos-` namespace. - The `--cosmosgl-attribution-color` / `--cosmosgl-error-message-color` CSS variables were written to document.documentElement, making instances with different backgrounds fight over one global value. They are now set on the graph's container div, which the attribution and error elements inherit from. - The FPS monitor widget and its injected style lived on document.body under the global ids #gl-bench / #gl-bench-style; destroying one instance removed whichever widget came first in the DOM, and Graph's destroy() reached into gl-bench internals. The widget is now mounted inside the graph container and cleaned up by FPSMonitor.destroy(), which also fixes the style element leaking on every showFPSMonitor toggle. Co-Authored-By: Claude Fable 5 --- src/index.ts | 20 +++++++++++++------- src/modules/FPSMonitor/index.ts | 12 ++++++++---- src/modules/Store/index.ts | 11 ++++++++--- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index ec94546d..2e8b64fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,13 @@ import { Drag } from '@/graph/modules/Drag' const LONG_PRESS_DURATION_MS = 500 const LONG_PRESS_MOVE_THRESHOLD_PX = 10 +/** + * Monotonic counter so each Graph instance gets its own d3 event namespace on + * `document` — with a shared namespace, instances replace each other's + * handlers and one instance's destroy() removes another's. + */ +let graphInstanceCounter = 0 + export class Graph { /** Current graph configuration. Always fully populated with default values for any unset properties. */ public config: GraphConfigInterface = createDefaultConfig() @@ -67,6 +74,7 @@ export class Graph { */ private _shouldSuppressNextClick = false + private readonly _instanceId = graphInstanceCounter++ private store = new Store() private points: Points | undefined private lines: Lines | undefined @@ -306,8 +314,8 @@ export class Graph { .on('contextmenu.cosmos', this.onContextMenu.bind(this)) select(document) - .on('keydown.cosmos', (event) => { if (event.code === 'Space') this.store.isSpaceKeyPressed = true }) - .on('keyup.cosmos', (event) => { if (event.code === 'Space') this.store.isSpaceKeyPressed = false }) + .on(`keydown.cosmos-${this._instanceId}`, (event) => { if (event.code === 'Space') this.store.isSpaceKeyPressed = true }) + .on(`keyup.cosmos-${this._instanceId}`, (event) => { if (event.code === 'Space') this.store.isSpaceKeyPressed = false }) this.zoomInstance.behavior .on('start.detect', (e: D3ZoomEvent) => { this.currentEvent = e }) @@ -376,7 +384,7 @@ export class Graph { this.store.updateLinkHoveringEnabled(this.config) - if (this.config.showFPSMonitor) this.fpsMonitor = new FPSMonitor(this.canvas) + if (this.config.showFPSMonitor) this.fpsMonitor = new FPSMonitor(this.canvas, this.store.div) if (this.config.randomSeed !== undefined) this.store.addRandomSeed(this.config.randomSeed) @@ -1374,7 +1382,7 @@ export class Graph { .on('.zoom', null) } - select(document).on('.cosmos', null) + select(document).on(`.cosmos-${this._instanceId}`, null) if (this.zoomInstance?.behavior) { this.zoomInstance.behavior @@ -1428,8 +1436,6 @@ export class Graph { this.attributionDivElement.parentNode.removeChild(this.attributionDivElement) } - document.getElementById('gl-bench-style')?.remove() - this.canvasD3Selection = undefined this.attributionDivElement = undefined } @@ -1630,7 +1636,7 @@ export class Graph { } if (prevConfig.showFPSMonitor !== this.config.showFPSMonitor) { if (this.config.showFPSMonitor) { - this.fpsMonitor = new FPSMonitor(this.canvas) + this.fpsMonitor = new FPSMonitor(this.canvas, this.store.div) } else { this.fpsMonitor?.destroy() this.fpsMonitor = undefined diff --git a/src/modules/FPSMonitor/index.ts b/src/modules/FPSMonitor/index.ts index 8764e2af..312472ea 100644 --- a/src/modules/FPSMonitor/index.ts +++ b/src/modules/FPSMonitor/index.ts @@ -1,15 +1,18 @@ -import { select } from 'd3-selection' import GLBench from 'gl-bench' import { benchCSS } from './css' export class FPSMonitor { private bench: GLBench | undefined + private container: HTMLElement - public constructor (canvas: HTMLCanvasElement) { + public constructor (canvas: HTMLCanvasElement, container?: HTMLElement) { + // Scope the widget (and the style element gl-bench injects) to the graph + // container so multiple Graph instances don't remove each other's monitor. + this.container = container ?? document.body this.destroy() const gl = (canvas.getContext('webgl') || canvas.getContext('experimental-webgl')) as WebGL2RenderingContext - this.bench = new GLBench(gl, { css: benchCSS }) + this.bench = new GLBench(gl, { css: benchCSS, dom: this.container }) } public begin (): void { @@ -23,6 +26,7 @@ export class FPSMonitor { public destroy (): void { this.bench = undefined - select('#gl-bench').remove() + this.container.querySelector('#gl-bench')?.remove() + this.container.querySelector('#gl-bench-style')?.remove() } } diff --git a/src/modules/Store/index.ts b/src/modules/Store/index.ts index 2f687321..70ff631c 100644 --- a/src/modules/Store/index.ts +++ b/src/modules/Store/index.ts @@ -180,9 +180,14 @@ export class Store { public set backgroundColor (color: [number, number, number, number]) { this._backgroundColor = color const brightness = rgbToBrightness(color[0], color[1], color[2]) - document.documentElement.style.setProperty('--cosmosgl-attribution-color', brightness > 0.65 ? 'black' : 'white') - document.documentElement.style.setProperty('--cosmosgl-error-message-color', brightness > 0.65 ? 'black' : 'white') - if (this.div) this.div.style.backgroundColor = `rgba(${color[0] * 255}, ${color[1] * 255}, ${color[2] * 255}, ${color[3]})` + if (this.div) { + // Set on the container, not document.documentElement — multiple Graph + // instances with different backgrounds must not fight over global values. + const contrastColor = brightness > 0.65 ? 'black' : 'white' + this.div.style.setProperty('--cosmosgl-attribution-color', contrastColor) + this.div.style.setProperty('--cosmosgl-error-message-color', contrastColor) + this.div.style.backgroundColor = `rgba(${color[0] * 255}, ${color[1] * 255}, ${color[2] * 255}, ${color[3]})` + } this.isDarkenGreyout = brightness < 0.65 } From d8cac2cdfca4a34e0d5be7d51f894c36cba00cee Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Mon, 6 Jul 2026 08:01:49 -0700 Subject: [PATCH 05/14] fix(simulation): keep boundary points in the repulsion quadtree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Positions are clamped to [0, spaceSize] inclusive, but the quadtree binning in calculate-level.vert did not clamp the cell index, so a point sitting exactly on the far boundary landed outside clip space and was dropped from every level — it stopped repelling other points and they piled up on the edge. The collision grid (build-grid.vert) already clamps this way; the level pass now does the same, and the centermass lookup mirrors the clamp. The occupancy test in force-level.frag / force-centermass.frag also treated a cell as empty when any of r/g/b was zero, but r/g are coordinate sums that are legitimately zero for points on the x=0 / y=0 boundary — such cells were ignored as repulsors. Only the count channel (b) indicates occupancy. Co-Authored-By: Claude Fable 5 --- src/modules/ForceManyBody/calculate-level.vert | 9 ++++++++- src/modules/ForceManyBody/force-centermass.frag | 8 ++++++-- src/modules/ForceManyBody/force-level.frag | 4 +++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/modules/ForceManyBody/calculate-level.vert b/src/modules/ForceManyBody/calculate-level.vert index a9bef58c..a03b46fd 100644 --- a/src/modules/ForceManyBody/calculate-level.vert +++ b/src/modules/ForceManyBody/calculate-level.vert @@ -29,7 +29,14 @@ void main() { float n = floor(pointPosition.x / cellSize); float m = floor(pointPosition.y / cellSize); - + + // Positions are clamped to [0, spaceSize] inclusive, so a point sitting + // exactly on the far boundary bins to cell == levelTextureSize, which lies + // outside clip space and would be dropped from every level (the point would + // stop repelling anything). Clamp to the last cell instead. + n = clamp(n, 0.0, levelTextureSize - 1.0); + m = clamp(m, 0.0, levelTextureSize - 1.0); + vec2 levelPosition = 2.0 * (vec2(n, m) + 0.5) / levelTextureSize - 1.0; gl_Position = vec4(levelPosition, 0.0, 1.0); diff --git a/src/modules/ForceManyBody/force-centermass.frag b/src/modules/ForceManyBody/force-centermass.frag index f85f5123..88e168cb 100644 --- a/src/modules/ForceManyBody/force-centermass.frag +++ b/src/modules/ForceManyBody/force-centermass.frag @@ -31,7 +31,9 @@ out vec4 fragColor; vec2 calculateAdditionalVelocity (vec2 ij, vec2 pp) { vec2 add = vec2(0.0); vec4 centermass = texture(levelFbo, ij); - if (centermass.r > 0.0 && centermass.g > 0.0 && centermass.b > 0.0) { + // b is the point count — the only reliable occupancy signal. r/g are + // coordinate sums, which are legitimately 0 for points on the space boundary. + if (centermass.b > 0.0) { vec2 centermassPosition = vec2(centermass.rg / centermass.b); vec2 distVector = pp - centermassPosition; float l = dot(distVector, distVector); @@ -59,7 +61,9 @@ void main() { // pos / cellSize; +0.5 targets the texel center (cellSize is 1 only when the // space size is a power of two, so pos / levelTextureSize would read the // wrong cell otherwise). - vec2 cellIndex = floor(pointPosition.xy / cellSize); + // Clamp mirrors the binning in calculate-level.vert so a point on the far + // space boundary reads the cell it was actually accumulated into. + vec2 cellIndex = clamp(floor(pointPosition.xy / cellSize), 0.0, levelTextureSize - 1.0); velocity.xy += calculateAdditionalVelocity((cellIndex + 0.5) / levelTextureSize, pointPosition.xy); // Apply random factor to the velocity velocity.xy += velocity.xy * random.rg; diff --git a/src/modules/ForceManyBody/force-level.frag b/src/modules/ForceManyBody/force-level.frag index 00f6d8c5..47ae4cd4 100644 --- a/src/modules/ForceManyBody/force-level.frag +++ b/src/modules/ForceManyBody/force-level.frag @@ -40,7 +40,9 @@ const float MAX_LEVELS_NUM = 14.0; vec2 calculateAdditionalVelocity (vec2 ij, vec2 pp) { vec2 add = vec2(0.0); vec4 centermass = texture(levelFbo, ij); - if (centermass.r > 0.0 && centermass.g > 0.0 && centermass.b > 0.0) { + // b is the point count — the only reliable occupancy signal. r/g are + // coordinate sums, which are legitimately 0 for points on the space boundary. + if (centermass.b > 0.0) { vec2 centermassPosition = vec2(centermass.rg / centermass.b); vec2 distVector = pp - centermassPosition; float l = dot(distVector, distVector); From 8dd3b4161382af8c26e237fc4d9c7d641724d467 Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Mon, 6 Jul 2026 08:03:07 -0700 Subject: [PATCH 06/14] fix(simulation): cover the full collision range and unbias cell averages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collision grid's cell size equaled one effective radius, but two touching max-size points interact at up to two effective radii apart, and the 3x3 neighborhood scan only guarantees coverage up to one cell of separation — colliding pairs 1.5-2 cells apart were never tested in any of the four offset passes (the offsets shuffle alignment, they do not extend the search radius). The cell size is now the full interaction range. The per-cell average position/size also included the current point itself in its own cell while the force count excluded it, so the measured distance was biased toward the point (for a 2-point cell the distance came out at half the true separation, overestimating overlap). The self-contribution is now subtracted before averaging. Co-Authored-By: Claude Fable 5 --- .../force-collision-spatial.frag | 19 +++++++++++++------ src/modules/ForceCollision/index.ts | 9 ++++++--- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/modules/ForceCollision/force-collision-spatial.frag b/src/modules/ForceCollision/force-collision-spatial.frag index 0aa5e64a..ff3b948d 100644 --- a/src/modules/ForceCollision/force-collision-spatial.frag +++ b/src/modules/ForceCollision/force-collision-spatial.frag @@ -93,18 +93,25 @@ void main() { float cellCount = cellData.w; if (cellCount < 0.5) continue; // Empty cell - // Scale force by number of points in cell - // Subtract 1 if this is our own cell to avoid self-collision + // In the point's own cell the accumulated sums include the point itself, + // which biases the average position toward the point (halving the + // measured distance for a 2-point cell) — remove the self-contribution + // before averaging, and skip the cell if the point is alone in it. float effectiveCount = cellCount; + vec2 sumPos = cellData.xy; + float sumSize = cellData.z; if (dx == 0 && dy == 0) { - effectiveCount = max(0.0, cellCount - 1.0); + effectiveCount = cellCount - 1.0; + sumPos -= currentPos; + sumSize -= currentSize; } + if (effectiveCount < 0.5) continue; totalNeighbors += effectiveCount; - // Get average position and size in this cell - vec2 avgPos = cellData.xy / cellCount; - float avgSize = cellData.z / cellCount; + // Get average position and size of the other points in this cell + vec2 avgPos = sumPos / effectiveCount; + float avgSize = sumSize / effectiveCount; float otherCollisionRadius = (collisionRadius > 0.0 ? collisionRadius : avgSize * 0.5) + collisionPadding; // Calculate combined collision radius diff --git a/src/modules/ForceCollision/index.ts b/src/modules/ForceCollision/index.ts index 15f16562..f3ecfee4 100644 --- a/src/modules/ForceCollision/index.ts +++ b/src/modules/ForceCollision/index.ts @@ -76,9 +76,12 @@ export class ForceCollision extends CoreModule { const collisionPadding = config.simulationCollisionPadding ?? 0 const effectiveRadius = (collisionRadius > 0 ? collisionRadius : maxSize * 0.5) + collisionPadding - // Cell size = collision radius (smaller cells = better accuracy). - // We use multiple offset passes to catch boundary collisions. - this.cellSize = Math.max(effectiveRadius, 8) + // Two touching max-size points interact up to 2 × effectiveRadius apart, + // and the 3x3 neighborhood scan only guarantees coverage up to one cell of + // separation — so the cell must be at least the full interaction range. + // The offset passes shuffle cell alignment but do not extend the search + // radius. (The multiple offset passes catch boundary collisions.) + this.cellSize = Math.max(effectiveRadius * 2, 8) // Grid texture size = space size / cell size, clamped to reasonable values this.gridTextureSize = Math.min( From b6e28463293a491be94151f98e52f281fbf450d2 Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Mon, 6 Jul 2026 08:04:01 -0700 Subject: [PATCH 07/14] perf(simulation): drop redundant zero-fill of quadtree level textures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ForceManyBody.create() zero-filled every quadtree level texture through freshly allocated Float32Arrays — at the default spaceSize of 4096 that is ~350 MB of transient CPU allocation plus the same volume of texture uploads on every data update (every setPointPositions call), with the deepest level alone accounting for 268 MB. The zeroing is unnecessary: drawLevels() begins every level's render pass with clearColor [0, 0, 0, 0] before anything samples the texture, run() is gated until create() has run for the current sizes, and WebGL2 zero-initializes new textures anyway. Co-Authored-By: Claude Fable 5 --- src/modules/ForceManyBody/index.ts | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/modules/ForceManyBody/index.ts b/src/modules/ForceManyBody/index.ts index c917c5d4..9b222c10 100644 --- a/src/modules/ForceManyBody/index.ts +++ b/src/modules/ForceManyBody/index.ts @@ -70,19 +70,16 @@ export class ForceManyBody extends CoreModule { const levelTextureSize = Math.pow(2, level + 1) const existingTarget = this.levelTargets.get(level) + // No need to clear retained (or fresh) level textures here: drawLevels() + // begins every level's render pass with clearColor [0, 0, 0, 0] before + // anything samples it, and run() is gated until create() has run. + // Zero-filling them through CPU arrays cost ~350 MB of allocation and + // upload per data update at the default space size. if ( existingTarget && existingTarget.texture.width === levelTextureSize && existingTarget.texture.height === levelTextureSize ) { - // Clear existing texture data to zero - existingTarget.texture.copyImageData({ - data: new Float32Array(levelTextureSize * levelTextureSize * 4).fill(0), - bytesPerRow: getBytesPerRow('rgba32float', levelTextureSize), - mipLevel: 0, - x: 0, - y: 0, - }) continue } @@ -98,13 +95,6 @@ export class ForceManyBody extends CoreModule { format: 'rgba32float', usage: Texture.SAMPLE | Texture.RENDER | Texture.COPY_DST, }) - texture.copyImageData({ - data: new Float32Array(levelTextureSize * levelTextureSize * 4).fill(0), - bytesPerRow: getBytesPerRow('rgba32float', levelTextureSize), - mipLevel: 0, - x: 0, - y: 0, - }) const fbo = device.createFramebuffer({ width: levelTextureSize, height: levelTextureSize, From 69d63cbb98338431b50a4850b12915f814e41eb2 Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Mon, 6 Jul 2026 08:09:25 -0700 Subject: [PATCH 08/14] perf(data): skip revalidation of unchanged data in GraphData.update() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GraphData.update() ran every channel-validation loop, reallocated the derived shape/image/arrow arrays, and rebuilt the adjacency lists and degree arrays on every render() — O(points + links) CPU work and allocation churn even when nothing changed (millions of per-element isNumber calls per frame for large graphs streaming a single channel). Input channels now sit behind accessors that mark a per-channel dirty flag on assignment, and update() only revalidates dirty channels. Channels are also refreshed when the positions array changes, since their validation depends on the point count; links (and the adjacency lists, degrees, and link channels derived from them) are refreshed when the links input or the positions change. The per-channel update methods stay public and unconditional for the config-change paths that call them directly. The public API is unchanged: the input* fields keep working as plain assignments from the Graph setters. Co-Authored-By: Claude Fable 5 --- src/modules/GraphData/index.ts | 183 +++++++++++++++++++++++++++------ 1 file changed, 154 insertions(+), 29 deletions(-) diff --git a/src/modules/GraphData/index.ts b/src/modules/GraphData/index.ts index 8bb0e528..a63daa50 100644 --- a/src/modules/GraphData/index.ts +++ b/src/modules/GraphData/index.ts @@ -16,18 +16,7 @@ export enum PointShape { export class GraphData { public inputPointPositions: Float32Array | undefined - public inputPointColors: Float32Array | undefined - public inputPointSizes: Float32Array | undefined - public inputPointShapes: Float32Array | undefined public inputImageData: ImageData[] | undefined - public inputPointImageIndices: Float32Array | undefined - public inputPointImageSizes: Float32Array | undefined - public inputLinkColors: Float32Array | undefined - public inputLinkWidths: Float32Array | undefined - public inputLinkStrength: Float32Array | undefined - public inputPointClusters: (number | undefined)[] | undefined - public inputClusterPositions: (number | undefined)[] | undefined - public inputClusterStrength: Float32Array | undefined public inputPinnedPoints: number[] | undefined public pointPositions: Float32Array | undefined @@ -49,11 +38,9 @@ export class GraphData { public pointImageIndices: Float32Array | undefined public pointImageSizes: Float32Array | undefined - public inputLinks: Float32Array | undefined public links: Float32Array | undefined public linkColors: Float32Array | undefined public linkWidths: Float32Array | undefined - public linkArrowsBoolean: boolean[] | undefined public linkArrows: number[] | undefined public linkStrength: Float32Array | undefined @@ -74,6 +61,35 @@ export class GraphData { public outDegree: number[] | undefined private _config: GraphConfigInterface + // Input channels sit behind accessors so every assignment marks the channel + // dirty and update() can skip revalidating data that did not change. + private _inputPointColors: Float32Array | undefined + private _inputPointSizes: Float32Array | undefined + private _inputPointShapes: Float32Array | undefined + private _inputPointImageIndices: Float32Array | undefined + private _inputPointImageSizes: Float32Array | undefined + private _inputLinks: Float32Array | undefined + private _inputLinkColors: Float32Array | undefined + private _inputLinkWidths: Float32Array | undefined + private _linkArrowsBoolean: boolean[] | undefined + private _inputLinkStrength: Float32Array | undefined + private _inputPointClusters: (number | undefined)[] | undefined + private _inputClusterPositions: (number | undefined)[] | undefined + private _inputClusterStrength: Float32Array | undefined + + // Dirty flags start true so the first update() processes every channel. + private _arePointColorsDirty = true + private _arePointSizesDirty = true + private _arePointShapesDirty = true + private _arePointImageIndicesDirty = true + private _arePointImageSizesDirty = true + private _areLinksDirty = true + private _areLinkColorsDirty = true + private _areLinkWidthsDirty = true + private _areLinkArrowsDirty = true + private _isLinkStrengthDirty = true + private _areClustersDirty = true + public constructor (config: GraphConfigInterface) { this._config = config } @@ -86,6 +102,85 @@ export class GraphData { return this.links && this.links.length / 2 } + public get inputPointColors (): Float32Array | undefined { return this._inputPointColors } + public get inputPointSizes (): Float32Array | undefined { return this._inputPointSizes } + public get inputPointShapes (): Float32Array | undefined { return this._inputPointShapes } + public get inputPointImageIndices (): Float32Array | undefined { return this._inputPointImageIndices } + public get inputPointImageSizes (): Float32Array | undefined { return this._inputPointImageSizes } + public get inputLinks (): Float32Array | undefined { return this._inputLinks } + public get inputLinkColors (): Float32Array | undefined { return this._inputLinkColors } + public get inputLinkWidths (): Float32Array | undefined { return this._inputLinkWidths } + public get linkArrowsBoolean (): boolean[] | undefined { return this._linkArrowsBoolean } + public get inputLinkStrength (): Float32Array | undefined { return this._inputLinkStrength } + public get inputPointClusters (): (number | undefined)[] | undefined { return this._inputPointClusters } + public get inputClusterPositions (): (number | undefined)[] | undefined { return this._inputClusterPositions } + public get inputClusterStrength (): Float32Array | undefined { return this._inputClusterStrength } + + public set inputPointColors (value: Float32Array | undefined) { + this._inputPointColors = value + this._arePointColorsDirty = true + } + + public set inputPointSizes (value: Float32Array | undefined) { + this._inputPointSizes = value + this._arePointSizesDirty = true + } + + public set inputPointShapes (value: Float32Array | undefined) { + this._inputPointShapes = value + this._arePointShapesDirty = true + } + + public set inputPointImageIndices (value: Float32Array | undefined) { + this._inputPointImageIndices = value + this._arePointImageIndicesDirty = true + } + + public set inputPointImageSizes (value: Float32Array | undefined) { + this._inputPointImageSizes = value + this._arePointImageSizesDirty = true + } + + public set inputLinks (value: Float32Array | undefined) { + this._inputLinks = value + this._areLinksDirty = true + } + + public set inputLinkColors (value: Float32Array | undefined) { + this._inputLinkColors = value + this._areLinkColorsDirty = true + } + + public set inputLinkWidths (value: Float32Array | undefined) { + this._inputLinkWidths = value + this._areLinkWidthsDirty = true + } + + public set linkArrowsBoolean (value: boolean[] | undefined) { + this._linkArrowsBoolean = value + this._areLinkArrowsDirty = true + } + + public set inputLinkStrength (value: Float32Array | undefined) { + this._inputLinkStrength = value + this._isLinkStrengthDirty = true + } + + public set inputPointClusters (value: (number | undefined)[] | undefined) { + this._inputPointClusters = value + this._areClustersDirty = true + } + + public set inputClusterPositions (value: (number | undefined)[] | undefined) { + this._inputClusterPositions = value + this._areClustersDirty = true + } + + public set inputClusterStrength (value: Float32Array | undefined) { + this._inputClusterStrength = value + this._areClustersDirty = true + } + public updatePoints (): void { // Don't sync the same positions twice — it breaks animations when points are added or removed. if (this.pointPositions === this.inputPointPositions) return @@ -380,24 +475,54 @@ export class GraphData { } } + /** + * Applies pending input changes. Channels whose input was not re-assigned + * since the last update are skipped — revalidating every channel and + * rebuilding the adjacency lists on every render() is O(points + links) of + * CPU work and allocation that is wasted when nothing changed. + */ public update (): void { + // Mirrors the reference guard inside updatePoints(): a new positions array + // changes the point count every derived channel is validated against. + const pointsChanged = this.pointPositions !== this.inputPointPositions + // Link validation depends on the point count, so links (and everything + // derived from them) are also refreshed when the positions change. + const linksChanged = this._areLinksDirty || pointsChanged + this.updatePoints() - this.updatePointColor() - this.updatePointSize() - this.updatePointShape() - this.updatePointImageIndices() - this.updatePointImageSizes() - - this.updateLinks() - this.updateLinkColor() - this.updateLinkWidth() - this.updateArrows() - this.updateLinkStrength() - - this.updateClusters() - - this._createAdjacencyLists() - this._calculateDegrees() + + if (pointsChanged || this._arePointColorsDirty) this.updatePointColor() + if (pointsChanged || this._arePointSizesDirty) this.updatePointSize() + if (pointsChanged || this._arePointShapesDirty) this.updatePointShape() + if (pointsChanged || this._arePointImageIndicesDirty) this.updatePointImageIndices() + // Image sizes fall back to a copy of point sizes when not provided, + // so they depend on the sizes channel as well. + if (pointsChanged || this._arePointImageSizesDirty || this._arePointSizesDirty) this.updatePointImageSizes() + + if (linksChanged) this.updateLinks() + if (linksChanged || this._areLinkColorsDirty) this.updateLinkColor() + if (linksChanged || this._areLinkWidthsDirty) this.updateLinkWidth() + if (linksChanged || this._areLinkArrowsDirty) this.updateArrows() + if (linksChanged || this._isLinkStrengthDirty) this.updateLinkStrength() + + if (pointsChanged || this._areClustersDirty) this.updateClusters() + + if (linksChanged) { + this._createAdjacencyLists() + this._calculateDegrees() + } + + this._arePointColorsDirty = false + this._arePointSizesDirty = false + this._arePointShapesDirty = false + this._arePointImageIndicesDirty = false + this._arePointImageSizesDirty = false + this._areLinksDirty = false + this._areLinkColorsDirty = false + this._areLinkWidthsDirty = false + this._areLinkArrowsDirty = false + this._isLinkStrengthDirty = false + this._areClustersDirty = false } /** From 545c8794d82b83fb2ebfb717f25dce5cb3bfc2bd Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Mon, 6 Jul 2026 10:25:54 -0700 Subject: [PATCH 09/14] fix(lines): make link hover work when enabled after initialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabling link-hover callbacks via setConfig only flipped the store flag; the screen-sized picking FBO is created lazily behind that flag (in Lines.initPrograms or on resize), so it never existed when hover was enabled at runtime. Lines.findHoveredLine() then bailed early while the Graph readback still consumed the zero-initialized 1x1 result texture — 0 >= 0 passed the index check, so link 0 was reported as hovered everywhere: spurious onLinkMouseOver(0), link cursor over empty space, and onLinkClick(0) on background clicks until the first window resize. The config branch now allocates the picking FBO, and the readback validates the shader's alpha flag (1 on a hit, 0 on a miss or on a never-rendered texture) instead of only checking index >= 0. Co-Authored-By: Claude Fable 5 --- src/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 2e8b64fd..1e7eb523 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1651,6 +1651,11 @@ export class Graph { prevConfig.onLinkMouseOver !== this.config.onLinkMouseOver || prevConfig.onLinkMouseOut !== this.config.onLinkMouseOut) { this.store.updateLinkHoveringEnabled(this.config) + // The picking FBO is created lazily behind the isLinkHoveringEnabled + // flag, so enabling hover at runtime must allocate it — otherwise + // findHoveredLine() bails while the readback still consumes the + // zero-initialized result texture and reports link 0 as hovered. + this.lines?.updateLinkIndexFbo() } } @@ -2294,8 +2299,11 @@ export class Graph { if (!this.device) return { mouseover: false, mouseout: false } const pixels = readPixels(this.device, this.lines.hoveredLineIndexFbo!) const hoveredLineIndex = pixels[0] as number + // The picking shader writes alpha 1 on a hit and (-1, 0, 0, 0) on a miss; + // a zero alpha also covers a result texture that was never rendered to. + const isHit = (pixels[3] as number) > 0 && hoveredLineIndex >= 0 - if (hoveredLineIndex >= 0) { + if (isHit) { if (this.store.hoveredLinkIndex !== hoveredLineIndex) isMouseover = true this.store.hoveredLinkIndex = hoveredLineIndex } else { From 1a90b79917e22823555abde27fd9b84f8b844caf Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Mon, 6 Jul 2026 10:26:29 -0700 Subject: [PATCH 10/14] fix: guard hover readbacks against missing picking targets pointerdown calls findHoveredItem(true) directly, but the point and link picking framebuffers are only created in initPrograms(), which first runs inside render(). Clicking the canvas after initialization but before the first render() passed undefined into readPixels (hidden by an `as Framebuffer` cast and a non-null assertion) and threw from inside the event handler. Bail out of hover detection until the targets exist. Co-Authored-By: Claude Fable 5 --- src/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 1e7eb523..d69c14f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2256,6 +2256,9 @@ export class Graph { /** Detect hovered point and update store state. Returns flags for deferred callback firing. */ private findHoveredPoint (): { mouseover: boolean; mouseout: boolean } { if (this._isDestroyed || !this.device || !this.points) return { mouseover: false, mouseout: false } + // The picking FBO is created in initPrograms(), which first runs on + // render() — a pointerdown before that must not read a missing target. + if (!this.points.hoveredFbo) return { mouseover: false, mouseout: false } this.points.findHoveredPoint() let isMouseover = false let isMouseout = false @@ -2297,7 +2300,10 @@ export class Graph { let isMouseout = false if (!this.device) return { mouseover: false, mouseout: false } - const pixels = readPixels(this.device, this.lines.hoveredLineIndexFbo!) + // The result FBO is created in initPrograms(), which first runs on + // render() — a pointerdown before that must not read a missing target. + if (!this.lines.hoveredLineIndexFbo) return { mouseover: false, mouseout: false } + const pixels = readPixels(this.device, this.lines.hoveredLineIndexFbo) const hoveredLineIndex = pixels[0] as number // The picking shader writes alpha 1 on a hit and (-1, 0, 0, 0) on a miss; // a zero alpha also covers a result texture that was never rendered to. From f258b1a5a97b4dc6642688b258f643ddee96eb38 Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Mon, 6 Jul 2026 11:49:02 -0700 Subject: [PATCH 11/14] fix(config): apply runtime config changes that were silently ignored Four config properties accepted new values via setConfig without any effect (or with a wrong one): - pointSamplingDistance / linkSamplingDistance were only read when the sampling grids were rebuilt on canvas resize or data recreation, so a runtime change did nothing until the window resized. The config diff now rebuilds the grids. - pointDefaultSize left point image sizes stale forever: they default to a copy of point sizes, but the config branch only refreshed the size channel. It now refreshes image sizes too. - enableZoom: false only detached wheel.zoom, leaving double-click and pinch zoom active. A d3-zoom filter now blocks the scale-changing gestures (wheel, dblclick, multi-touch) while keeping panning alive. - transitionDuration was read live on every Transition.step(), so a mid-animation config change snapped progress (backwards for larger values). The duration is now captured at start(). Co-Authored-By: Claude Fable 5 --- src/index.ts | 9 +++++++++ src/modules/Transition/index.ts | 13 +++++++++---- src/modules/Zoom/index.ts | 11 +++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index d69c14f2..9111eb9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1550,6 +1550,9 @@ export class Graph { if (prevConfig.pointDefaultSize !== this.config.pointDefaultSize) { this.graph.updatePointSize() this.points?.updateSize() + // Image sizes default to a copy of point sizes, so they follow this change. + this.graph.updatePointImageSizes() + this.points?.updateImageSizes() } if (prevConfig.pointDefaultShape !== this.config.pointDefaultShape) { this.graph.updatePointShape() @@ -1645,6 +1648,12 @@ export class Graph { if (prevConfig.enableZoom !== this.config.enableZoom || prevConfig.enableDrag !== this.config.enableDrag) { this.updateZoomDragBehaviors() } + if (prevConfig.pointSamplingDistance !== this.config.pointSamplingDistance) { + this.points?.updateSampledPointsGrid() + } + if (prevConfig.linkSamplingDistance !== this.config.linkSamplingDistance) { + this.lines?.updateSampledLinksGrid() + } if (prevConfig.onLinkClick !== this.config.onLinkClick || prevConfig.onLinkContextMenu !== this.config.onLinkContextMenu || diff --git a/src/modules/Transition/index.ts b/src/modules/Transition/index.ts index b561c393..087fbf08 100644 --- a/src/modules/Transition/index.ts +++ b/src/modules/Transition/index.ts @@ -61,6 +61,12 @@ export class Transition { private readonly config: GraphConfigInterface private startTime = 0 + /** + * Duration captured at `start()`. `step()` must not read the live config + * value: changing `transitionDuration` mid-cycle would make progress jump + * (backwards for a larger value, forward for a smaller one). + */ + private duration = 0 /** Properties queued via `queue()`, awaiting `start()` to consume them. */ private pendingProperties = new Set() /** Properties currently animating in the running cycle. */ @@ -128,6 +134,7 @@ export class Transition { } this.startTime = performance.now() + this.duration = transitionDuration this.progress = 0 this.activeProperties = new Set(this.pendingProperties) this.pendingProperties.clear() @@ -145,14 +152,12 @@ export class Transition { public step (): void { if (!this.isActive) return - const { transitionDuration } = this.config - - if (transitionDuration <= 0) { + if (this.duration <= 0) { this.end(true) return } - const linear = Math.min((performance.now() - this.startTime) / transitionDuration, 1) + const linear = Math.min((performance.now() - this.startTime) / this.duration, 1) const eased = this.applyEasing(linear) this.progress = eased this.config.onTransition?.(eased) diff --git a/src/modules/Zoom/index.ts b/src/modules/Zoom/index.ts index 6b3310c7..cb66680b 100644 --- a/src/modules/Zoom/index.ts +++ b/src/modules/Zoom/index.ts @@ -10,6 +10,17 @@ export class Zoom { public eventTransform = zoomIdentity public behavior = zoom() .scaleExtent([0.001, Infinity]) + .filter((event: MouseEvent | WheelEvent | TouchEvent): boolean => { + // With zooming disabled only `wheel.zoom` used to be detached, leaving + // double-click and pinch zoom active. Panning must keep working, so + // block just the scale-changing gestures here instead. + if (!this.config.enableZoom) { + if (event.type === 'wheel' || event.type === 'dblclick') return false + if ('touches' in event && event.touches.length > 1) return false + } + // Mirrors d3-zoom's default filter. + return (!event.ctrlKey || event.type === 'wheel') && ((event as MouseEvent).button ?? 0) === 0 + }) .on('start', (e: D3ZoomEvent) => { this.isRunning = true // User-driven zooms (scroll, pinch) clear any programmatic override From a4f73161987a15e354eab6463e5b6896be0cef9c Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Mon, 6 Jul 2026 11:49:45 -0700 Subject: [PATCH 12/14] fix(points): exclude padding texels from rect/polygon selection results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The position texture is square, so texels past pointsNumber are padding initialized to position (0, 0). The rect/polygon search passes run over the whole texture and mark every texel whose transformed position falls inside the searched area — whenever the screen location of the space origin was inside the selection, all padding texels matched and findPointsInRect/findPointsInPolygon returned phantom indices >= pointsNumber. extractIndicesFromPixels now takes the real point count and stops there. The parameter is optional, so external callers of the exported helper keep their previous behavior. Co-Authored-By: Claude Fable 5 --- src/helper.ts | 11 ++++++++--- src/index.ts | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/helper.ts b/src/helper.ts index 9a717a2b..285bf862 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -92,11 +92,16 @@ export function readPixels (device: Device, fbo: Framebuffer, sourceX = 0, sourc /** * Extracts point indices from a pixel readback buffer. * Every 4th value (R channel) is checked — non-zero means the point at that index was found. + * @param maxCount - Number of real points in the texture. The texture is square, + * so texels past this count are padding; padding texels hold position (0, 0) and + * would otherwise be reported as phantom indices whenever the space origin falls + * inside the searched area. */ -export function extractIndicesFromPixels (pixels: Float32Array): number[] { +export function extractIndicesFromPixels (pixels: Float32Array, maxCount?: number): number[] { const result: number[] = [] - for (let i = 0; i < pixels.length; i += 4) { - if (pixels[i] !== 0) result.push(i / 4) + const count = Math.min(pixels.length / 4, maxCount ?? Infinity) + for (let i = 0; i < count; i++) { + if (pixels[i * 4] !== 0) result.push(i) } return result } diff --git a/src/index.ts b/src/index.ts index 9111eb9f..7c7f80f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1072,7 +1072,7 @@ export class Graph { const h = this.store.screenSize[1] this.store.searchArea = [[rect[0][0], (h - rect[1][1])], [rect[1][0], (h - rect[0][1])]] if (!this.points.findPointsInRect()) return [] - return extractIndicesFromPixels(readPixels(this.device, this.points.searchFbo as Framebuffer)) + return extractIndicesFromPixels(readPixels(this.device, this.points.searchFbo as Framebuffer), this.graph.pointsNumber) } /** @@ -1099,7 +1099,7 @@ export class Graph { const convertedPath = polygonPath.map(([x, y]) => [x, h - y] as [number, number]) this.points.updatePolygonPath(convertedPath) if (!this.points.findPointsInPolygon()) return [] - return extractIndicesFromPixels(readPixels(this.device, this.points.searchFbo as Framebuffer)) + return extractIndicesFromPixels(readPixels(this.device, this.points.searchFbo as Framebuffer), this.graph.pointsNumber) } /** From 92dec9f3fb3ecf4244308fa864ee998cdf40de16 Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Mon, 6 Jul 2026 11:51:20 -0700 Subject: [PATCH 13/14] fix: close NaN poisoning paths in geometry and simulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four independent inputs could inject NaN into positions or geometry: - A NaN (or negative/non-finite) value in setLinkStrength bypassed the `??` fallback in ForceLink, and Math.sqrt wrote NaN into the strength texture — one poisoned link made both endpoints' positions NaN and the points vanished. Invalid values now fall back to the degree-based default. - Self-loop links (source == target) hit normalize(vec2(0.0)) in draw-curve-line.vert and fill-sampled-links.vert, producing NaN vertices, and the arrow-length math divided by a zero link length. Degenerate directions now use a fixed basis and the division is clamped. - A point exactly at the mouse position fed atan(0.0, 0.0) — undefined per the GLSL spec — into the right-click repulsion velocity; the force is now skipped for a zero distance vector. - An odd-length setPointPositions array produced a fractional point count, and `new Array(n)` deep inside render() threw an unrelated RangeError. The trailing value is now dropped with a warning. Also adds the missing early return in updateLinkStrength, which only worked by coincidence when linksNumber was undefined. Co-Authored-By: Claude Fable 5 --- src/modules/ForceLink/index.ts | 10 ++++++++-- src/modules/ForceMouse/force-mouse.frag | 17 +++++++++++------ src/modules/GraphData/index.ts | 8 ++++++++ src/modules/Lines/draw-curve-line.vert | 11 +++++++---- src/modules/Lines/fill-sampled-links.vert | 3 ++- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/modules/ForceLink/index.ts b/src/modules/ForceLink/index.ts index 38ea666f..b97a485a 100644 --- a/src/modules/ForceLink/index.ts +++ b/src/modules/ForceLink/index.ts @@ -65,8 +65,14 @@ export class ForceLink extends CoreModule { // Prevent division by zero const bias = degreeSum !== 0 ? degree / degreeSum : 0.5 const minDegree = Math.min(degree, connectedDegree) - // Prevent division by zero - let strength = data.linkStrength?.[initialLinkIndex] ?? (1 / Math.max(minDegree, 1)) + // NaN (and negative/non-finite) strength values slip through `??`; + // Math.sqrt would then write NaN into the strength texture, and one + // poisoned link makes both endpoints' positions NaN. Fall back to + // the degree-based default (which also prevents division by zero). + let strength = data.linkStrength?.[initialLinkIndex] + if (strength === undefined || !Number.isFinite(strength) || strength < 0) { + strength = 1 / Math.max(minDegree, 1) + } strength = Math.sqrt(strength) linkBiasAndStrengthState[linkIndex * 4 + 0] = bias linkBiasAndStrengthState[linkIndex * 4 + 1] = strength diff --git a/src/modules/ForceMouse/force-mouse.frag b/src/modules/ForceMouse/force-mouse.frag index ca44a6da..3bac63ec 100644 --- a/src/modules/ForceMouse/force-mouse.frag +++ b/src/modules/ForceMouse/force-mouse.frag @@ -23,13 +23,18 @@ void main() { vec4 pointPosition = texture(positionsTexture, textureCoords); vec4 velocity = vec4(0.0); vec2 mouse = mousePos; - // Move particles away from the mouse position using a repulsive force + // Move particles away from the mouse position using a repulsive force. + // A point exactly at the mouse position has no direction — atan(0.0, 0.0) + // is undefined per the GLSL spec and can produce NaN velocity that + // friction never removes. vec2 distVector = mouse - pointPosition.rg; - float dist = sqrt(dot(distVector, distVector)); - dist = max(dist, 10.0); - float angle = atan(distVector.y, distVector.x); - float addV = 100.0 * repulsion / (dist * dist); - velocity.rg -= addV * vec2(cos(angle), sin(angle)); + float l = dot(distVector, distVector); + if (l > 0.0) { + float dist = max(sqrt(l), 10.0); + float angle = atan(distVector.y, distVector.x); + float addV = 100.0 * repulsion / (dist * dist); + velocity.rg -= addV * vec2(cos(angle), sin(angle)); + } fragColor = velocity; } \ No newline at end of file diff --git a/src/modules/GraphData/index.ts b/src/modules/GraphData/index.ts index a63daa50..42248509 100644 --- a/src/modules/GraphData/index.ts +++ b/src/modules/GraphData/index.ts @@ -182,6 +182,13 @@ export class GraphData { } public updatePoints (): void { + if (this.inputPointPositions && this.inputPointPositions.length % 2 !== 0) { + console.warn('cosmos.gl: The point positions array has an odd length; the trailing value was ignored') + // Normalize the stored input (a view, no copy) so later updates compare + // equal and a fractional point count never reaches derived array + // allocations — new Array(n) throws a RangeError for fractional n. + this.inputPointPositions = this.inputPointPositions.subarray(0, this.inputPointPositions.length - 1) + } // Don't sync the same positions twice — it breaks animations when points are added or removed. if (this.pointPositions === this.inputPointPositions) return @@ -443,6 +450,7 @@ export class GraphData { public updateLinkStrength (): void { if (this.linksNumber === undefined) { this.linkStrength = undefined + return } if (this.inputLinkStrength === undefined || this.inputLinkStrength.length !== this.linksNumber) { diff --git a/src/modules/Lines/draw-curve-line.vert b/src/modules/Lines/draw-curve-line.vert index cb7e0b34..71df9f45 100644 --- a/src/modules/Lines/draw-curve-line.vert +++ b/src/modules/Lines/draw-curve-line.vert @@ -157,10 +157,12 @@ void main() { // Calculate direction vector and its perpendicular vec2 xBasis = b - a; - vec2 yBasis = normalize(vec2(-xBasis.y, xBasis.x)); - - // Calculate link distance and control point for curved link float linkDist = length(xBasis); + // Self-loops (a == b) have no direction — normalize(vec2(0.0)) yields NaN + // vertices and the link (or others sharing the strip) silently disappears. + vec2 yBasis = linkDist > 0.0 ? normalize(vec2(-xBasis.y, xBasis.x)) : vec2(0.0, 1.0); + + // Calculate control point for curved link float h = curvedLinkControlPointDistance; vec2 controlPoint = (a + b) / 2.0 + yBasis * linkDist * h; @@ -190,7 +192,8 @@ void main() { // Calculate arrow length proportional to its width // 0.866 is approximately sqrt(3)/2 - related to equilateral triangle geometry // Cap the length to avoid overly long arrows on short links - arrowLength = min(0.3, (0.866 * arrowWidthPx * 2.0) / linkDist); + // (max() keeps zero-length self-loops from dividing by zero) + arrowLength = min(0.3, (0.866 * arrowWidthPx * 2.0) / max(linkDist, 1e-6)); useArrow = arrow; if (useArrow > 0.5) { diff --git a/src/modules/Lines/fill-sampled-links.vert b/src/modules/Lines/fill-sampled-links.vert index 669aee32..084e559a 100644 --- a/src/modules/Lines/fill-sampled-links.vert +++ b/src/modules/Lines/fill-sampled-links.vert @@ -53,8 +53,9 @@ void main() { mid = (a + b) * 0.5; } else if (curvedLinkControlPointDistance != 0.0 && curvedWeight != 0.0) { vec2 xBasis = b - a; - vec2 yBasis = normalize(vec2(-xBasis.y, xBasis.x)); float linkDist = length(xBasis); + // Self-loops (a == b) have no direction — normalize(vec2(0.0)) yields NaN. + vec2 yBasis = linkDist > 0.0 ? normalize(vec2(-xBasis.y, xBasis.x)) : vec2(0.0, 1.0); float h = curvedLinkControlPointDistance; vec2 controlPoint = (a + b) / 2.0 + yBasis * linkDist * h; mid = conicParametricCurve(a, b, controlPoint, 0.5, curvedWeight); From c742bff37e861dab746d44562204b0bd9e5d675b Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Mon, 6 Jul 2026 11:52:25 -0700 Subject: [PATCH 14/14] fix(simulation): sample texel centers in force shader fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six fetch sites sampled textures at the texel corner (index / size), where NEAREST filtering depends on float division rounding — `(k / size) * size` can land just below k for non-power-of-two sizes, and pointsTextureSize / clustersTextureSize are ceil(sqrt(n)), almost never powers of two. The rest of the codebase already samples centers via (index + 0.5) / size (e.g. force-spring, draw-curve-line). Affected: the quadtree binning and boundary-walk lookups in ForceManyBody (calculate-level.vert, force-level.frag), the centermass passes (ForceCenter and Clusters calculate-centermass.vert), the collision grid builder (build-grid.vert), and the cluster force (force-cluster.frag) — where clustersTextureSize is tiny, so a rounded-down fetch made a point read a different cluster's centermass or position. Co-Authored-By: Claude Fable 5 --- src/modules/Clusters/calculate-centermass.vert | 4 ++-- src/modules/Clusters/force-cluster.frag | 4 ++-- src/modules/ForceCenter/calculate-centermass.vert | 2 +- src/modules/ForceCollision/build-grid.vert | 4 ++-- src/modules/ForceManyBody/calculate-level.vert | 2 +- src/modules/ForceManyBody/force-level.frag | 8 ++++---- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/modules/Clusters/calculate-centermass.vert b/src/modules/Clusters/calculate-centermass.vert index 231b9c88..393e1028 100644 --- a/src/modules/Clusters/calculate-centermass.vert +++ b/src/modules/Clusters/calculate-centermass.vert @@ -24,10 +24,10 @@ in vec2 pointIndices; out vec4 rgba; void main() { - vec4 pointPosition = texture(positionsTexture, pointIndices / pointsTextureSize); + vec4 pointPosition = texture(positionsTexture, (pointIndices + 0.5) / pointsTextureSize); rgba = vec4(pointPosition.xy, 1.0, 0.0); - vec4 pointClusterIndices = texture(clusterTexture, pointIndices / pointsTextureSize); + vec4 pointClusterIndices = texture(clusterTexture, (pointIndices + 0.5) / pointsTextureSize); vec2 xy = vec2(0.0); if (pointClusterIndices.x >= 0.0 && pointClusterIndices.y >= 0.0) { xy = 2.0 * (pointClusterIndices.xy + 0.5) / clustersTextureSize - 1.0; diff --git a/src/modules/Clusters/force-cluster.frag b/src/modules/Clusters/force-cluster.frag index 70168de6..0c12f57c 100644 --- a/src/modules/Clusters/force-cluster.frag +++ b/src/modules/Clusters/force-cluster.frag @@ -37,9 +37,9 @@ void main() { // no cluster, so no forces if (pointClusterIndices.x >= 0.0 && pointClusterIndices.y >= 0.0) { // positioning points to custom cluster position or either to the center of mass - vec2 clusterPositions = texture(clusterPositionsTexture, pointClusterIndices.xy / clustersTextureSize).xy; + vec2 clusterPositions = texture(clusterPositionsTexture, (pointClusterIndices.xy + 0.5) / clustersTextureSize).xy; if (clusterPositions.x < 0.0 || clusterPositions.y < 0.0) { - vec4 centermassValues = texture(centermassTexture, pointClusterIndices.xy / clustersTextureSize); + vec4 centermassValues = texture(centermassTexture, (pointClusterIndices.xy + 0.5) / clustersTextureSize); clusterPositions = centermassValues.xy / centermassValues.b; } vec4 clusterCustomCoeff = texture(clusterForceCoefficient, textureCoords); diff --git a/src/modules/ForceCenter/calculate-centermass.vert b/src/modules/ForceCenter/calculate-centermass.vert index d0127c6d..7b015307 100644 --- a/src/modules/ForceCenter/calculate-centermass.vert +++ b/src/modules/ForceCenter/calculate-centermass.vert @@ -18,7 +18,7 @@ in vec2 pointIndices; out vec4 rgba; void main() { - vec4 pointPosition = texture(positionsTexture, pointIndices / pointsTextureSize); + vec4 pointPosition = texture(positionsTexture, (pointIndices + 0.5) / pointsTextureSize); rgba = vec4(pointPosition.xy, 1.0, 0.0); gl_Position = vec4(0.0, 0.0, 0.0, 1.0); diff --git a/src/modules/ForceCollision/build-grid.vert b/src/modules/ForceCollision/build-grid.vert index 6cb3e537..0aea52f4 100644 --- a/src/modules/ForceCollision/build-grid.vert +++ b/src/modules/ForceCollision/build-grid.vert @@ -28,8 +28,8 @@ in vec2 pointIndices; out vec4 cellData; // xy = position, z = size, w = count (1.0) void main() { - vec4 pointPosition = texture(positionsTexture, pointIndices / pointsTextureSize); - vec4 pointSize = texture(sizeTexture, pointIndices / pointsTextureSize); + vec4 pointPosition = texture(positionsTexture, (pointIndices + 0.5) / pointsTextureSize); + vec4 pointSize = texture(sizeTexture, (pointIndices + 0.5) / pointsTextureSize); // Output: position sum, size sum, count cellData = vec4(pointPosition.xy, pointSize.r, 1.0); diff --git a/src/modules/ForceManyBody/calculate-level.vert b/src/modules/ForceManyBody/calculate-level.vert index a03b46fd..2058bfc3 100644 --- a/src/modules/ForceManyBody/calculate-level.vert +++ b/src/modules/ForceManyBody/calculate-level.vert @@ -24,7 +24,7 @@ in vec2 pointIndices; out vec4 vColor; void main() { - vec4 pointPosition = texture(positionsTexture, pointIndices / pointsTextureSize); + vec4 pointPosition = texture(positionsTexture, (pointIndices + 0.5) / pointsTextureSize); vColor = vec4(pointPosition.rg, 1.0, 0.0); float n = floor(pointPosition.x / cellSize); diff --git a/src/modules/ForceManyBody/force-level.frag b/src/modules/ForceManyBody/force-level.frag index 47ae4cd4..c791d8e3 100644 --- a/src/modules/ForceManyBody/force-level.frag +++ b/src/modules/ForceManyBody/force-level.frag @@ -110,28 +110,28 @@ void main() { float m = top + cellSize * n_top + cellSize * i; if (n < (left + n_left * cellSize) && m < bottom) { - velocity.xy += calculateAdditionalVelocity(vec2(n / cellSize, m / cellSize) / levelTextureSize, pointPosition.xy); + velocity.xy += calculateAdditionalVelocity((vec2(n, m) / cellSize + 0.5) / levelTextureSize, pointPosition.xy); } n = left + cellSize * i; m = top + cellSize * j; if (n < (right - n_right * cellSize) && m < (top + n_top * cellSize)) { - velocity.xy += calculateAdditionalVelocity(vec2(n / cellSize, m / cellSize) / levelTextureSize, pointPosition.xy); + velocity.xy += calculateAdditionalVelocity((vec2(n, m) / cellSize + 0.5) / levelTextureSize, pointPosition.xy); } n = right - n_right * cellSize + cellSize * j; m = top + cellSize * i; if (n < right && m < (bottom - n_bottom * cellSize)) { - velocity.xy += calculateAdditionalVelocity(vec2(n / cellSize, m / cellSize) / levelTextureSize, pointPosition.xy); + velocity.xy += calculateAdditionalVelocity((vec2(n, m) / cellSize + 0.5) / levelTextureSize, pointPosition.xy); } n = left + n_left * cellSize + cellSize * i; m = bottom - n_bottom * cellSize + cellSize * j; if (n < right && m < bottom) { - velocity.xy += calculateAdditionalVelocity(vec2(n / cellSize, m / cellSize) / levelTextureSize, pointPosition.xy); + velocity.xy += calculateAdditionalVelocity((vec2(n, m) / cellSize + 0.5) / levelTextureSize, pointPosition.xy); } } }