Module

Hylograph.Kernel.D3.Simulation

Package
purescript-hylograph-d3-kernel
Repository
afcondon/purescript-hylograph-d3-kernel

Force Engine Simulation

High-level API for running force simulations. This module provides a clean interface for:

  • Creating and configuring simulations
  • Running the animation loop
  • Handling tick callbacks for rendering

Unlike D3's simulation, we have full control over:

  • When forces are applied
  • How alpha decays
  • When to render

== IMPORTANT: Force Target Caching

D3's forceX/forceY forces cache their target values at initialization time. This is a significant gotcha when animating between different target positions.

The Problem:

If you update node.gridX and call reheat, the force will still use the old cached values. The simulation appears to run but nodes won't move to new positions.

Wrong approach:

updateGridXWithFn newXFn sim
reheat sim  -- Nodes don't move!

Correct approach:

-- Use the helper that handles re-initialization:
updateGridXYAndReinit (Just newXFn) Nothing forceXHandle Nothing sim

-- Or manually re-initialize the force:
updateGridXWithFn newXFn sim
currentNodes <- getNodes sim
_ <- Core.initializeForce forceXHandle currentNodes
reheat sim

The updateGridXYAndReinit function is the recommended way to animate force-directed transitions as it handles this caching issue automatically.

#SimulationNode Source

type SimulationNode :: Row Type -> Typetype SimulationNode r = Record (D3_ID + D3_XY + D3_VxyFxy + r)

A simulation node with all required fields for force simulation and transitions. Extends user data row with id, position (x/y), velocity (vx/vy), and fixed position (fx/fy).

Example:

type MyNode = SimulationNode (name :: String, group :: Int)
-- Expands to: { id :: Int, x, y, vx, vy, fx, fy, name :: String, group :: Int }

#NodeID Source

type NodeID = Int

Node ID type (Int for efficient lookups)

#D3_ID Source

type D3_ID :: Row Type -> Row Typetype D3_ID row = (id :: NodeID | row)

Composable row types for building simulation nodes These can be combined with (+) to create custom node types

#D3_XY Source

type D3_XY :: Row Type -> Row Typetype D3_XY row = (x :: Number, y :: Number | row)

#D3_VxyFxy Source

type D3_VxyFxy :: Row Type -> Row Typetype D3_VxyFxy row = (fx :: Nullable Number, fy :: Nullable Number, vx :: Number, vy :: Number | row)

#D3_FocusXY Source

type D3_FocusXY :: Row Type -> Row Typetype D3_FocusXY row = (cluster :: Int, focusX :: Number, focusY :: Number | row)

#Simulation Source

type Simulation :: Row Type -> Row Type -> Typetype Simulation row linkRow = { alpha :: Ref Number, callbacks :: Maybe SimulationCallbacks, cancelAnimation :: Ref (Effect Unit), config :: SimConfig, forces :: Ref (Map String ForceHandle), links :: Ref (Array { source :: Int, target :: Int | linkRow }), nodes :: Ref (Array (SimulationNode row)), prevAlpha :: Ref Number, running :: Ref Boolean, tickCallback :: Ref (Effect Unit) }

A running simulation This is a mutable structure that holds the simulation state

#SimConfig Source

type SimConfig = { alphaDecay :: Number, alphaMin :: Number, alphaTarget :: Number, velocityDecay :: Number }

Simulation configuration

#defaultConfig Source

defaultConfig :: SimConfig

Default simulation configuration

#create Source

create :: forall row linkRow. SimConfig -> Effect (Simulation row linkRow)

Create a new simulation (without callbacks)

For the new callback-based API, use createWithCallbacks instead.

#createWithCallbacks Source

createWithCallbacks :: forall row linkRow. SimConfig -> SimulationCallbacks -> Effect (Simulation row linkRow)

Create a new simulation with callback system

Example:

callbacks <- defaultCallbacks
onSimulationTick updateDOM callbacks
onSimulationStop (log "Simulation settled") callbacks
sim <- createWithCallbacks config callbacks

#setNodes Source

setNodes :: forall row linkRow. Array (SimulationNode row) -> Simulation row linkRow -> Effect Unit

Set the nodes for the simulation This initializes nodes and re-initializes all forces

#addForce Source

addForce :: forall row linkRow. ForceSpec -> Simulation row linkRow -> Effect Unit

Add a force to the simulation

#addForceHandle Source

addForceHandle :: forall row linkRow. String -> ForceHandle -> Simulation row linkRow -> Effect Unit

Add a pre-initialized force handle to the simulation Use this when you've created and initialized a force handle manually (e.g., for dynamic forces not covered by ForceSpec)

#removeForce Source

removeForce :: forall row linkRow. String -> Simulation row linkRow -> Effect Unit

Remove a force from the simulation

#clearForces Source

clearForces :: forall row linkRow. Simulation row linkRow -> Effect Unit

Clear all forces from the simulation Used when switching between force configurations (e.g., grid vs tree vs orbit)

#start Source

start :: forall row linkRow. Simulation row linkRow -> Effect Unit

Start the simulation animation loop

#stop Source

stop :: forall row linkRow. Simulation row linkRow -> Effect Unit

Stop the simulation

#tick Source

tick :: forall row linkRow. Simulation row linkRow -> Effect Number

Run a single tick of the simulation Returns the new alpha value

#reheat Source

reheat :: forall row linkRow. Simulation row linkRow -> Effect Unit

Reheat the simulation (set alpha to 1)

#onTick Source

onTick :: forall row linkRow. Effect Unit -> Simulation row linkRow -> Effect Unit

Set the tick callback (legacy single-callback API) This is called after each simulation tick

#setCallbacks Source

setCallbacks :: forall row linkRow. SimulationCallbacks -> Simulation row linkRow -> Simulation row linkRow

Set the callbacks for a simulation (new multi-callback API) Note: This replaces any callbacks passed to createWithCallbacks

#getCallbacks Source

getCallbacks :: forall row linkRow. Simulation row linkRow -> Maybe SimulationCallbacks

Get the current callbacks (if any)

#isRunning Source

isRunning :: forall row linkRow. Simulation row linkRow -> Effect Boolean

Check if the simulation is running

#getAlpha Source

getAlpha :: forall row linkRow. Simulation row linkRow -> Effect Number

Get the current alpha value

#getNodes Source

getNodes :: forall row linkRow. Simulation row linkRow -> Effect (Array (SimulationNode row))

Get the current nodes (with updated positions)

#PositionMap Source

type PositionMap = Object { x :: Number, y :: Number }

Position map type (keyed by node id as string)

#updatePositionsInPlace Source

updatePositionsInPlace :: forall row linkRow. PositionMap -> Simulation row linkRow -> Effect Unit

Update node positions in place from a position map Mutates the simulation's internal nodes (same objects bound to D3)

#interpolatePositionsInPlace Source

interpolatePositionsInPlace :: forall row linkRow. PositionMap -> PositionMap -> Number -> Simulation row linkRow -> Effect Unit

Interpolate node positions in place between start and target Progress should be 0.0 to 1.0 (apply easing before calling)

#pinNodesInPlace Source

pinNodesInPlace :: forall row linkRow. Simulation row linkRow -> Effect Unit

Pin all nodes at their current positions (fx = x, fy = y) Use before starting a transition to freeze current state

#unpinNodesInPlace Source

unpinNodesInPlace :: forall row linkRow. Simulation row linkRow -> Effect Unit

Unpin all nodes (fx = null, fy = null) Use after transition to Grid scene to let forces take over

#pinNodesAtPositions Source

pinNodesAtPositions :: forall row linkRow. PositionMap -> Simulation row linkRow -> Effect Unit

Pin nodes at specific positions from a position map Sets both x/y and fx/fy - use at end of transition to non-Grid scenes

#updateGridPositionsInPlace Source

updateGridPositionsInPlace :: forall row linkRow. PositionMap -> Simulation row linkRow -> Effect Unit

Update node gridX/gridY positions in place from a positions map Used to change force targets before reheating the simulation The positions map is keyed by node.id (as string) -> { x, y } where x becomes gridX and y becomes gridY

#updateGridXWithFn Source

updateGridXWithFn :: forall row linkRow. (SimulationNode row -> Number) -> Simulation row linkRow -> Effect Unit

Update gridX for all nodes using a function Useful for toggle animations where only the X target changes Example: toggle between combined (all same X) and separated (X by department)

#updateGridYWithFn Source

updateGridYWithFn :: forall row linkRow. (SimulationNode row -> Number) -> Simulation row linkRow -> Effect Unit

Update gridY for all nodes using a function Useful for toggle animations where only the Y target changes

#updateGridXYAndReinit Source

updateGridXYAndReinit :: forall row linkRow. Maybe (SimulationNode row -> Number) -> Maybe (SimulationNode row -> Number) -> ForceHandle -> Maybe ForceHandle -> Simulation row linkRow -> Effect Unit

Update gridX and/or gridY for all nodes and re-initialize the forces

This is the recommended way to animate force-directed transitions. It handles the D3 force caching issue automatically by:

  1. Updating gridX/gridY values on nodes
  2. Re-initializing the ForceXGrid/ForceYGrid forces (so they pick up new targets)
  3. Reheating the simulation

IMPORTANT: D3's forceX/forceY cache target values at initialization time. Simply updating node.gridX/gridY won't make the force see new values. This function handles that by re-initializing the forces after updating.

Example:

-- Move nodes to new X positions (Y unchanged)
updateGridXYAndReinit
  (Just (\node -> departmentX node.department))
  Nothing
  forceXHandle
  Nothing
  sim

-- Move nodes to new X and Y positions
updateGridXYAndReinit
  (Just xFn)
  (Just yFn)
  forceXHandle
  (Just forceYHandle)
  sim

#attachDrag Source

attachDrag :: forall row linkRow. Array Element -> Simulation row linkRow -> Effect Unit

Attach simulation-aware drag behavior to node elements

This sets up D3 drag handlers that:

  1. Reheat the simulation on drag start
  2. Update fx/fy (fixed position) during drag
  3. Clear fx/fy on drag end (release node)

Example:

elements <- select ".node" >>= selectAll
attachDrag elements sim

#attachGroupDrag Source

attachGroupDrag :: forall row linkRow. Array Element -> String -> Simulation row linkRow -> Effect Unit

Attach drag to transformed group elements (like bubble packs)

Unlike attachDrag, this version takes a container selector to get pointer coordinates in the correct coordinate space. Use this when dragging <g> elements that have transform attributes.

Example:

-- For bubble packs inside a zoom group
packElements <- select ".module-pack" >>= selectAll
attachGroupDrag packElements "#zoom-group" sim

#attachPinningDrag Source

attachPinningDrag :: forall row linkRow. Array Element -> Simulation row linkRow -> Effect Unit

Attach drag with toggle-pinning behavior

Unlike attachDrag, this version keeps nodes pinned after drag ends.

  • First drag: pins the node where you drop it
  • Subsequent drags: if you barely move (< 3px), unpins the node

This is useful for force playgrounds and exploration where users want to "fix" certain nodes in place while letting others float freely.

Example:

nodeElements <- select ".node" >>= selectAll
attachPinningDrag nodeElements sim

#querySelectorElements Source

querySelectorElements :: String -> Effect (Array Element)

Query DOM elements by CSS selector

Convenience re-export from Core for getting element references.

Example:

nodeCircles <- querySelectorElements "#my-graph circle"
attachPinningDrag nodeCircles sim