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 ec94546d..7c7f80f5 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) @@ -1064,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) } /** @@ -1091,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) } /** @@ -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 } @@ -1544,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() @@ -1630,7 +1639,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 @@ -1639,12 +1648,23 @@ 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 || 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() } } @@ -2245,6 +2265,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 @@ -2286,10 +2309,16 @@ 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. + 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 { 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/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/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/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( 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/ForceManyBody/calculate-level.vert b/src/modules/ForceManyBody/calculate-level.vert index a9bef58c..2058bfc3 100644 --- a/src/modules/ForceManyBody/calculate-level.vert +++ b/src/modules/ForceManyBody/calculate-level.vert @@ -24,12 +24,19 @@ 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); 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 eb4a4bd3..88e168cb 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; @@ -28,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); @@ -52,8 +57,14 @@ 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). + // 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..c791d8e3 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); @@ -108,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); } } } diff --git a/src/modules/ForceManyBody/index.ts b/src/modules/ForceManyBody/index.ts index 8148f7e5..9b222c10 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 @@ -69,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 } @@ -97,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, @@ -294,11 +285,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 +446,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 +475,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, }, }) 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 adf0804c..42248509 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,7 +102,93 @@ 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 { + 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 @@ -228,7 +330,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 } /** @@ -306,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) { @@ -338,24 +483,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 { - this.updatePoints() - this.updatePointColor() - this.updatePointSize() - this.updatePointShape() - this.updatePointImageIndices() - this.updatePointImageSizes() + // 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.updateLinks() - this.updateLinkColor() - this.updateLinkWidth() - this.updateArrows() - this.updateLinkStrength() + this.updatePoints() - this.updateClusters() + 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._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 } /** @@ -438,6 +613,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 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); 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 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 } 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