Skip to content
scsiwyg
sign insign up
get startedhow it worksmcpscsiblogcommunityapiplaygroundswaggersign insign up
Atlas·From Flat to Spatial: Replacing D3.js SVG with a 3D WebGL Knowledge Graph16 Apr 2026David Olsson

From Flat to Spatial: Replacing D3.js SVG with a 3D WebGL Knowledge Graph

#atlas#devlog#feature#building-in-public

David OlssonDavid Olsson

Atlas builds a knowledge graph as the LLM reads your documents. Entities become nodes, extracted relationships become edges, and the whole structure grows in real time. Until recently, we rendered that graph using D3's SVG-based force simulation — the standard choice, solid and predictable. It also has a ceiling: once a graph gets dense, SVG starts to choke, and the 2D layout collapses into a tangled hairball.

We replaced it with a WebGL 3D force-directed graph backed by Three.js. Here is what changed and why the implementation is more interesting than it first looks.

What it looks like

Here is Atlas after a completed graph build — 263 entity nodes and 350 relationship edges extracted from a set of uploaded documents, rendered in the 3D graph panel. The right side shows the completed workflow steps: ontology generation (entity and relation types the LLM designed for this document set), the graph build stats, and the prompt to proceed to environment setup. The status bar at the bottom shows the last LLM call cost and running totals.

The entity type legend in the bottom-left lists 30+ types — Company, ResearchInstitution, Person, FieldProfessional, Application, SoftwarePlatform, and others — each color-coded in the graph. Edge labels (WORKS_FOR, USES_PLATFORM, PARTICIPATES_IN, ENDORSES, etc.) are toggled on, showing the relationship types the ontology generated.

This is a real build, not a demo. The graph was constructed by gpt-4.1-nano processing 103 document chunks in parallel, completing in under two minutes at $0.995 total LLM cost.

The rendering pipeline

The key architectural decision was keeping the existing data model unchanged. processGraphData() still handles multi-edge curvature, self-loop grouping, and pair-key deduplication — the 3D renderer just consumes the same { nodes, links } output that D3 did. The swap was purely at the render boundary.

Setting up the 3D scene

The initial graph creation happens once. Subsequent data updates reuse the same instance, which preserves camera position and the force layout across live updates — critical because the graph is building while you watch it.

const graph = ForceGraph3D({
  controlType: 'orbit'
})(graphMount.value)
  .width(width)
  .height(height)
  .backgroundColor('#fafafa')
  .showNavInfo(false)
  .nodeRelSize(5)
  .nodeColor(node => getColor(node.type))
  .nodeThreeObjectExtend(true)
  .nodeThreeObject(node => {
    const label = node.name.length > 14 ? node.name.substring(0, 14) + '...' : node.name
    const sprite = new SpriteText(label)
    sprite.color = '#444444'
    sprite.textHeight = 2.5
    sprite.fontWeight = '500'
    sprite.position.set(0, 7, 0)
    return sprite
  })
  .linkDirectionalArrowLength(2.5)
  .linkDirectionalArrowRelPos(1)
  .linkCurvature(link => link.curvature)
  .linkCurveRotation(link => link.curveRotation || 0)

graph.d3Force('charge').strength(-80)
graph.d3Force('link').distance(40)

nodeThreeObjectExtend(true) tells the library to keep the default sphere and attach our SpriteText on top, rather than replacing the node geometry entirely. Edge labels work the same way — each link gets a SpriteText stored in a Map keyed by the link object, so toggling visibility is a single iteration over that map with no graph re-render.

Zoom to node

Clicking a node opens the detail panel and also moves the camera to a fixed distance from that node using Three.js's cameraPosition with a 1-second eased transition.

.onNodeClick(node => {
  selectedNodeId.value = node.id
  selectedItem.value = {
    type: 'node',
    data: node.rawData,
    entityType: node.type,
    color: getColor(node.type)
  }
  refreshColors()

  const distance = 80
  const distRatio = 1 + distance / Math.hypot(node.x || 1, node.y || 1, node.z || 1)
  graph.cameraPosition(
    { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio },
    node,   // look-at target
    1000    // ms
  )
})

Math.hypot(node.x, node.y, node.z) gives the node's distance from the origin. Normalizing by that value and scaling by distance places the camera at a consistent orbital radius regardless of where in 3D space the node landed. The second argument to cameraPosition is the look-at point, so the camera always faces the node at the end of the transition.

Live updates without layout thrashing

The graphData prop is watched with { deep: true }. When new entities arrive from the LLM, the watch fires and we call renderGraph(). The critical branch:

js
if (graphInstance) {
  linkLabelSprites.clear()
  graphInstance.graphData({ nodes, links })
  return
}

Feeding new data to an existing instance is a hot update. The force simulation continues, new nodes join with initial velocities, and the camera does not move. This is what makes the live-build experience feel continuous rather than flickering.

Spacebar panning

One detail worth noting: OrbitControls defaults to left-drag for rotation. Panning requires middle-drag or right-drag, which is non-obvious. We added a spacebar modifier that swaps the left mouse button to pan mode while held:

js
const onKeyDown = (e) => {
  if (e.code === 'Space' && !spaceDown) {
    spaceDown = true
    controls.enableRotate = false
    controls.mouseButtons = { LEFT: 2 } // 2 = PAN
  }
}

controls.screenSpacePanning = true keeps pan motion in the screen plane so the graph does not drift into or out of the screen on a diagonal.

What we kept

Every feature from the D3 implementation survived the rewrite:

  • Detail panel for nodes and edges (including self-loop groups with expand/collapse)
  • Entity type legend with consistent color assignment
  • Edge label toggle (visibility-only, no re-render)
  • Multi-edge curvature and rotation to separate parallel links
  • ResizeObserver for responsive sizing as panels expand and collapse

The commit was 475 insertions and 462 deletions — roughly symmetric, which reflects a genuine rewrite rather than a layer added on top.

What changed for users

The graph is navigable in three dimensions now. Dense clusters that used to overlap in 2D separate themselves on the Z axis. Directional arrows on every edge make relationship direction unambiguous without needing to read labels. And because we are on WebGL, rendering stays smooth even as the graph scales into the hundreds of nodes that a multi-document session produces.

The knowledge graph view is one of the more honest parts of atlas — it shows exactly what the system extracted and how it connected things. Getting the renderer right felt important.

Share
𝕏 Post