Module

Hylograph.ForceEngine

Package
purescript-hylograph-simulation
Repository
afcondon/purescript-hylograph-simulation

Pure Force Engine

A clean, debuggable force simulation engine. Uses D3's force calculation algorithms but manages the simulation ourselves.

Key benefits over d3.forceSimulation:

  • Full control over when forces run
  • Predictable, debuggable behavior
  • Clean PureScript types
  • No hidden state or timers

Usage:

import Hylograph.ForceEngine as FE

main = do
  sim <- FE.create FE.defaultConfig
  FE.setNodes myNodes sim
  FE.addForce (FE.ManyBody "charge" FE.defaultManyBody) sim
  FE.onTick (renderNodes sim) sim
  FE.start sim

Re-exports from Hylograph.ForceEngine.Core

#ForceHandle Source

#simulationTick Source

simulationTick :: forall r. { alpha :: Number, alphaDecay :: Number, alphaMin :: Number, alphaTarget :: Number, forces :: Array ForceHandle, nodes :: Array (Record r), velocityDecay :: Number } -> Effect Number

Complete simulation tick:

  1. Apply all forces
  2. Integrate positions
  3. Return new alpha

#logNodes Source

logNodes :: forall r. String -> Array (Record r) -> Effect Unit

Log node positions for debugging

#integratePositions Source

integratePositions :: forall r. Array (Record r) -> Number -> Effect Unit

Integrate positions: apply velocity decay and update positions Call this once per tick after all forces have been applied

#initializeNodes Source

initializeNodes :: forall r. Array (Record r) -> Effect Unit

Initialize nodes with indices and default velocities This mutates the nodes array to add index, vx, vy if missing

#initializeLinkForce Source

initializeLinkForce :: forall nodeRow linkRow. ForceHandle -> Array (Record nodeRow) -> Array (Record linkRow) -> Effect ForceHandle

Initialize a link force with nodes and links Link forces need both nodes and links

#initializeForce Source

initializeForce :: forall r. ForceHandle -> Array (Record r) -> Effect ForceHandle

Initialize a force with nodes Must be called before applying the force

#decayAlpha Source

decayAlpha :: Number -> Number -> Number -> Number -> Number

Calculate new alpha value (the "cooling" step) Returns 0 if alpha falls below alphaMin

#createRadial Source

createRadial :: RadialConfig -> ForceHandle

Create a radial force

#createManyBody Source

createManyBody :: ManyBodyConfig -> ForceHandle

Create a many-body (charge) force

#createForceY Source

createForceY :: ForceYConfig -> ForceHandle

Create a Y positioning force

#createForceX Source

createForceX :: ForceXConfig -> ForceHandle

Create an X positioning force

#createCollide Source

createCollide :: CollideConfig -> ForceHandle

Create a collision force

#createCenter Source

createCenter :: CenterConfig -> ForceHandle

Create a centering force

#applyForces Source

applyForces :: Array ForceHandle -> Number -> Effect Unit

Apply multiple forces in sequence

#applyForce Source

applyForce :: ForceHandle -> Number -> Effect Unit

Apply a single force This mutates vx/vy on the nodes the force was initialized with

Re-exports from Hylograph.ForceEngine.Events

#SimulationEvent Source

data SimulationEvent

Events that can occur during simulation lifecycle.

These types are primarily for Halogen integration via subscriptions. Internally, callbacks are invoked directly without constructing these values.

Usage with Halogen:

handleAction (SimEvent event) = case event of
  Tick -> liftEffect updateDOMPositions
  Started -> H.modify_ _ { simRunning = true }
  Stopped -> H.modify_ _ { simRunning = false }
  AlphaDecayed alpha -> when (alpha < 0.1) $ liftEffect doSomething

Constructors

Instances

#SimulationCallbacks Source

type SimulationCallbacks = { onAlphaThreshold :: Ref (Number -> Effect Unit), onStart :: Ref (Effect Unit), onStop :: Ref (Effect Unit), onTick :: Ref (Effect Unit) }

Callbacks for simulation events.

All callbacks are stored in Refs to allow dynamic updates and to keep the tick loop allocation-free (no closures created per-tick).

The callbacks are Effect Unit rather than taking event data because:

  1. Performance: No boxing/unboxing of event data in hot path
  2. Simplicity: Consumers can read simulation state directly if needed
  3. Flexibility: The consumer decides what data they need

#onSimulationTick Source

onSimulationTick :: Effect Unit -> SimulationCallbacks -> Effect Unit

Set the tick callback.

WARNING: This callback runs 60x/second during simulation. Keep it fast! Avoid allocations, complex logic, or console.log.

Good:

onSimulationTick (updateGroupPositions nodes) callbacks

Bad:

onSimulationTick (do
  nodes <- Ref.read nodesRef  -- Extra ref read
  log "tick"                   -- Console I/O
  H.tell ...                   -- Halogen message (allocates)
) callbacks

#onSimulationStop Source

onSimulationStop :: Effect Unit -> SimulationCallbacks -> Effect Unit

Set the stop callback.

Called when simulation stops (alpha < alphaMin). Safe to do heavier work here.

#onSimulationStart Source

onSimulationStart :: Effect Unit -> SimulationCallbacks -> Effect Unit

Set the start callback.

Called when simulation starts running or is reheated from stopped state. Safe to do heavier work here (only called occasionally).

#onAlphaDecay Source

onAlphaDecay :: (Number -> Effect Unit) -> SimulationCallbacks -> Effect Unit

Set the alpha threshold callback.

Called when alpha crosses common thresholds: 0.5, 0.1, 0.01. The callback receives the new alpha value.

Useful for:

  • Progressive rendering (show more detail as simulation settles)
  • UI updates (e.g., hide "Simulating..." spinner)
  • Analytics (track how long simulations run)

#defaultCallbacks Source

defaultCallbacks :: Effect SimulationCallbacks

Create default callbacks (all no-ops).

Usage:

callbacks <- defaultCallbacks
Ref.write updateDOMPositions callbacks.onTick
sim <- createWithCallbacks config callbacks

Re-exports from Hylograph.ForceEngine.Links

#swizzleLinksByIndex Source

swizzleLinksByIndex :: forall node rawLink swizzled. (node -> Int) -> Array node -> Array { source :: Int, target :: Int | rawLink } -> (node -> node -> Int -> { source :: Int, target :: Int | rawLink } -> swizzled) -> Array swizzled

Swizzle links by looking up nodes by their index field

USE THIS when working with a FILTERED node subset where array positions don't match node.index values. Looks up nodes by their .index field and silently drops links where either endpoint is not found.

PERFORMANCE: Uses FFI-backed IntMap for O(1) lookups instead of O(n) find.

This is essential for GUP patterns where you show only a subset of nodes.

Example:

let visibleNodes = filter isVisible allNodes
    swizzled = swizzleLinksByIndex _.index visibleNodes links \src tgt i link ->
      { source: src, target: tgt, index: i, value: link.value }

#filterLinksToSubset Source

filterLinksToSubset :: forall node linkData. (node -> Int) -> Array node -> Array { source :: Int, target :: Int | linkData } -> Array { source :: Int, target :: Int | linkData }

Filter links to only those where both endpoints are in the node subset

PERFORMANCE: Uses FFI-backed IntSet for O(1) membership instead of O(n) elem.

Use this before swizzling when you want to show links only between visible/selected nodes. Pairs naturally with swizzleLinksByIndex.

Example:

let visibleNodes = filter isVisible allNodes
    visibleLinks = filterLinksToSubset _.index visibleNodes allLinks
    swizzled = swizzleLinksByIndex _.index visibleNodes visibleLinks transform

Re-exports from Hylograph.ForceEngine.Registry

#AnySimulation Source

data AnySimulation

Internal registry storage. Uses existential encoding to store simulations with different row types. The trade-off is we can only perform operations that work on any simulation.

#unsafeFromAny Source

unsafeFromAny :: forall row linkRow. AnySimulation -> Simulation row linkRow

#unregister Source

unregister :: String -> Effect Unit

Unregister a simulation by name.

Does nothing if no simulation with this name exists. The simulation is NOT stopped automatically.

#stopAll Source

stopAll :: Effect Unit

Stop all registered simulations.

Useful when cleaning up, e.g., when navigating away from a page.

#register Source

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

Register a simulation with a name.

If a simulation with this name already exists, it will be replaced. The old simulation is NOT stopped automatically.

sim <- create defaultConfig
register "call-graph" sim

#lookup Source

lookup :: String -> Effect (Maybe AnySimulation)

Look up a simulation by name.

WARNING: The returned simulation has an unknown row type. Only use operations that work on any simulation (stop, isRunning, getAlpha). Calling getNodes will require an unsafe cast.

#listSimulations Source

listSimulations :: Effect (Array String)

List all registered simulation names.

#debugRegistry Source

debugRegistry :: Effect Unit

Print debug information about all registered simulations.

Designed for use from browser console:

// In browser console:
import('./output/Hylograph.ForceEngine.Registry/index.js').then(m => m.debugRegistry())

#clearRegistry Source

clearRegistry :: Effect Unit

Clear the registry without stopping simulations.

Use stopAll first if you want to stop them.

Re-exports from Hylograph.ForceEngine.Simulation

#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

#tick Source

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

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

#stop Source

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

Stop the simulation

#start Source

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

Start the simulation animation loop

#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

#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

#removeForce Source

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

Remove a force from the simulation

#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

#isRunning Source

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

Check if the simulation is running

#getNodes Source

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

Get the current nodes (with updated positions)

#getCallbacks Source

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

Get the current callbacks (if any)

#getAlpha Source

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

Get the current alpha value

#defaultConfig Source

defaultConfig :: SimConfig

Default simulation configuration

#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

#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.

#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

#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

#addForce Source

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

Add a force to the simulation

Re-exports from Hylograph.ForceEngine.Types

#SimulationState Source

type SimulationState :: Row Type -> Row Type -> Typetype SimulationState nodeRow linkData = { alpha :: Number, alphaDecay :: Number, alphaMin :: Number, alphaTarget :: Number, links :: Array (RawLink linkData), nodes :: Array (SimNode nodeRow), running :: Boolean, velocityDecay :: Number }

The complete state of a force simulation. We manage this ourselves instead of letting D3 do it.

#SimNode Source

type SimNode :: Row Type -> Typetype SimNode extra = { index :: Int, vx :: Number, vy :: Number, x :: Number, y :: Number | extra }

A simulation node with position and velocity. This is the minimal structure that D3 force functions expect.

The extra row allows extending with application-specific data. Note: vx, vy, index are added by the FFI initialization function.

#RadialConfig Source

type RadialConfig = { radius :: Number, strength :: Number, x :: Number, y :: Number }

Configuration for radial force

#ManyBodyConfig Source

type ManyBodyConfig = { distanceMax :: Number, distanceMin :: Number, strength :: Number, theta :: Number }

Configuration for a many-body (charge) force

#LinkConfig Source

type LinkConfig = { distance :: Number, iterations :: Int, strength :: Number }

Configuration for a link force

#ForceYConfig Source

type ForceYConfig = { strength :: Number, y :: Number }

Configuration for Y positioning force

#ForceXConfig Source

type ForceXConfig = { strength :: Number, x :: Number }

Configuration for X positioning force

#CollideConfig Source

type CollideConfig = { iterations :: Int, radius :: Number, strength :: Number }

Configuration for a collision force

#CenterConfig Source

type CenterConfig = { strength :: Number, x :: Number, y :: Number }

Configuration for centering force

#forceName Source

forceName :: ForceSpec -> String

Get the name of a force

#defaultSimParams Source

defaultSimParams :: { alpha :: Number, alphaDecay :: Number, alphaMin :: Number, alphaTarget :: Number, velocityDecay :: Number }

Default simulation parameters