Skip to content

Network Visualization System

Technical reference for the Three.js network graph animation on the landing page.

Source file: src/scripts/network-scene.ts (~1,200 lines) Astro component: src/components/NetworkScene.astro Landing page: src/content/docs/index.mdx Stack: Astro 5.17 + Starlight 0.37, Three.js (core + fat-line addons)


The Astro component NetworkScene.astro provides a <div id="network-scene"> container with aria-hidden="true" and a <noscript> fallback (static SVG). A <script> block imports init from network-scene.ts and wires up lazy initialization:

  1. An IntersectionObserver (threshold 0.05) watches the container.
  2. When the container enters the viewport, init(container) runs and the observer disconnects.
  3. A guard prevents re-initialization if a <canvas> already exists.
  4. The astro:after-swap event re-runs setup to support Astro view transitions.
StepAction
1Create seeded PRNG (seed 42)
2buildGraph(rand) — construct initial graph (~44 nodes, ~60+ edges)
3Read current palette from DOM theme attribute
4Check prefers-reduced-motion media query
5Create <canvas>, append to container
6Create WebGLRenderer (antialias, alpha, pixel ratio capped at 2, clear color transparent)
7Create PerspectiveCamera (FOV 32deg, near 1, far 1200, position z=500)
8Create Scene
9Classify initial nodes by type; build GPU objects (see section 5)
10Register ResizeObserver on container
11Register mousemove listener on window
12Call syncPositions() + renderer.render() for first frame
13If prefers-reduced-motion is true, return (no animation loop)
14Start requestAnimationFrame loop
15Create MutationObserver for theme changes
16Return cleanup function

interface LiveNode {
x: number; // world-space X position
y: number; // world-space Y position
z: number; // world-space Z position
vx: number; // X velocity
vy: number; // Y velocity
kind: 'regular' | 'hub' | 'bridge' | 'peripheral';
cluster: number; // 0-2 for clustered nodes, -1 for bridge/peripheral
size: number; // visual size hint (unused by current shader)
baseOpacity: number; // 0-1 base brightness
alive: boolean; // false = fully dead
age: number; // seconds since creation (initial nodes start at 10)
fadeIn: number; // seconds for fade-in
dying: boolean; // true = in death fade
deathAge: number; // age when dying started
fadeOut: number; // seconds for death fade
bufIdx: number; // index into the GPU buffer for this node's visual type
}
interface LiveEdge {
a: number; // source node index
b: number; // target node index
alive: boolean;
age: number; // seconds since creation
fadeIn: number; // seconds for fade-in
dying: boolean; // true = in death fade
deathAge: number; // age when dying started
fadeOut: number; // seconds for death fade
bufIdx: number; // index into the edge visual buffer
maxAge: number; // Infinity = permanent, finite = auto-sever
highlight: number; // seconds of bright highlight color on creation
}
interface GraphState {
nodes: LiveNode[];
edges: LiveEdge[];
adj: number[][]; // adjacency list (bidirectional)
clusterCount: number; // always 3
clusterDrift: { dx: number; dy: number }[]; // per-cluster drift vectors
clusterCenters: { x: number; y: number }[]; // live cluster centroids
}

ClusterCenter (x, y, z)Node countSpread
0(-150, 90, -8)1160
1(140, 50, 6)1158
2(-30, -110, -4)1052

Each cluster contains one hub node at the center and satellites positioned at random angles and distances (35-100% of spread radius).

BridgePositionConnects clusters
0(-5, 78, 22)0 and 1
1(75, -35, 28)1 and 2
2(-115, -10, 18)0 and 2

Each bridge connects to the 2-3 nearest nodes in each of its paired clusters. All three bridges are connected to each other.

Hardcoded positions at the graph’s outer edges. No edges. baseOpacity: 0.4.

Edge typeRuleProbability
Hub spokesHub to each satellite55% per satellite
Ring connectionsSatellite[i] to Satellite[i+1]65% per pair
Random chordsSatellite[i] to Satellite[j] (j >= i+2)10% per pair
Bridge to clusterBridge to 3 nearest in each connected cluster100%
Inter-bridgeAll 3 bridges fully connected100%

All initial edges have maxAge: Infinity (permanent) and age: 10 (pre-aged to skip fade-in).


Edge-based force-directed layout. Three forces per clustered node:

Edge attraction (PRIMARY, EDGE_ATTRACT = 1.5)

Section titled “Edge attraction (PRIMARY, EDGE_ATTRACT = 1.5)”

Each node is pulled toward the average position of its live connected neighbors (computed from G.adj). This is the force that makes topology shape layout.

When a cross-cluster edge is added, the neighbor centroid shifts toward the distant node, pulling the local node. Its neighbors follow because their centroids update next frame. Cascading propagation emerges naturally. When the edge severs, the distant node drops out of the centroid calculation and remaining same-cluster neighbors pull the node back home.

Cluster gravity (WEAK, CLUSTER_ATTRACT = 0.5)

Section titled “Cluster gravity (WEAK, CLUSTER_ATTRACT = 0.5)”

Gentle pull toward cluster centroid. Prevents scatter when a node has few or no edges.

Per-cluster random direction vector (30 u/s magnitude). Rotates randomly every 5 seconds. Gives macro movement to the whole scene.

vx += (drift.dx + edgeFx + clusterFx) * dt
vy += (drift.dy + edgeFy + clusterFy) * dt
vx *= DAMPING // 0.88
vy *= DAMPING
x += vx * dt
y += vy * dt

Bridge and peripheral nodes only get edge attraction at 0.3x strength + gentler damping (0.98).


All rendering uses separate GPU objects per visual type. A single combined Points object with drawRange caused black screens in earlier iterations.

Node objects (4 Points, all renderOrder: 1)

Section titled “Node objects (4 Points, all renderOrder: 1)”
ObjectMaterialSizeColor sourceBlending
regPointsVertex-color ShaderMaterial20vec4 attribute (RGBA)Normal
bPointsUniform-color ShaderMaterial48uColor uniform (indigo)Normal
pPointsUniform-color ShaderMaterial14uColor uniform (grey)Normal
dynPointsVertex-color ShaderMaterial17vec4 attribute (RGBA)Normal

Custom GLSL ShaderMaterial using gl_PointCoord to draw procedural hard-edged circles with white outline. Two vertex shader variants feed a shared fragment shader:

  • Vertex-color variant: reads attribute vec4 color (rgb=tint, a=opacity). For regular + dynamic nodes.
  • Uniform-color variant: reads uniform vec3 uColor + uniform float uOpacity. For bridge + peripheral nodes.

Fragment shader logic:

  1. dist > 0.5 — discard (outside circle)
  2. smoothstep(0.47, 0.5, dist) — anti-aliased outer edge
  3. smoothstep(innerR - 0.02, innerR, dist) — outline ring transition
  4. Outline color = min(1.0, max(r,g,b) * uOutlineBoost) (brightness-derived)
  5. mix(fill, outline, ring) * uBrightness — final color
  6. Output vec4(col, outer * vAlpha) — proper alpha for opacity

Nodes use NormalBlending + depthTest: false + renderOrder: 1 so they render as fully opaque circles on top of edges.

Edge objects (3 LineSegments2, all renderOrder: 0)

Section titled “Edge objects (3 LineSegments2, all renderOrder: 0)”
ObjectMaterialLine widthOpacity
Regular edgesLineMaterial (uniform color)2px0.20
Bridge edgesLineMaterial (uniform color)3px0.35
Dynamic edgesLineMaterial (vertex colors)2pxvaries

LineSegments2 from Three.js addons renders each line segment as a screen-space quad. Works on all platforms (WebGL’s native lineWidth is capped at 1px). LineMaterial requires viewport resolution set on resize.

New edges start with a bright highlight color (white in dark mode) and lerp to normal edge color over the highlight duration (1.5-2.0s).


Node spawn (every 0.8s base, scroll-accelerated)

Section titled “Node spawn (every 0.8s base, scroll-accelerated)”
  • Pick cluster (smaller clusters weighted higher)
  • Spawn at random angle/distance from cluster center
  • Connect to 2-4 nearest same-cluster nodes
  • Fade in over 0.4-0.8s
  • Max 150 dynamic nodes

Node death (every 1.2s base, scroll-accelerated)

Section titled “Node death (every 1.2s base, scroll-accelerated)”
  • Pick from oldest regular nodes (random from top 8)
  • Mark dying, fade out over 0.6-1.0s
  • All connected edges also begin dying
  • On death: clean G.adj (remove from all neighbor lists)
  • Min population guard: skip if 5 or fewer candidates

Cross-cluster random edges (every 2.5s base, scroll-accelerated)

Section titled “Cross-cluster random edges (every 2.5s base, scroll-accelerated)”
  • Pick two alive nodes from different clusters
  • Create edge with maxAge = 6-14s, highlight = 2.0s
  • Edge-based physics immediately pulls both endpoints together
  • Their neighborhoods follow via cascading neighbor-centroid shifts
  • On maxAge: edge auto-severs, adjacency cleaned, nodes drift back
  • Max 400 dynamic edges

When edges die or nodes die, entries are removed from G.adj. This is critical for edge-based physics — without cleanup, dead connections would still exert phantom attraction forces.


scrollFraction = scrollY / (2 * viewportHeight), clamped 0-1
EffectFormulaRange
Container opacity1 - scrollFraction * 0.31.0 to 0.7
Churn multiplier1 + scrollFraction * 101x to 11x

The churn multiplier accelerates all three lifecycle timers equally.

Camera x/y follows cursor position (plus/minus 30/22 units), smoothed with 0.04 lerp factor. Camera always looks at origin.


MutationObserver on document.documentElement watches the data-theme attribute.

ColorDark modeLight mode
Cluster 0#aabbee#4466aa
Cluster 1#ccaaee#7744aa
Cluster 2#88ddcc#228877
Bridge#a5b4fc#6366f1
Edge#d4d4d8#71717a
Edge highlight#ffffff#e0e7ff
Peripheral#a1a1aa#a1a1aa

On theme change: rebuilds palette, updates uniform colors, rewrites vertex color buffers, updates edge materials.


  • Container has aria-hidden="true" — screen readers skip the visualization
  • <noscript> block provides static SVG fallback
  • prefers-reduced-motion: reduce renders a single static frame, no animation loop

dt is capped at 100ms to prevent physics explosions after tab backgrounding.

OrderOperation
1Compute scroll fraction and churn multiplier
2Set container opacity
3Update mouse parallax
4Age all alive nodes and edges
5Check edge lifespans (auto-sever expired)
6Run physics (stepPhysics)
7Spawn nodes if interval exceeded
8Kill nodes if interval exceeded
9Spawn cross-cluster edges if interval exceeded
10Sync all positions/colors to GPU
11Render

ConstantValuePurpose
PRNG seed42Deterministic initial layout
Camera Z500Distance from scene
Camera FOV32degNarrow FOV for less distortion
EDGE_ATTRACT1.5Primary force strength
CLUSTER_ATTRACT0.5Background gravity
DAMPING0.88Velocity decay per frame
Drift speed30 u/sPer-cluster drift magnitude
Drift rotation interval5sHow often drift direction changes
Spawn interval0.8sBase time between node spawns
Destroy interval1.2sBase time between node kills
Random edge interval2.5sBase time between cross-cluster edges
Cross-edge lifespan6-14sBefore auto-sever
Max dynamic nodes150Pre-allocated buffer size
Max dynamic edges400Pre-allocated buffer size
Scroll churn multiplier10xMax churn acceleration
uBrightness0.85Global node brightness multiplier
uOutlineWidth0.08Outline ring width (0.06 for bridge)
uOutlineBoost1.4Outline brightness multiplier
Node size (regular)20Point sprite size
Node size (bridge)48Larger for visual prominence
Node size (peripheral)14Smaller, dimmer
Node size (dynamic)17Spawned nodes
Edge width (regular)2pxScreen-space line width
Edge width (bridge)3pxThicker for bridge connections

PackageUsage
three (core)Scene, Camera, Renderer, BufferGeometry, Points, ShaderMaterial, Color, Vector2
three/examples/jsm/lines/LineSegments2.jsFat line segment rendering
three/examples/jsm/lines/LineSegmentsGeometry.jsGeometry for fat lines
three/examples/jsm/lines/LineMaterial.jsMaterial for screen-space line width

No other runtime dependencies. The file is a self-contained module.


  1. Separate Points per type: A single combined Points with drawRange produced black screens. Each node type gets its own Points object.
  2. Seeded PRNG: Seed 42 ensures identical initial graph on every page load.
  3. RGBA vertex colors: RGB + Alpha stored separately (4-component attribute) allows NormalBlending with proper opacity for opaque node circles.
  4. NormalBlending + renderOrder: Nodes at renderOrder 1 fully occlude edges at renderOrder 0.
  5. Procedural circles: No texture needed. Fragment shader draws circles using gl_PointCoord.
  6. Adjacency cleanup: Dead edges/nodes are removed from G.adj to prevent phantom physics forces.