Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
51 changes: 40 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<HTMLCanvasElement, undefined>) => { this.currentEvent = e })
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}

/**
Expand All @@ -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)
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/modules/Clusters/calculate-centermass.vert
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/modules/Clusters/force-cluster.frag
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 8 additions & 4 deletions src/modules/FPSMonitor/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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()
}
}
2 changes: 1 addition & 1 deletion src/modules/ForceCenter/calculate-centermass.vert
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/modules/ForceCollision/build-grid.vert
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 13 additions & 6 deletions src/modules/ForceCollision/force-collision-spatial.frag
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions src/modules/ForceCollision/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 8 additions & 2 deletions src/modules/ForceLink/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions src/modules/ForceManyBody/calculate-level.vert
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 14 additions & 3 deletions src/modules/ForceManyBody/force-centermass.frag
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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;

Expand Down
Loading