diff --git a/.storybook/style.css b/.storybook/style.css index 264c3d01..3379ad55 100644 --- a/.storybook/style.css +++ b/.storybook/style.css @@ -1,3 +1,10 @@ +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap'); + +body { + font-family: 'Space Grotesk', sans-serif; + font-optical-sizing: auto; +} + tr:nth-of-type(2n) { background-color: #1e2326; } \ No newline at end of file diff --git a/README.md b/README.md index 55455fe2..018530c3 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ cosmos.gl v3.0 brings a new rendering engine, async initialization, and several - **GPU transitions** — point positions, point colors/sizes, and link colors/widths now animate by default (`transitionDuration: 800`, `transitionEasing: TransitionEasing.CubicInOut`). Use `transitionDuration: 0` to keep snap updates. - **Transition callbacks** — use `onTransitionStart`, `onTransition`, and `onTransitionEnd` to track transition lifecycle and progress. - **Default point shape** — new `pointDefaultShape` config property lets you set the fallback shape for all points when no per-point shapes are provided. Accepts a `PointShape` enum value (e.g., `PointShape.Star`), a plain number (e.g., `6`), or a numeric string (e.g., `"6"`). +- **Collision force** — new GPU-accelerated collision force keeps points from overlapping, using a spatial-hash grid that scales to large graphs. Enable it with `simulationCollision`, and tune the spacing with `simulationCollisionRadius` (fixed radius vs. size-derived) and `simulationCollisionPadding` (extra gap between points). See the [Collision example](https://cosmosgl.github.io/graph/?path=/story/examples-experiments--collision). - **Exported defaults** — `defaultConfigValues` is now part of the public API. - **Optimized hover detection** — skips GPU work when the mouse hasn't moved. diff --git a/history/2026/2026-06-13-collision-force.md b/history/2026/2026-06-13-collision-force.md new file mode 100644 index 00000000..99a53450 --- /dev/null +++ b/history/2026/2026-06-13-collision-force.md @@ -0,0 +1,139 @@ +# Spatial-hash collision force + +**Date:** 2026-06-13 +**Commits:** `6cb1b48`, `566bcba`, `6e41a8a`, `ad860ec`, `8284883`, `9c852f7`, `94dcfd3` + +## Why + +To make graph visualizations clearer. When point size carries meaning (degree, a +metric, importance), overlapping nodes become illegible — you can't see or click an +individual point. cosmos.gl had no force that resolves overlap: Link, Many-Body, +Gravity, Centering, and Cluster were all there, but Many-Body repulsion acts on point +*centers* and ignores radius, so it can't keep sized points apart. This adds the +missing piece — the same overlap-resolution `d3-force` provides via `forceCollide`, +which the cosmos.gl layout was modeled on. + +## What changed + +A new GPU force module, `src/modules/ForceCollision/`, that pushes overlapping points +apart. Rather than naive O(n²) pair checks, it builds a spatial-hash grid each tick and +resolves each point against its 3×3 cell neighborhood — staying in line with cosmos.gl's +"everything on the GPU, hundreds of thousands of points" goal. + +Module layout: +- `index.ts` — the `ForceCollision` class (`CoreModule` subclass): resource allocation, + program setup, and the per-tick `run()`. +- `build-grid.vert` / `build-grid.frag` — bins each point into a grid cell (point-list + draw, additive blend); each cell accumulates `(sumX, sumY, sumSize, count)`. +- `force-collision-spatial.frag` — fullscreen pass that reads the grid and writes the + per-point collision velocity. + +## Config + +Three properties (interface in `src/config.ts`, defaults in `src/variables.ts`, +`defaultConfigValues`): + +| Property | Meaning | Default | Notes | +|---|---|---|---| +| `simulationCollision` | Force strength; `0` disables it (and skips all GPU work / allocation). | `0` | Story demos use ~0.2–1.0. | +| `simulationCollisionRadius` | Collision radius. `0` and `undefined` are aliases — both derive it per-point as `size * 0.5`; a positive value sets a fixed radius for all points. | `undefined` | Use a fixed value to decouple physics from visual size. | +| `simulationCollisionPadding` | Extra room added to every radius, so neighbors keep a `2 × padding` gap instead of just touching. | `0` | Composes with both derived and fixed radius. | + +## How it runs each tick (GPU pipeline) + +`run()` (`src/modules/ForceCollision/index.ts:256`) is two-phase, repeated over +`GRID_OFFSETS` — 4 half-cell offsets (`[0,0],[0.5,0],[0,0.5],[0.5,0.5]`) that catch +collisions straddling cell boundaries: + +1. **Build:** for each offset, a point-list draw bins every point into one grid cell with + **additive blending** (`blend: 'one'/'one'`), accumulating position-sum, size-sum, and + count. Each offset writes its own grid framebuffer (4 separate FBOs allocated in + `create()`). +2. **Resolve:** a single render pass into `points.velocityFbo` (cleared once), with the + fullscreen force shader drawn 4 times — once per offset grid — accumulating additively. + Each point reads the cell *averages* in its 3×3 neighborhood, computes a push-apart + velocity from the overlap, and the integrator applies it via the usual + `swapFbo → run → updatePosition` dance. + +Grid sizing: `cellSize = max(effectiveRadius, 8)` and +`gridTextureSize = clamp(ceil(spaceSize / cellSize), 32, 512)`, then `cellSize` is +recomputed to divide `spaceSize` evenly. The 512 cap bounds grid memory regardless of +space size. + +Shaders are **GLSL ES 3.0** (`#version 300 es`), imported with `?raw`, mirroring the +luma.gl `ForceManyBody` module. The build vertex shader samples the positions/size +textures (vertex-shader texture reads are required and supported here). + +## Simulation integration (exact wiring) + +All in `src/index.ts`: +- **Construction** — created alongside the other forces when `enableSimulation`. +- **Run + lazy init** — gated on `if (simulationCollision)`. On first use (or + after invalidation) it calls `create()` + `initPrograms()` and sets `isForceCollisionReady`. +- **Ordering matters:** collision runs **after** gravity, many-body, links, and clusters +. Running it before the attraction forces let springs/clusters re-create overlap + in the same tick, producing a standing oscillation. Keep it last. +- **Destroy**. +- **Invalidation** of `isForceCollisionReady` (forces a rebuild on next run): + — on point-size / position / many-body data changes (`applyPendingChanges`). + — in `updateStateFromConfig`: on `simulationCollisionRadius` / + `simulationCollisionPadding` change, and — in derived-radius mode — on + `pointDefaultSize` change (size texture + cell size depend on point sizes). + +### Lazy allocation (zero-cost when off) + +`isForceCollisionReady` (`src/index.ts`) is the whole state machine: GPU resources +(4 grid FBOs, size texture, compiled programs) are allocated **lazily on first run**, so a +graph that never sets `simulationCollision > 0` pays no memory or compile cost. Anything +that changes the inputs sets the flag `false`; the next collision tick rebuilds. If you +add a config/data path that affects collision sizing, add an invalidation there too. + +## Stability & correctness details + +- **Per-pass correction cap** — each pass clamps its output to ~10% of the point's + collision radius (~40%/frame across 4 passes), so deep overlaps resolve by relaxation + over several frames instead of overshooting and ping-ponging in dense regions. +- **Density damping** — force is scaled down when a point has many neighbors, further + reducing jitter in dense clusters. +- **Border clamping** — the force pass clamps a point's own cell coords to the grid + bounds, matching `build-grid.vert`. Without it, a point that drifts >1 cell outside the + space sees an all-out-of-bounds neighborhood and loses collision response near edges + (fixed in `8284883`). +- **Large-graph safety** — max point size is computed by **looping** over `data.pointSizes`, + not `Math.max(...Array.from(...))`; spreading a 50K+ typed array as call args throws a + `RangeError` before collision even initializes (fixed in `8284883`). + +## Tuning guidance + +- **Link distance must clear the collision radii.** If `simulationLinkDistance` is smaller + than the combined radii of linked points, springs pull them inside each other and + collision can't win — you get an unresolvable pile. The Collision demo uses + `linkDistance: 50` for sizes up to ~30. +- **Jitter** is reduced by lower `simulationFriction` and shorter `simulationDecay` (less + residual energy), and by the force ordering / correction cap above. +- **Density** = cost. More points per cell (smaller `spaceSize`, larger points) means more + work per tick. + +## Examples & docs + +- **Collision** (`src/stories/forces/collision.ts`, *Examples/Forces*): a 6-cluster network + of ~600 points, sized by degree and linked sparsely, that the collision force spreads into + a readable layout. Seed positions are symmetric around the space center and the view is + framed up front, so start-up isn't misread as drift. A new *Examples/Forces* group was + introduced and the existing Clustering stories moved under it (*Examples/Forces/Clustering*). +- **Collision Stress Test** (`src/stories/forces/collision-stress-test.ts`, *Examples/Forces*, + commit `9c852f7`): 50,000 points seeded with heavy overlap in a dense disc, repulsion off, + gentle gravity to keep them packed so collision keeps working, `showFPSMonitor: true` to + read the cost under load. Use this to gauge collision performance at scale. +- Docs: `simulationCollision` / `simulationCollisionRadius` / `simulationCollisionPadding` + documented in the Configuration docs (ranges + defaults), Collision listed among the + simulation forces, and the example linked from the README. + +## Known limitations / future work + +- **Centroid-based resolution.** A point reacts to the *average* of each neighboring cell, + not to individual neighbors — cheap and scalable, but the source of residual jitter in + dense areas. The bigger quality jump (if needed) is exact pairwise resolution: store point + indices per cell and iterate real neighbors. +- The per-pass correction cap trades convergence speed for smoothness; raising it resolves + faster but reintroduces overshoot. diff --git a/package-lock.json b/package-lock.json index 75216083..da692ef4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cosmos.gl/graph", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@cosmos.gl/graph", - "version": "3.0.0", + "version": "3.1.0", "license": "MIT", "dependencies": { "@luma.gl/core": "~9.2.6", diff --git a/package.json b/package.json index 86dc2192..dfaa2c31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cosmos.gl/graph", - "version": "3.0.0", + "version": "3.1.0", "description": "GPU-based force graph layout and rendering", "jsdelivr": "dist/index.min.js", "main": "dist/index.js", diff --git a/src/config.ts b/src/config.ts index e959ca99..73514823 100644 --- a/src/config.ts +++ b/src/config.ts @@ -382,6 +382,27 @@ export interface GraphConfigInterface { * Default value: `0.1` */ simulationCluster: number; + /** + * Collision force coefficient. When set to a value greater than 0, + * points will push each other apart when they overlap. + * Uses a spatial-hash grid, so it scales better than naive O(n²) collision. + * Default value: `0` + */ + simulationCollision: number; + /** + * Collision radius. When set to undefined or 0, the collision radius is derived from + * point sizes (half of the point size). When set to a positive value, + * all points use this fixed collision radius. + * Default value: `undefined` + */ + simulationCollisionRadius: number | undefined; + /** + * Extra padding added to each point's collision radius, in space units. + * Without padding, points settle just touching; with padding, two points + * keep a gap of twice this value between their visual edges. + * Default value: `0` + */ + simulationCollisionPadding: number; /** * Callback function that will be called when the simulation starts. diff --git a/src/index.ts b/src/index.ts index 2bfe220c..ec94546d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { webgl2Adapter } from '@luma.gl/webgl' import { applyConfig, createDefaultConfig, resetConfigToDefaults, GraphConfigInterface, type GraphConfig } from '@/graph/config' import { getRgbaColor, getMaxPointSize, readPixels, extractIndicesFromPixels, sanitizeHtml } from '@/graph/helper' import { ForceCenter } from '@/graph/modules/ForceCenter' +import { ForceCollision } from '@/graph/modules/ForceCollision' import { ForceGravity } from '@/graph/modules/ForceGravity' import { ForceLink, LinkDirection } from '@/graph/modules/ForceLink' import { ForceManyBody } from '@/graph/modules/ForceManyBody' @@ -75,6 +76,7 @@ export class Graph { private forceLinkIncoming: ForceLink | undefined private forceLinkOutgoing: ForceLink | undefined private forceMouse: ForceMouse | undefined + private forceCollision: ForceCollision | undefined private clusters: Clusters | undefined private zoomInstance = new Zoom(this.store, this.config) private transition = new Transition(this.config) @@ -128,6 +130,11 @@ export class Graph { private isForceCenterUpdateNeeded = false private isPointImageSizesUpdateNeeded = false + // Whether the collision force's GPU resources (grid/size textures, programs) + // are allocated and match the current data. Allocated lazily the first time + // collision runs, so a graph that never enables it pays no memory cost. + private isForceCollisionReady = false + private _isDestroyed = false /** @@ -351,6 +358,7 @@ export class Graph { this.forceLinkIncoming = new ForceLink(device, this.config, this.store, this.graph, this.points) this.forceLinkOutgoing = new ForceLink(device, this.config, this.store, this.graph, this.points) this.forceMouse = new ForceMouse(device, this.config, this.store, this.graph, this.points) + this.forceCollision = new ForceCollision(device, this.config, this.store, this.graph, this.points) } this.clusters = new Clusters(device, this.config, this.store, this.graph, this.points) @@ -1394,6 +1402,7 @@ export class Graph { this.forceLinkIncoming?.destroy() this.forceLinkOutgoing?.destroy() this.forceMouse?.destroy() + this.forceCollision?.destroy() if (this.device) { // Only clear and destroy the device if Graph owns it @@ -1448,6 +1457,10 @@ export class Graph { if (this.isLinkArrowUpdateNeeded) this.lines.updateArrow() if (this.isForceManyBodyUpdateNeeded) this.forceManyBody?.create() + // Collision grid/size textures depend on point count and sizes. Mark them + // stale so they're rebuilt lazily the next time the collision force runs, + // rather than reallocating here while collision may be disabled. + if (this.isForceManyBodyUpdateNeeded || this.isPointSizeUpdateNeeded) this.isForceCollisionReady = false if (this.isForceLinkUpdateNeeded) { this.forceLinkIncoming?.create(LinkDirection.INCOMING) this.forceLinkOutgoing?.create(LinkDirection.OUTGOING) @@ -1590,6 +1603,17 @@ export class Graph { if (prevConfig.highlightedLinkIndices !== this.config.highlightedLinkIndices) { this.lines?.updateLinkStatus() } + // The collision grid's cell size is derived from the collision radius and + // padding, so a change to either requires rebuilding the grid textures. + // In derived-radius mode (radius 0/undefined) the radius — and the size + // texture — come from point sizes, so a pointDefaultSize change must also + // invalidate the collision resources. + if (prevConfig.simulationCollisionRadius !== this.config.simulationCollisionRadius || + prevConfig.simulationCollisionPadding !== this.config.simulationCollisionPadding || + ((this.config.simulationCollisionRadius === undefined || this.config.simulationCollisionRadius === 0) && + prevConfig.pointDefaultSize !== this.config.pointDefaultSize)) { + this.isForceCollisionReady = false + } if (prevConfig.pixelRatio !== this.config.pixelRatio) { // Update device's canvas context useDevicePixels if (this.device?.canvasContext) { @@ -1743,7 +1767,7 @@ export class Graph { * to respect pause/unpause state. */ private runSimulationStep (forceExecution = false): void { - const { config: { simulationGravity, simulationCenter, enableSimulation }, store: { isSimulationRunning } } = this + const { config: { simulationGravity, simulationCenter, simulationCollision, enableSimulation }, store: { isSimulationRunning } } = this if (!enableSimulation) return @@ -1798,6 +1822,23 @@ export class Graph { this.points?.updatePosition() } + // Collision runs after the attraction forces (links, clusters) so it + // corrects the overlap they introduce within the same tick, instead of + // lagging one frame behind and oscillating against them. + if (simulationCollision) { + // Lazily allocate the collision GPU resources on first use (or after a + // data change marked them stale), so a graph that never enables + // collision never pays the grid/size-texture memory cost. + if (!this.isForceCollisionReady) { + this.forceCollision?.create() + this.forceCollision?.initPrograms() + this.isForceCollisionReady = true + } + this.points?.swapFbo() + this.forceCollision?.run() + this.points?.updatePosition() + } + // Alpha decay and progress this.store.alpha += this.store.addAlpha(this.config.simulationDecay) if (this.isRightClickMouse && this.config.enableRightClickRepulsion) { @@ -1826,6 +1867,7 @@ export class Graph { this.forceLinkIncoming?.initPrograms() this.forceLinkOutgoing?.initPrograms() this.forceMouse?.initPrograms() + // ForceCollision programs are built lazily on first use (see runSimulationStep) this.clusters.initPrograms() } @@ -1838,6 +1880,7 @@ export class Graph { this.forceLinkIncoming ||= new ForceLink(this.device, this.config, this.store, this.graph, this.points) this.forceLinkOutgoing ||= new ForceLink(this.device, this.config, this.store, this.graph, this.points) this.forceMouse ||= new ForceMouse(this.device, this.config, this.store, this.graph, this.points) + this.forceCollision ||= new ForceCollision(this.device, this.config, this.store, this.graph, this.points) } private destroySimulationModules (): void { @@ -1853,6 +1896,10 @@ export class Graph { this.forceLinkOutgoing = undefined this.forceMouse?.destroy() this.forceMouse = undefined + this.forceCollision?.destroy() + this.forceCollision = undefined + // Force lazy re-allocation if collision is re-enabled on a new instance. + this.isForceCollisionReady = false this.points?.destroySimulationResources() } diff --git a/src/modules/ForceCollision/build-grid.frag b/src/modules/ForceCollision/build-grid.frag new file mode 100644 index 00000000..79aa8942 --- /dev/null +++ b/src/modules/ForceCollision/build-grid.frag @@ -0,0 +1,11 @@ +#version 300 es +precision highp float; + +in vec4 cellData; +out vec4 fragColor; + +void main() { + // Output accumulated cell data (blended additively) + // xy = sum of positions, z = sum of sizes, w = count + fragColor = cellData; +} diff --git a/src/modules/ForceCollision/build-grid.vert b/src/modules/ForceCollision/build-grid.vert new file mode 100644 index 00000000..6cb3e537 --- /dev/null +++ b/src/modules/ForceCollision/build-grid.vert @@ -0,0 +1,53 @@ +#version 300 es +precision highp float; + +uniform sampler2D positionsTexture; +uniform sampler2D sizeTexture; + +#ifdef USE_UNIFORM_BUFFERS +layout(std140) uniform buildGridUniforms { + float pointsTextureSize; + float gridTextureSize; + float cellSize; + vec2 gridOffset; // Offset for multi-pass (0-1 range, multiplied by cellSize) +} buildGrid; + +#define pointsTextureSize buildGrid.pointsTextureSize +#define gridTextureSize buildGrid.gridTextureSize +#define cellSize buildGrid.cellSize +#define gridOffset buildGrid.gridOffset +#else +uniform float pointsTextureSize; +uniform float gridTextureSize; +uniform float cellSize; +uniform vec2 gridOffset; +#endif + +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); + + // Output: position sum, size sum, count + cellData = vec4(pointPosition.xy, pointSize.r, 1.0); + + // Apply grid offset for multi-pass collision detection + vec2 offsetPosition = pointPosition.xy + gridOffset * cellSize; + + // Calculate which grid cell this point belongs to + float cellX = floor(offsetPosition.x / cellSize); + float cellY = floor(offsetPosition.y / cellSize); + + // Clamp to grid bounds + cellX = clamp(cellX, 0.0, gridTextureSize - 1.0); + cellY = clamp(cellY, 0.0, gridTextureSize - 1.0); + + // Convert to clip space coordinates + vec2 gridPosition = 2.0 * (vec2(cellX, cellY) + 0.5) / gridTextureSize - 1.0; + + gl_Position = vec4(gridPosition, 0.0, 1.0); + gl_PointSize = 1.0; +} diff --git a/src/modules/ForceCollision/force-collision-spatial.frag b/src/modules/ForceCollision/force-collision-spatial.frag new file mode 100644 index 00000000..0aa5e64a --- /dev/null +++ b/src/modules/ForceCollision/force-collision-spatial.frag @@ -0,0 +1,164 @@ +#version 300 es +precision highp float; + +uniform sampler2D positionsTexture; +uniform sampler2D sizeTexture; +uniform sampler2D gridTexture; + +#ifdef USE_UNIFORM_BUFFERS +layout(std140) uniform forceCollisionUniforms { + float pointsTextureSize; + float gridTextureSize; + float cellSize; + float alpha; + float collisionStrength; + float collisionRadius; + float collisionPadding; + float pointsNumber; + vec2 gridOffset; // Must match the offset used when building the grid +} forceCollision; + +#define pointsTextureSize forceCollision.pointsTextureSize +#define gridTextureSize forceCollision.gridTextureSize +#define cellSize forceCollision.cellSize +#define alpha forceCollision.alpha +#define collisionStrength forceCollision.collisionStrength +#define collisionRadius forceCollision.collisionRadius +#define collisionPadding forceCollision.collisionPadding +#define pointsNumber forceCollision.pointsNumber +#define gridOffset forceCollision.gridOffset +#else +uniform float pointsTextureSize; +uniform float gridTextureSize; +uniform float cellSize; +uniform float alpha; +uniform float collisionStrength; +uniform float collisionRadius; +uniform float collisionPadding; +uniform float pointsNumber; +uniform vec2 gridOffset; +#endif + +in vec2 textureCoords; +out vec4 fragColor; + +void main() { + vec4 pointPosition = texture(positionsTexture, textureCoords); + vec4 velocity = vec4(0.0); + + // Get current point's index + float currentIndex = pointPosition.b; + + // Skip if this is an empty texel + if (currentIndex < 0.0 || currentIndex >= pointsNumber) { + fragColor = velocity; + return; + } + + // Get current point's size for collision radius + vec4 currentSizeData = texture(sizeTexture, textureCoords); + float currentSize = currentSizeData.r; + float currentCollisionRadius = (collisionRadius > 0.0 ? collisionRadius : currentSize * 0.5) + collisionPadding; + + vec2 currentPos = pointPosition.rg; + + // Apply the same offset used when building the grid + vec2 offsetPos = currentPos + gridOffset * cellSize; + + // Calculate which grid cell this point is in (with offset). + // Clamp to the grid bounds to match build-grid.vert, so a point that drifts + // outside the space still reads the edge cell it was binned into. + float myCellX = clamp(floor(offsetPos.x / cellSize), 0.0, gridTextureSize - 1.0); + float myCellY = clamp(floor(offsetPos.y / cellSize), 0.0, gridTextureSize - 1.0); + + // Track total neighbor count for damping + float totalNeighbors = 0.0; + + // Check 3x3 neighborhood of cells + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + float neighborCellX = myCellX + float(dx); + float neighborCellY = myCellY + float(dy); + + // Skip cells outside grid bounds + if (neighborCellX < 0.0 || neighborCellX >= gridTextureSize || + neighborCellY < 0.0 || neighborCellY >= gridTextureSize) { + continue; + } + + // Sample the grid cell + vec2 gridCoord = (vec2(neighborCellX, neighborCellY) + 0.5) / gridTextureSize; + vec4 cellData = texture(gridTexture, gridCoord); + + 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 + float effectiveCount = cellCount; + if (dx == 0 && dy == 0) { + effectiveCount = max(0.0, cellCount - 1.0); + } + + totalNeighbors += effectiveCount; + + // Get average position and size in this cell + vec2 avgPos = cellData.xy / cellCount; + float avgSize = cellData.z / cellCount; + float otherCollisionRadius = (collisionRadius > 0.0 ? collisionRadius : avgSize * 0.5) + collisionPadding; + + // Calculate combined collision radius + float combinedRadius = currentCollisionRadius + otherCollisionRadius; + + // Calculate distance vector to average position (using original positions) + vec2 distVector = currentPos - avgPos; + float dist = length(distVector); + + // Check for collision + if (dist < combinedRadius && dist > 0.001) { + // Calculate overlap ratio (0 = just touching, 1 = fully overlapping) + float overlapRatio = (combinedRadius - dist) / combinedRadius; + + // Soft collision curve: use square root for gentler force near edges + // This prevents the "ping-pong" effect at boundaries + float softOverlap = sqrt(overlapRatio) * combinedRadius * 0.5; + + // Direction to push apart (normalized) + vec2 direction = distVector / dist; + + // Apply repulsion force with soft curve + // Divide by 4 since we run 4 passes with different offsets + float force = alpha * collisionStrength * softOverlap * 0.25 * effectiveCount; + + // Clamp maximum force to prevent instability + force = min(force, combinedRadius * 0.5); + + velocity.rg += force * direction; + } else if (dist <= 0.001 && effectiveCount > 0.0) { + // Points at same position - push based on index + float angle = currentIndex * 0.618033988749895; + float force = min(alpha * collisionStrength * combinedRadius * 0.1, combinedRadius * 0.3); + velocity.rg += force * effectiveCount * vec2(cos(angle), sin(angle)); + } + } + } + + // Apply density-based damping: reduce force when surrounded by many neighbors + // This prevents chaotic oscillations in dense clusters + if (totalNeighbors > 2.0) { + float damping = 2.0 / totalNeighbors; + velocity.rg *= damping; + } + + // Cap the per-pass correction so overlaps resolve by relaxation over a few + // frames instead of overshooting in one. Across the 4 offset passes the + // total displacement stays within ~40% of this point's collision radius, + // which converges without the ping-pong of full-overlap corrections. + float maxCorrection = currentCollisionRadius * 0.1; + float correction = length(velocity.rg); + if (correction > maxCorrection) { + velocity.rg *= maxCorrection / correction; + } + + fragColor = velocity; +} diff --git a/src/modules/ForceCollision/index.ts b/src/modules/ForceCollision/index.ts new file mode 100644 index 00000000..15f16562 --- /dev/null +++ b/src/modules/ForceCollision/index.ts @@ -0,0 +1,384 @@ +import { Buffer, Framebuffer, Texture, UniformStore } from '@luma.gl/core' +import { Model } from '@luma.gl/engine' +import { CoreModule } from '@/graph/modules/core-module' + +import buildGridVert from '@/graph/modules/ForceCollision/build-grid.vert?raw' +import buildGridFrag from '@/graph/modules/ForceCollision/build-grid.frag?raw' +import forceFrag from '@/graph/modules/ForceCollision/force-collision-spatial.frag?raw' +import { createIndexesForBuffer } from '@/graph/modules/Shared/buffer' +import { getBytesPerRow } from '@/graph/modules/Shared/texture-utils' +import updateVert from '@/graph/modules/Shared/quad.vert?raw' +import { defaultConfigValues } from '@/graph/variables' + +type GridTarget = { + texture: Texture; + fbo: Framebuffer; +} + +// Grid offsets for multiple passes (improves collision detection at cell boundaries) +const GRID_OFFSETS: [number, number][] = [ + [0.0, 0.0], + [0.5, 0.0], + [0.0, 0.5], + [0.5, 0.5], +] + +export class ForceCollision extends CoreModule { + private gridTargets: GridTarget[] = [] + private sizeTexture: Texture | undefined + private pointIndices: Buffer | undefined + private forceVertexCoordBuffer: Buffer | undefined + + private buildGridCommand: Model | undefined + private forceCommand: Model | undefined + + private buildGridUniformStore: UniformStore<{ + buildGridUniforms: { + pointsTextureSize: number; + gridTextureSize: number; + cellSize: number; + gridOffset: [number, number]; + }; + }> | undefined + + private forceUniformStore: UniformStore<{ + forceCollisionUniforms: { + pointsTextureSize: number; + gridTextureSize: number; + cellSize: number; + alpha: number; + collisionStrength: number; + collisionRadius: number; + collisionPadding: number; + pointsNumber: number; + gridOffset: [number, number]; + }; + }> | undefined + + private gridTextureSize = 0 + private cellSize = 0 + private previousPointsTextureSize: number | undefined + private previousSpaceSize: number | undefined + + public create (): void { + const { device, store, data, config } = this + if (!store.pointsTextureSize || data.pointsNumber === undefined) return + + // Calculate grid size based on space size and collision radius. + // Scan the size buffer instead of spreading it into Math.max — spreading a + // large typed array as arguments can throw a RangeError on big graphs. + const defaultSize = config.pointDefaultSize ?? defaultConfigValues.pointDefaultSize + let maxSize = defaultSize + if (data.pointSizes) { + for (const size of data.pointSizes) maxSize = Math.max(maxSize, size) + } + const collisionRadius = config.simulationCollisionRadius ?? 0 + 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) + + // Grid texture size = space size / cell size, clamped to reasonable values + this.gridTextureSize = Math.min( + 512, + Math.max(32, Math.ceil(store.adjustedSpaceSize / this.cellSize)) + ) + + // Recalculate cell size to fit the grid evenly + this.cellSize = store.adjustedSpaceSize / this.gridTextureSize + + // Allocate one grid framebuffer per offset pass. These are scratch buffers + // (cleared and rebuilt every tick in run()), so reuse them when the grid + // dimensions are unchanged instead of reallocating on every create(). + const gridTargetsValid = + this.gridTargets.length === GRID_OFFSETS.length && + this.gridTargets.every((t) => !t.texture.destroyed && !t.fbo.destroyed && t.texture.width === this.gridTextureSize) + if (!gridTargetsValid) { + this.destroyGridTargets() + this.gridTargets = GRID_OFFSETS.map(() => { + const texture = device.createTexture({ + width: this.gridTextureSize, + height: this.gridTextureSize, + format: 'rgba32float', + usage: Texture.SAMPLE | Texture.RENDER | Texture.COPY_DST, + }) + const fbo = device.createFramebuffer({ + width: this.gridTextureSize, + height: this.gridTextureSize, + colorAttachments: [texture], + }) + return { texture, fbo } + }) + } + + // Create size texture for collision radius calculation + const sizeState = new Float32Array(store.pointsTextureSize * store.pointsTextureSize * 4) + for (let i = 0; i < data.pointsNumber; i++) { + sizeState[i * 4] = data.pointSizes?.[i] ?? defaultSize + } + + const recreateSizeTexture = + !this.sizeTexture || + this.sizeTexture.destroyed || + this.sizeTexture.width !== store.pointsTextureSize || + this.sizeTexture.height !== store.pointsTextureSize + + if (recreateSizeTexture) { + if (this.sizeTexture && !this.sizeTexture.destroyed) this.sizeTexture.destroy() + this.sizeTexture = device.createTexture({ + width: store.pointsTextureSize, + height: store.pointsTextureSize, + format: 'rgba32float', + usage: Texture.SAMPLE | Texture.COPY_DST, + }) + } + this.sizeTexture!.copyImageData({ + data: sizeState, + bytesPerRow: getBytesPerRow('rgba32float', store.pointsTextureSize), + mipLevel: 0, + x: 0, + y: 0, + }) + + // Create / update point indices buffer + if (!this.pointIndices || this.previousPointsTextureSize !== store.pointsTextureSize) { + if (this.pointIndices && !this.pointIndices.destroyed) this.pointIndices.destroy() + this.pointIndices = device.createBuffer({ + data: createIndexesForBuffer(store.pointsTextureSize), + usage: Buffer.VERTEX | Buffer.COPY_DST, + }) + this.buildGridCommand?.setAttributes({ + pointIndices: this.pointIndices, + }) + } + + this.previousPointsTextureSize = store.pointsTextureSize + this.previousSpaceSize = store.adjustedSpaceSize + } + + public initPrograms (): void { + const { device, store, data } = this + if (!data.pointsNumber || !store.pointsTextureSize) return + + // Build-grid command: positions each point into its grid cell (additive accumulation) + this.buildGridUniformStore ||= new UniformStore({ + buildGridUniforms: { + uniformTypes: { + pointsTextureSize: 'f32', + gridTextureSize: 'f32', + cellSize: 'f32', + gridOffset: 'vec2', + }, + }, + }) + + this.buildGridCommand ||= new Model(device, { + fs: buildGridFrag, + vs: buildGridVert, + topology: 'point-list', + vertexCount: data.pointsNumber, + attributes: { + ...this.pointIndices && { pointIndices: this.pointIndices }, + }, + bufferLayout: [ + { name: 'pointIndices', format: 'float32x2' }, + ], + defines: { + USE_UNIFORM_BUFFERS: true, + }, + bindings: { + buildGridUniforms: this.buildGridUniformStore.getManagedUniformBuffer(device, 'buildGridUniforms'), + // Texture bindings set dynamically in run() + }, + parameters: { + blend: true, + blendColorOperation: 'add', + blendColorSrcFactor: 'one', + blendColorDstFactor: 'one', + blendAlphaOperation: 'add', + blendAlphaSrcFactor: 'one', + blendAlphaDstFactor: 'one', + depthWriteEnabled: false, + depthCompare: 'always', + }, + }) + + // Collision force command: reads the spatial hash grid (additive accumulation) + this.forceUniformStore ||= new UniformStore({ + forceCollisionUniforms: { + uniformTypes: { + pointsTextureSize: 'f32', + gridTextureSize: 'f32', + cellSize: 'f32', + alpha: 'f32', + collisionStrength: 'f32', + collisionRadius: 'f32', + collisionPadding: 'f32', + pointsNumber: 'f32', + gridOffset: 'vec2', + }, + }, + }) + + this.forceVertexCoordBuffer ||= device.createBuffer({ + data: new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), + }) + + this.forceCommand ||= new Model(device, { + fs: forceFrag, + vs: updateVert, + topology: 'triangle-strip', + vertexCount: 4, + attributes: { + vertexCoord: this.forceVertexCoordBuffer, + }, + bufferLayout: [ + { name: 'vertexCoord', format: 'float32x2' }, + ], + defines: { + USE_UNIFORM_BUFFERS: true, + }, + bindings: { + forceCollisionUniforms: this.forceUniformStore.getManagedUniformBuffer(device, 'forceCollisionUniforms'), + // Texture bindings set dynamically in run() + }, + parameters: { + blend: true, + blendColorOperation: 'add', + blendColorSrcFactor: 'one', + blendColorDstFactor: 'one', + blendAlphaOperation: 'add', + blendAlphaSrcFactor: 'one', + blendAlphaDstFactor: 'one', + depthWriteEnabled: false, + depthCompare: 'always', + }, + }) + } + + public run (): void { + const { device, store, data, points, config } = this + if (!points) return + if (!this.buildGridCommand || !this.buildGridUniformStore) return + if (!this.forceCommand || !this.forceUniformStore) return + if (!this.pointIndices) return + if (data.pointsNumber === undefined) return + if (!points.previousPositionTexture || points.previousPositionTexture.destroyed) return + if (!points.velocityFbo || points.velocityFbo.destroyed) return + if (!this.sizeTexture || this.sizeTexture.destroyed) return + if (this.gridTargets.length !== GRID_OFFSETS.length) return + // Skip if sizes changed and create() wasn't called yet + if (store.pointsTextureSize !== this.previousPointsTextureSize || store.adjustedSpaceSize !== this.previousSpaceSize) return + + const collisionRadius = config.simulationCollisionRadius ?? 0 + const collisionPadding = config.simulationCollisionPadding ?? 0 + + // Step 1: Build the spatial hash grid for each offset pass. + // Each grid is cleared and accumulated within its own render pass. + this.buildGridCommand.setVertexCount(data.pointsNumber) + this.buildGridCommand.setBindings({ + positionsTexture: points.previousPositionTexture, + sizeTexture: this.sizeTexture, + }) + for (const [i, gridOffset] of GRID_OFFSETS.entries()) { + const target = this.gridTargets[i] + if (!target || target.fbo.destroyed || target.texture.destroyed) continue + + this.buildGridUniformStore.setUniforms({ + buildGridUniforms: { + pointsTextureSize: store.pointsTextureSize ?? 0, + gridTextureSize: this.gridTextureSize, + cellSize: this.cellSize, + gridOffset, + }, + }) + + const gridPass = device.beginRenderPass({ + framebuffer: target.fbo, + clearColor: [0, 0, 0, 0], + }) + this.buildGridCommand.draw(gridPass) + gridPass.end() + } + + // Step 2: Accumulate the collision forces from all offset passes into velocityFbo + // within a single render pass (cleared once, then blended additively). + // The position/size bindings are constant across passes, so set them once; + // setBindings merges, so only gridTexture changes per offset in the loop. + this.forceCommand.setBindings({ + positionsTexture: points.previousPositionTexture, + sizeTexture: this.sizeTexture, + }) + const forcePass = device.beginRenderPass({ + framebuffer: points.velocityFbo, + clearColor: [0, 0, 0, 0], + }) + for (const [i, gridOffset] of GRID_OFFSETS.entries()) { + const target = this.gridTargets[i] + if (!target || target.texture.destroyed) continue + + this.forceUniformStore.setUniforms({ + forceCollisionUniforms: { + pointsTextureSize: store.pointsTextureSize ?? 0, + gridTextureSize: this.gridTextureSize, + cellSize: this.cellSize, + alpha: store.alpha, + collisionStrength: config.simulationCollision ?? 0, + collisionRadius, + collisionPadding, + pointsNumber: data.pointsNumber, + gridOffset, + }, + }) + + this.forceCommand.setBindings({ + gridTexture: target.texture, + }) + this.forceCommand.draw(forcePass) + } + forcePass.end() + } + + /** + * Destruction order matters + * Models -> Framebuffers -> Textures -> UniformStores -> Buffers + */ + public destroy (): void { + // 1. Destroy Models FIRST + this.buildGridCommand?.destroy() + this.buildGridCommand = undefined + this.forceCommand?.destroy() + this.forceCommand = undefined + + // 2. Destroy Framebuffers (before the textures they reference) & 3. their textures + this.destroyGridTargets() + + // 3. Destroy remaining Textures + if (this.sizeTexture && !this.sizeTexture.destroyed) this.sizeTexture.destroy() + this.sizeTexture = undefined + + // 4. Destroy UniformStores + this.buildGridUniformStore?.destroy() + this.buildGridUniformStore = undefined + this.forceUniformStore?.destroy() + this.forceUniformStore = undefined + + // 5. Destroy Buffers (passed via attributes - NOT owned by Models) + if (this.pointIndices && !this.pointIndices.destroyed) this.pointIndices.destroy() + this.pointIndices = undefined + if (this.forceVertexCoordBuffer && !this.forceVertexCoordBuffer.destroyed) this.forceVertexCoordBuffer.destroy() + this.forceVertexCoordBuffer = undefined + } + + private destroyGridTargets (): void { + for (const target of this.gridTargets) { + if (target.fbo && !target.fbo.destroyed) target.fbo.destroy() + } + for (const target of this.gridTargets) { + if (target.texture && !target.texture.destroyed) target.texture.destroy() + } + this.gridTargets = [] + } +} diff --git a/src/stories/2. configuration.mdx b/src/stories/2. configuration.mdx index 4592d66b..fa3e1e85 100644 --- a/src/stories/2. configuration.mdx +++ b/src/stories/2. configuration.mdx @@ -80,7 +80,7 @@ All configuration properties are optional. When creating a graph or calling `set ## Simulation configuration -cosmos.gl layout algorithm was inspired by the [d3-force](https://github.com/d3/d3-force#forces) simulation forces: Link, Many-Body, Gravitation, and Centering. It provides several simulation settings to adjust the layout. Each of them can be changed in real time, while the simulation is in progress. +cosmos.gl layout algorithm was inspired by the [d3-force](https://github.com/d3/d3-force#forces) simulation forces: Link, Many-Body, Gravitation, Centering, and Collision. It provides several simulation settings to adjust the layout. Each of them can be changed in real time, while the simulation is in progress. | Property | Description | Recommended range | Default | |---|---|---|---| @@ -96,6 +96,9 @@ cosmos.gl layout algorithm was inspired by the [d3-force](https://github.com/d3/ | enableRightClickRepulsion | Enable or disable the repulsion force from mouse when right-clicking. When set to `true`, holding the right mouse button will activate the mouse repulsion force. When set to `false`, right-clicking will not trigger any repulsion force. | - | `false` | | simulationFriction | Friction coefficient. Values range from `0` (high friction, stops quickly) to `1` (no friction, keeps moving). | 0.0 – 1.0 | `0.85` | | simulationCluster | Cluster coefficient | 0.0 – 1.0 | `0.1` | +| simulationCollision | Collision force coefficient. When greater than `0`, points push each other apart when they overlap, so they don't stack on top of one another. Uses a GPU spatial-hash grid, so it scales to large graphs better than naive O(n²) collision. Set to `0` to disable (its GPU resources are then never allocated). | 0.0 – 1.0 | `0` | +| simulationCollisionRadius | Collision radius. When `0`, each point's collision radius is derived from its size (half of the point size). When greater than `0`, every point uses this fixed radius regardless of its size. | 0 – 100 | `0` | +| simulationCollisionPadding | Extra room added to every point's collision radius, in simulation space units. Without padding, points settle just touching; with padding, neighbouring points keep a gap of twice this value between their visual edges. Composes with both size-derived and fixed `simulationCollisionRadius`. | 0 – 50 | `0` | ## Event Callbacks diff --git a/src/stories/clusters.stories.ts b/src/stories/clusters.stories.ts index a6329fe2..065e0336 100644 --- a/src/stories/clusters.stories.ts +++ b/src/stories/clusters.stories.ts @@ -17,7 +17,7 @@ import polygonSelectionStyleRaw from './clusters/polygon-selection/style.css?raw import polygonSelectionPolygonRaw from './clusters/polygon-selection/polygon.ts?raw' const meta: Meta = { - title: 'Examples/Clusters', + title: 'Examples/Forces/Clustering', parameters: { controls: { disable: true, diff --git a/src/stories/forces.stories.ts b/src/stories/forces.stories.ts new file mode 100644 index 00000000..1d6d76b2 --- /dev/null +++ b/src/stories/forces.stories.ts @@ -0,0 +1,41 @@ +import type { Meta } from '@storybook/html' + +import { createStory, Story } from '@/graph/stories/create-story' +import { CosmosStoryProps } from './create-cosmos' +import { collision } from './forces/collision' +import { collisionStressTest } from './forces/collision-stress-test' + +import createCosmosRaw from './create-cosmos?raw' +import collisionRaw from './forces/collision?raw' +import collisionStressTestRaw from './forces/collision-stress-test?raw' + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta: Meta = { + title: 'Examples/Forces', +} + +const sourceCodeAddonParams = [ + { name: 'create-cosmos', code: createCosmosRaw }, +] + +export const Collision: Story = { + ...createStory(collision), + parameters: { + sourceCode: [ + { name: 'Story', code: collisionRaw }, + ...sourceCodeAddonParams, + ], + }, +} +export const CollisionStressTest: Story = { + ...createStory(collisionStressTest), + parameters: { + sourceCode: [ + { name: 'Story', code: collisionStressTestRaw }, + ...sourceCodeAddonParams, + ], + }, +} + +// eslint-disable-next-line import/no-default-export +export default meta diff --git a/src/stories/forces/collision-stress-test.ts b/src/stories/forces/collision-stress-test.ts new file mode 100644 index 00000000..e3dcc205 --- /dev/null +++ b/src/stories/forces/collision-stress-test.ts @@ -0,0 +1,58 @@ +import { Graph, getRgbaColor } from '@cosmos.gl/graph' +import { scaleSequential } from 'd3-scale' +import { interpolateRainbow } from 'd3-scale-chromatic' +import { createCosmos } from '../create-cosmos' + +export const collisionStressTest = (): { graph: Graph; div: HTMLDivElement } => { + // Stress test for the collision force on a large graph (50K points). + // Points are seeded with heavy overlap inside a dense disc, so the + // spatial-hash collision force has to resolve a large number of overlaps + // every tick — a worst case for its performance. The FPS monitor is enabled + // so the cost under load is visible. + const numPoints = 50_000 + const spaceCenter = 2048 + const seedRadius = 1500 + + const colorScale = scaleSequential(interpolateRainbow).domain([0, 1]) + + const pointPositions = new Float32Array(numPoints * 2) + const pointSizes = new Float32Array(numPoints) + const pointColors = new Float32Array(numPoints * 4) + + for (let i = 0; i < numPoints; i++) { + // Uniform area density within the disc (sqrt keeps it from clumping centre) + const angle = Math.random() * Math.PI * 2 + const radius = Math.sqrt(Math.random()) * seedRadius + pointPositions[i * 2] = spaceCenter + Math.cos(angle) * radius + pointPositions[i * 2 + 1] = spaceCenter + Math.sin(angle) * radius + + // Small points with a little variability so collision radii differ + pointSizes[i] = 2 + Math.random() * 15 + + // Colour by distance from the centre for a clean radial gradient + const rgba = getRgbaColor(colorScale(radius / seedRadius)) + pointColors[i * 4] = rgba[0] + pointColors[i * 4 + 1] = rgba[1] + pointColors[i * 4 + 2] = rgba[2] + pointColors[i * 4 + 3] = 1 + } + + return createCosmos({ + pointPositions, + pointSizes, + pointColors, + simulationCollision: 0.25, + simulationCollisionPadding: 1, + simulationCollisionRadius: undefined, // derive collision radius from point sizes + // Isolate the collision force: no repulsion, a gentle gravity that keeps the + // points packed so collision has to keep resolving overlap every tick. + simulationRepulsion: 0, + simulationGravity: 0.001, + simulationDecay: 100000, + simulationFriction: 0.85, + showFPSMonitor: true, // read the collision cost under load + fitViewOnInit: false, + fitViewDelay: 0, + fitViewDuration: 0, + }) +} diff --git a/src/stories/forces/collision.ts b/src/stories/forces/collision.ts new file mode 100644 index 00000000..f59e938a --- /dev/null +++ b/src/stories/forces/collision.ts @@ -0,0 +1,135 @@ +import { Graph, getRgbaColor } from '@cosmos.gl/graph' +import { scaleSequential } from 'd3-scale' +import { interpolateRainbow } from 'd3-scale-chromatic' +import { createCosmos } from '../create-cosmos' + +function getRandom (min: number, max: number): number { + return Math.random() * (max - min) + min +} + +export const collision = (): { graph: Graph; div: HTMLDivElement } => { + // Build a clustered network so the collision force has a graph-like + // structure to spread apart (instead of a featureless blob). + const numClusters = 6 + const numPoints = 600 + const spaceCenter = 2048 + + const clusterColorScale = scaleSequential(interpolateRainbow).domain([0, numClusters]) + + const pointPositions = new Float32Array(numPoints * 2) + const pointSizes = new Float32Array(numPoints) + const pointColors = new Float32Array(numPoints * 4) + const pointCluster = new Array(numPoints).fill(0) + const degree = new Array(numPoints).fill(0) + + // Spread cluster centers around a wide ring so the clusters start + // well separated (close to the resolved layout) rather than piled on + // top of each other at the centre. + const clusterRingRadius = 1500 + const clusterCenters: [number, number][] = [] + for (let c = 0; c < numClusters; c++) { + const angle = (c / numClusters) * Math.PI * 2 + clusterCenters.push([ + spaceCenter + Math.cos(angle) * clusterRingRadius, + spaceCenter + Math.sin(angle) * clusterRingRadius, + ]) + } + + // Assign points to clusters and seed positions spread out around each + // cluster centre, so they begin mostly non-overlapping and the + // simulation barely has to move them on start-up. + for (let i = 0; i < numPoints; i++) { + const cluster = i % numClusters + pointCluster[i] = cluster + const [cx, cy] = clusterCenters[cluster] as [number, number] + const angle = Math.random() * Math.PI * 2 + const radius = Math.sqrt(Math.random()) * 25 + pointPositions[i * 2] = cx + Math.cos(angle) * radius + pointPositions[i * 2 + 1] = cy + Math.sin(angle) * radius + } + + // Build links: mostly intra-cluster (a few neighbours each), plus a + // sprinkle of inter-cluster bridges. Track degree to size the points. + const links: number[] = [] + const addLink = (a: number, b: number): void => { + if (a === b) return + links.push(a, b) + degree[a] = (degree[a] as number) + 1 + degree[b] = (degree[b] as number) + 1 + } + + // Group point indices by cluster for easy intra-cluster wiring + const byCluster: number[][] = Array.from({ length: numClusters }, () => []) + for (let i = 0; i < numPoints; i++) byCluster[pointCluster[i] as number]!.push(i) + + for (const members of byCluster) { + for (const point of members) { + // Connect to ~1 random other member of the same cluster (occasionally 2), + // keeping the graph sparse enough for collision to spread it out. + const connections = Math.random() < 0.3 ? 2 : 1 + for (let k = 0; k < connections; k++) { + const other = members[Math.floor(Math.random() * members.length)] as number + addLink(point, other) + } + } + } + + // A few bridges between clusters + for (let i = 0; i < numPoints; i++) { + if (Math.random() < 0.02) { + const other = Math.floor(Math.random() * numPoints) + if (pointCluster[other] !== pointCluster[i]) addLink(i, other) + } + } + + // Sizes scale with degree so hubs are visibly larger; collision uses sizes + const maxDegree = Math.max(1, ...degree) + for (let i = 0; i < numPoints; i++) { + const hubness = (degree[i] as number) / maxDegree + pointSizes[i] = 4 + hubness * 24 + Math.random() * 5 + + const rgba = getRgbaColor(clusterColorScale(pointCluster[i] as number)) + pointColors[i * 4] = rgba[0] + pointColors[i * 4 + 1] = rgba[1] + pointColors[i * 4 + 2] = rgba[2] + pointColors[i * 4 + 3] = 1 + } + + // Colour each link by its source point's cluster, with a low alpha + const linkCount = links.length / 2 + const linkColors = new Float32Array(linkCount * 4) + const linkWidths = new Float32Array(linkCount) + for (let i = 0; i < linkCount; i++) { + const source = links[i * 2] as number + const rgba = getRgbaColor(clusterColorScale(pointCluster[source] as number)) + linkColors[i * 4] = rgba[0] + linkColors[i * 4 + 1] = rgba[1] + linkColors[i * 4 + 2] = rgba[2] + linkColors[i * 4 + 3] = 0.3 + linkWidths[i] = getRandom(0.3, 1.2) + } + + return createCosmos({ + pointPositions, + pointSizes, + pointColors, + links: new Float32Array(links), + linkColors, + linkWidths, + simulationCollision: 0.95, + simulationCollisionPadding: 2, + simulationCollisionRadius: undefined, // Use point sizes for collision radius + simulationRepulsion: 0.8, + simulationGravity: 0.05, + simulationCluster: 0.01, + // Link distance must clear the points' collision radii (sizes up to ~30), + // otherwise the spring pulls connected points into an unresolvable pile. + simulationLinkSpring: 0.3, + simulationLinkDistance: 50, + simulationDecay: 100000, + simulationFriction: 0.85, + fitViewOnInit: false, + fitViewDelay: 250, + fitViewDuration: 1000, + }) +} diff --git a/src/stories/transition/cities-transition.ts b/src/stories/transition/cities-transition.ts new file mode 100644 index 00000000..5cf5e48b --- /dev/null +++ b/src/stories/transition/cities-transition.ts @@ -0,0 +1,255 @@ +/** + * Demonstrates point position transitions using the cities CSV. + */ + +import { Graph, TransitionEasing, defaultConfigValues, getRgbaColor } from '@cosmos.gl/graph' + +import './transition.css' + +const CITIES_URL = 'https://assets.cosmograph.app/cities.csv' + +interface City { + continent: string; + population: number; + mercatorX: number; + mercatorY: number; + barX: number; + barY: number; + latX: number; + latY: number; +} + +interface Layout { + name: string; + x: 'mercatorX' | 'barX' | 'latX'; + y: 'mercatorY' | 'barY' | 'latY'; +} + +const LAYOUTS: Layout[] = [ + { name: 'Mercator', x: 'mercatorX', y: 'mercatorY' }, + { name: 'Latitude', x: 'latX', y: 'latY' }, + { name: 'Population bars', x: 'barX', y: 'barY' }, +] + +const CONTINENT_COLORS = new Map([ + ['Africa', '#fdb863'], + ['America', '#a090f0'], + ['Asia', '#a0e080'], + ['Europe', '#e86020'], + ['Oceania', '#40b8e8'], +]) + +interface CityData { + cities: City[]; + colors: Float32Array; + sizes: Float32Array; +} + +function parseCitiesCsv (text: string): City[] { + const lines = text.trim().split(/\r?\n/) + + return lines.slice(1).flatMap((line) => { + const columns = line.split(',').slice(-8) + const mercatorX = Number(columns[0]) + const mercatorY = Number(columns[1]) + const barX = Number(columns[2]) + const barY = Number(columns[3]) + const latX = Number(columns[4]) + const latY = Number(columns[5]) + const population = Number(columns[6]) + const continent = columns[7] + + if ( + continent === undefined || + !Number.isFinite(mercatorX) || + !Number.isFinite(mercatorY) || + !Number.isFinite(barX) || + !Number.isFinite(barY) || + !Number.isFinite(latX) || + !Number.isFinite(latY) || + !Number.isFinite(population) + ) { + return [] + } + + return [{ + continent, + population, + mercatorX, + mercatorY, + barX, + barY, + latX, + latY, + }] + }) +} + +function createPositions (cities: City[], layoutIndex: number): Float32Array { + const layout = LAYOUTS[layoutIndex] ?? LAYOUTS[0]! + const positions = new Float32Array(cities.length * 2) + + cities.forEach((city, i) => { + const offset = i * 2 + positions[offset] = city[layout.x] + positions[offset + 1] = city[layout.y] + }) + + return positions +} + +function createColors (cities: City[]): Float32Array { + const colors = new Float32Array(cities.length * 4) + + cities.forEach((city, i) => { + const color = CONTINENT_COLORS.get(city.continent) ?? '#b3b3b3' + const rgba = getRgbaColor(color) + const offset = i * 4 + colors[offset] = rgba[0] + colors[offset + 1] = rgba[1] + colors[offset + 2] = rgba[2] + colors[offset + 3] = 0.85 + }) + + return colors +} + +function createSizes (cities: City[]): Float32Array { + const sizes = new Float32Array(cities.length) + let maxPopulation = 0 + + for (const city of cities) { + maxPopulation = Math.max(maxPopulation, city.population) + } + + cities.forEach((city, i) => { + const population = city.population + sizes[i] = 5 + (Math.sqrt(population) / Math.sqrt(maxPopulation)) * 85 + }) + + return sizes +} + +async function loadCities (): Promise { + const response = await fetch(CITIES_URL) + if (!response.ok) throw new Error(`Failed to fetch cities: ${response.status}`) + + const cities = parseCitiesCsv(await response.text()).filter(city => city.continent !== 'Antarctica') + return { + cities, + colors: createColors(cities), + sizes: createSizes(cities), + } +} + +export const citiesTransition = async (): Promise<{ + graph: Graph; + div: HTMLDivElement; + destroy?: () => void; +}> => { + const { cities, colors, sizes } = await loadCities() + + let layoutIndex = 0 + let loopIntervalId: ReturnType | undefined + + const div = document.createElement('div') + div.className = 'app' + div.style.background = defaultConfigValues.backgroundColor + + const graphDiv = document.createElement('div') + graphDiv.className = 'graph' + div.appendChild(graphDiv) + + const layoutAction = document.createElement('div') + layoutAction.className = 'action' + layoutAction.textContent = LAYOUTS[layoutIndex]!.name + layoutAction.title = 'Switch to the next city layout.' + + const pausePlayAction = document.createElement('div') + pausePlayAction.className = 'action' + pausePlayAction.textContent = 'Pause' + pausePlayAction.title = 'Pause or resume the auto-loop.' + + const fitViewAction = document.createElement('div') + fitViewAction.className = 'action' + fitViewAction.textContent = 'FitView' + fitViewAction.title = 'Fit the camera to current points.' + + const actionsDiv = document.createElement('div') + actionsDiv.className = 'actions' + actionsDiv.appendChild(layoutAction) + actionsDiv.appendChild(pausePlayAction) + actionsDiv.appendChild(fitViewAction) + div.appendChild(actionsDiv) + + const graph = new Graph(graphDiv, { + enableSimulation: false, + pointDefaultSize: 2, + pointOpacity: 1, + scalePointsOnZoom: true, + transitionDuration: 2000, + transitionEasing: TransitionEasing.CubicInOut, + rescalePositions: true, + fitViewPadding: 0.12, + attribution: [ + [ + 'dataset by Fritz Lekschas,', + 'from Jupyter Scatter', + 'and GeoNames', + ].join(' '), + ].join('
'), + }) + + const renderLayout = (): void => { + graph.setPointPositions(createPositions(cities, layoutIndex)) + graph.render() + layoutAction.textContent = LAYOUTS[layoutIndex]!.name + } + + const stopLoopTimer = (): void => { + if (loopIntervalId === undefined) return + clearInterval(loopIntervalId) + loopIntervalId = undefined + } + + const showNextLayout = (): void => { + layoutIndex = (layoutIndex + 1) % LAYOUTS.length + renderLayout() + } + + const startLoop = (): void => { + loopIntervalId = setInterval(showNextLayout, 1800) + } + + graph.setPointPositions(createPositions(cities, layoutIndex)) + graph.setPointColors(colors) + graph.setPointSizes(sizes) + graph.render() + graph.fitView() + startLoop() + + layoutAction.addEventListener('click', showNextLayout) + + pausePlayAction.addEventListener('click', () => { + if (loopIntervalId !== undefined) { + stopLoopTimer() + pausePlayAction.textContent = 'Play' + } else { + startLoop() + pausePlayAction.textContent = 'Pause' + } + }) + + fitViewAction.addEventListener('click', () => { + graph.fitView() + }) + + return { + div, + graph, + destroy: (): void => { + stopLoopTimer() + graph.destroy() + }, + } +} diff --git a/src/stories/transition/point-transition.ts b/src/stories/transition/image-transition.ts similarity index 98% rename from src/stories/transition/point-transition.ts rename to src/stories/transition/image-transition.ts index bd813ff8..7deca145 100644 --- a/src/stories/transition/point-transition.ts +++ b/src/stories/transition/image-transition.ts @@ -24,7 +24,7 @@ const LOOP_STEPS = [ undefined, ] -export const pointTransition = async (): Promise<{ +export const imageTransition = async (): Promise<{ graph: Graph; div: HTMLDivElement; destroy?: () => void; diff --git a/src/stories/transition/point-transition.stories.ts b/src/stories/transition/point-transition.stories.ts index f2087922..573189f8 100644 --- a/src/stories/transition/point-transition.stories.ts +++ b/src/stories/transition/point-transition.stories.ts @@ -2,26 +2,29 @@ import type { Meta } from '@storybook/html' import { createStory, Story } from '@/graph/stories/create-story' import { CosmosStoryProps } from '@/graph/stories/create-cosmos' -import { pointTransition } from './point-transition' +import { imageTransition } from './image-transition' +import { citiesTransition } from './cities-transition' // @ts-expect-error Vite raw imports are resolved by Storybook at runtime. -import pointTransitionRaw from './point-transition?raw' +import imageTransitionRaw from './image-transition?raw' // @ts-expect-error Vite raw imports are resolved by Storybook at runtime. import transitionCssRaw from './transition.css?raw' // @ts-expect-error Vite raw imports are resolved by Storybook at runtime. import pointDataRaw from './point-data?raw' // @ts-expect-error Vite raw imports are resolved by Storybook at runtime. import transitionHelpersRaw from './transition-helpers?raw' +// @ts-expect-error Vite raw imports are resolved by Storybook at runtime. +import citiesTransitionRaw from './cities-transition?raw' const meta: Meta = { title: 'Examples/Transitions', } -export const PointTransition: Story = { - ...createStory(pointTransition), +export const ImageTransition: Story = { + ...createStory(imageTransition), parameters: { sourceCode: [ - { name: 'Story', code: pointTransitionRaw }, + { name: 'Story', code: imageTransitionRaw }, { name: 'transition.css', code: transitionCssRaw }, { name: 'point-data.ts', code: pointDataRaw }, { name: 'transition-helpers.ts', code: transitionHelpersRaw }, @@ -29,5 +32,15 @@ export const PointTransition: Story = { }, } +export const CitiesTransition: Story = { + ...createStory(citiesTransition), + parameters: { + sourceCode: [ + { name: 'Story', code: citiesTransitionRaw }, + { name: 'transition.css', code: transitionCssRaw }, + ], + }, +} + // eslint-disable-next-line import/no-default-export export default meta diff --git a/src/variables.ts b/src/variables.ts index 28d2c33c..daa772bc 100644 --- a/src/variables.ts +++ b/src/variables.ts @@ -72,6 +72,9 @@ export const defaultConfigValues = { simulationRepulsionFromMouse: 2, simulationFriction: 0.85, simulationCluster: 0.1, + simulationCollision: 0, + simulationCollisionRadius: undefined, + simulationCollisionPadding: 0, enableRightClickRepulsion: false, // Simulation callbacks