Skip to content

Visualisation internals

How Visualise mode works under the hood: the rendering seam, the weight function, the layout forces, Bridges, saved views and PNG export. For what users see, read How visualisation works.

The data flows in this order, all in components/map-visualiser/map-visualiser.tsx:

  1. rawGraph: every node and connection, read from the shared collections with useLiveQuery.
  2. graph: scopeGraph(rawGraph, …) (graph/modes.ts), the nodes and connections the map is drawn from (see Node scope).
  3. vizGraph: toVizModeGraph(graph, weightConfig). Defined connections only, each with a weight and a strength.
  4. tagClusters: clusterByTags(vizGraph.nodes, tags, method), in Capabilities only (see Capabilities clusters).
  5. forceData: toForceGraphData(vizGraph, …), the nodes and links the simulation runs on.
  6. visibleLinkIds: visibleEdgeIds(vizGraph.edges, visibilityFilter), which links are drawn. visibilityFilter holds the type, value-range and time checks and nodeMatch, the nodes the filter’s node conditions pick out.
  7. hiddenConnectionGroups: hiddenConnections(rawGraph.edges, vizGraph.edges, …), every connection that isn’t drawn and why, for the details panel.

viz-canvas.tsx is the only file that imports react-force-graph-2d. The library touches window and canvas at import time, so the canvas loads it with lazy() and renders it only after a mounted flag is set. Nothing else may import it on a path that could run on the server.

lib/force-adapter.ts is the pure VizGraphData → { nodes, links } adapter:

  • It keeps every edge, including zero-weight ones. Hiding them is the job of visibleEdgeIds, so a weighting or filter change doesn’t have to reshape the link list.
  • It seeds x/y from the Edit-canvas position, or from the previous layout (see Layout continuity). It never sets fx/fy.
  • In Capabilities it adds synthetic meta-nodes (metaNodeId, prefix __meta__:): one per cluster for Exact and Similarity, or one hub per chosen tag for Overlap (hubNodeId, isHub). Meta-nodes are never stored or selectable. Callers skip them with isMetaNodeId. Each real node gets its cluster’s clusterColor, and either the clusterId it gathers into or the hubIds it sits between.
  • In Bridges it tags each node with communityId (not for a community of one) and each link that joins two communities with crossesCommunity.

lib/viz-modes.ts (VIZ_MODES) is the one place that says what each mode owns. Components ask it through lensOwnsSize, lensOwnsColour, usesBetweenness, usesClusterField and usesCommunities instead of comparing viz_type.

UI label viz_type Owns
Landscape topology nothing; size by measure and colour by measure, tag group or tag are available
Bridges betweenness_centrality node size (betweenness); colours by community by default
Capabilities metric_clustering node colour; clusters by chosen tags

All three run the same d3 simulation. Switching mode reconfigures it rather than tearing it down.

clusterByTags (lib/tag-clusters.ts) takes the chosen tags (clusterTagsOf, which drops ids that aren’t tags of the map) and cluster_method, and returns a TagClustering: the clusters largest first with “None of these” last, each node’s cluster index, and each node’s chosen tags. A tag counts when the node has a value for it (hasTag).

cluster_method Clusters Layout
exact (default) one per distinct set of chosen tags members pull toward their cluster’s meta-node
overlap as exact, for colour and the legend members pull toward the mean of their tags’ hubs; each hub pulls toward the mean of its members
similarity groups of tag sets, by community detection as exact

Similarity runs detectCommunities over the distinct tag sets, not the nodes. Two sets are joined by their Jaccard similarity times both sets’ sizes, and each set holds m(m − 1) as a self-weight (CommunityOptions.selfWeights): every pair of its m identical members, counted both ways. That is the graph Louvain would reach after merging identical nodes, at the cost of the distinct sets rather than every node pair. A cluster is named after the tags at least half its members have (else the commonest), prefixed “Mostly” unless every member has exactly those.

Colours come from tagClusterColor: palette by size rank, OTHER_COMMUNITY_COLOR past the palette, and NONE_CLUSTER_COLOR for “None of these”. buildTagClusterLegendEntries builds the legend from the same result. Overlap’s hubs are painted as their tag’s name only (paintHubLabel, lib/paint-node.ts). A fresh layout starts them evenly round a ring (hubPosition, hubRingRadius).

The Clustering choice appears only with two or more tags chosen. With one tag, Exact and Similarity give the same clusters.

graph/weight.ts turns a node pair’s defined connections into one number. It is code, never user input. WeightMode (the UI’s Draw the map from) has four variants:

Kind Which connections count Pair weight
presence (default) all, except types in exclude 1
count all, except types in exclude connection count ÷ the busiest pair’s
type { fieldId, byValue } only that type 1, or with byValue the pair’s summed positive values ÷ the largest pair total
score { scores, byValue? } types with a score above 0 1 − ∏(1 − c) over the pair’s types
  • countsTowardWeight(connection, mode) decides whether a connection counts. One that doesn’t is hidden and doesn’t pull. An untyped connection (a note) always counts under presence and count.
  • typeIsDrawn is the type-level version. The visibility filter uses it to ignore types the map isn’t drawn from.
  • excludeUnvalued on type and score drops connections with no usable amount (hasValue: missing, blank or unparseable; 0 is an amount) while weighing by value. Otherwise an unknown amount counts at the floor.
  • In score, each type contributes its score c (clamped to 1) once per pair. A type in byValue multiplies c by the pair’s total for that type over the largest pair total. With every type at 1, score gives the same weights as presence. With one type at 1, it matches type. A missing byValue scales every numeric type, which keeps older saved views drawing the same.
  • Any pair where something counts is floored at MIN_COUNTED_WEIGHT (0.05). A small grant beside a huge one still draws and pulls. Negative and unparseable values count as 0 before the floor.

computeGraphWeights works over the whole graph, because count, by-value type and by-value score are relative to the strongest pair. toPathCost inverts a weight into a shortest-path cost for betweenness.

toVizModeGraph (graph/modes.ts) drops undefined connections, groups the rest by pair, and gives each counted connection its pair’s weight and strength. An uncounted one gets 0. A pair joined by Funded and Partnered, drawn from Funded, draws only the Funded line.

visibleEdgeIds returns the edges to draw: weight above 0, and passing the type filter, value ranges and time filter. It runs after toVizModeGraph, so filtering changes only this set and never the layout.

Line thickness never reads weight. computeGraphStrengths gives each pair its strength in its own units (a count, a total value, a combined score, or 1), with no scaling or floor. The floor would hide the difference between small amounts on a log scale.

linkWidth (lib/paint-node.ts):

  • With Show connection strength as thickness off (link_width_by_weight), width is LINK_BASE_WIDTH × link_width_scale.
  • With it on, strengthPosition places the strength between the map’s weakest and strongest counted strengths (strengthExtent), linearly or on a log scale (link_width_log). Width runs from the base width up to link_width_range times it. The extent covers every counted link, not only visible ones, so filtering never changes the widths.

lib/use-force-config.ts configures the simulation. Its numbers are the lens’s ForceParameters (lib/force-parameters.ts): DEFAULT_FORCE_PARAMETERS, overridden by the view’s config.forces[viz_type]. The defaults:

Force Behaviour
link strength 0 for a zero-weight link. Otherwise d3’s default 1 / min(degree) (degree counts only pulling links) scaled between min_link_pull (0.1) and 1 by weight. Weight 1 matches d3’s default.
link distance link_distance / (1 + weight), 220 by default
charge charge, −140
gravity a weak pull (gravity, 0.03) toward the origin, so nodes with no counted link aren’t pushed off screen
cluster Capabilities only: pulls each node toward its cluster’s meta-node, or toward the mean of its Overlap hubs; pulls each hub toward the mean of its members (cluster_strength, 0.3)
community pulls each node toward its community’s centroid (community_strength, 0.15); does nothing without communityId

A link with crossesCommunity pulls at cross_community_pull (0.15×) strength over cross_community_length (2.5×) the distance. LENS_FORCE_PARAMETERS lists which parameters each lens reads, and the schema accepts only those per lens, within FORCE_PARAMETER_BOUNDS.

The effect re-runs when the clustering flag changes, when instanceNonce bumps, or when the lens’s overrides change (forceSettingsKey). The visualiser keys forceData on the same key, so a change hands force-graph a new dataset in the same commit and the layout carries on to its new shape. It is a layout effect, so the forces are set before force-graph takes the render’s new dataset (a moment later, on a 1 ms timer): a new layout is worked out in full at that point (see Gliding to a new layout). The cluster force reads clusterId and hubIds off the data, so changing the tags or method only needs a new dataset. The canvas bumps the nonce when the lazy ForceGraph2D instance mounts. Without it, a saved view restored before the chunk loaded would never get its forces configured.

The simulation runs for a fixed 300 ticks (cooldownTicks, with cooldownTime: Infinity). The same seed positions settle into the same layout on every load. A layout that glides in runs the same 300 ticks as warmupTicks instead, so it settles into the same layout too.

Communities (graph/communities.ts) come from a weighted, deterministic Louvain written in-house. Nodes and edges are sorted first and ties keep the current community, so a map always yields the same communities. The visualiser runs it over every counted edge (weight > 0), not the visible ones. Communities shape the layout, and the layout never follows the visibility filter.

Communities are ordered largest first. That index is the colour slot (communityColor). Communities past the palette, and communities of one, share OTHER_COMMUNITY_COLOR. colourByCommunity(config) is on in Bridges unless a Colour nodes by measure, tag group or tag is chosen or community_colouring is false.

topBridgeIds (lib/appearance.ts) labels one node per coloured community at any zoom: its highest-betweenness member, or its hub if nothing in it bridges.

Betweenness (graph/betweenness.ts) is weighted Brandes with Dijkstra, using toPathCost(weight) as edge cost and normalised to 0..1. Consumers depend on the BetweennessStrategy type, not on weightedBetweenness. It runs on the main thread inside a setTimeout(0), so the “Computing centrality…” indicator paints first. Unlike communities, it runs over visible edges only, so Bridges’ node sizes follow the visibility filter.

Node size in Bridges comes from bridgeSizes (lib/appearance.ts), not the raw score. Each node gets the square root of its share of the map’s highest betweenness, so the strongest bridge is 1 and a node’s area tracks how much it bridges. nodeRadius adds that times MAX_RADIUS_BONUS to BASE_RADIUS. Normalised betweenness is small on a sparse map, so an absolute scale would draw every node the same size. The result is cached per betweenness map. The details panel’s Analysis section shows the raw score.

fadedNodeIds (lib/faded-nodes.ts) is worked out from forceData, visibleLinkIds and nodeMatch, and passed to the canvas. It is null while every link is visible and there’s no node filter. Otherwise it holds every real node the node filter doesn’t pick out, and every node that has links but none visible. A node with no links never fades for the second reason. The painter greys these nodes, and linkColor draws a link with a greyed end at half alpha. Find a node marks the same nodes “greyed out”. The click-highlight adjacency is built from visible links only. Both reach the painter through refs, so filtering repaints without re-creating it.

While a node is selected (selectedIdRef), the painters in lib/paint-node.ts:

  • grow it and its neighbours to at least SELECTED_MIN_RADIUS, twice BASE_RADIUS, through selectionRadius. Larger nodes keep their size. The pointer-area painter uses the same radius, so a grown node is as easy to click as it looks.
  • fill it with SELECTED_NODE_COLOR (slate-800) and give it a dark ring and a bold label at every zoom. The directional highlight keeps its own colour for the selected node, which its legend keys.
  • dim every other node.
  • draw connections that don’t touch it at LINK_ALPHA_DIMMED, decided by linkDimmed.

A node’s fill comes from nodeFill: grey when faded, the directional highlight’s colours while one shows (directionalHighlightFor, lib/directional-highlight.ts), else its colour encoding. The node panel’s “group by node” bullets use the same two functions, so they match the canvas.

force-graph’s hover tooltip is hidden for a node whose name the canvas already paints (labelPainted: the selected node, a top bridge, or any node past LABEL_MIN_ZOOM). VizCanvas rechecks on hover, zoom and selection change and sets data-node-label-painted on its container, which CSS uses to hide the tooltip (by visibility: the tooltip sets display inline). force-graph only sets tooltip text when the hovered node changes, so this is how a node selected while hovered loses its tooltip.

A selection change fades in steps rather than at once (lib/selection-transition.ts). Each element has a ring around a selected node: 0 the node, 1 its connections, 2 its neighbours, 3 the rest. Selecting goes inside out, one TRANSITION_STEP_MS (50 ms) per ring; deselecting goes outside in. Clicking from one node to another does both together: the old neighbourhood folds in as the new one opens out. Each element fades over TRANSITION_FADE_MS (120 ms), eased out, so the longest transition is TRANSITION_TOTAL_MS (270 ms).

VizCanvas records the transition (transitionRef) in the same render that moves selectedIdRef, so no frame paints the new selection without it. The painters read it per frame and draw each element’s opacity, radius, selected fill, ring and label part way between the old selection’s look and the new one’s (fadeProgress). While it runs, autoPauseRedraw is off, since force-graph otherwise stops drawing once the layout settles. With prefers-reduced-motion, selection changes land at once.

The details panel lists a node’s connections from rawGraph, so it can name ones the canvas doesn’t draw. hiddenConnections (graph/modes.ts) gives each of those a HiddenReason:

  • not-drawn-from: weight 0 under the weighting (another type, a type left out or scored 0, no amount while those are left out), or an undefined connection.
  • filtered-out: the Visibility filter’s type, value-range or time check, or both ends greyed out by its node conditions.
  • node-not-shown: an end was dropped from graph as unplaced.
  • node-left-out: an end was left out by node_scope, or for having no connection left. scopeGraph’s excluded map says which; unplaced wins when the ends differ.
  • connection-left-out: a connection_exclusions rule left it out (scopeGraph’s excludedEdges).

A viz edge keeps its connection’s id, and visibleEdgeIds and hiddenConnections share one per-edge rule (vizEdgeHiddenReason), so the panel and the canvas can’t disagree. The visualiser turns the result into labelled groups for NodeInspector’s hiddenConnections prop. Edit mode doesn’t pass it, so its list never splits.

The toolbar’s search (node-search.tsx) wraps EntryCombobox. matchNodes (lib/node-search.ts) matches labels case-insensitively, prefix matches first, at most 20. It searches rawGraph, and the visualiser tags each result faded, not-shown or left-out from fadedNodeIds, graph and excluded.

Picking a node calls handleSelectNode, the same path as a canvas click. A node that isn’t in graph goes through the reveal path instead (revealedNodeId, as for Table mode’s requests). Only then, because revealing rebuilds graph and restarts the simulation. Then VizCanvasHandle.focusNode fits the node and its neighbours over visible links (the click-highlight adjacency), from their current simulated positions: focusFit (lib/focus-fit.ts) centres on their bounding box and zooms to fill the canvas less 80 px of padding, capped at 4×. A node with no visible neighbour is centred at 2×. A node without a position yet (just revealed) is focused on the next onEngineTick, or when a glide lands. Focusing also cancels the pending first-load zoomToFit and any saved-view camera restore, so onEngineStop doesn’t move the camera off the node.

A settings change continues the layout from where it is instead of restarting it.

  • vizGraph depends only on graph and config.weight_config. If the new graph has the same nodes, edge ids, weights and strengths (sameVizGraph), the committed one is kept and nothing restarts.
  • forceData is keyed on vizGraph, tagClusters (only in Capabilities), the communities (only in Bridges) and freshLayout. tagClusters is keyed on the chosen tag ids joined into one string and the method, so other settings never recluster. Filters, encodings and appearance settings aren’t in the key, so they never move a node.
  • When forceData changes, nodes start from the previous dataset’s settled positions (positionsOf). The previous dataset is read from a ref set in an effect after commit, not inside the memo, because StrictMode runs memos twice. A node new to the dataset starts beside the centroid of its neighbours already placed, and a new cluster meta-node at its members’ centroid.
  • The layout restarts from Edit-canvas positions only when freshLayout’s epoch changes (loading a saved view, or Re-run layout) or when rawGraph changes. freshLayout.savedView marks a saved view’s epoch, whose camera is the view’s.
  • Loading a view with a step while another view with a step is active doesn’t change the epoch, so the layout carries on and nodes migrate between steps. applyView clears pendingViewport and bumps refit instead (see Progressive reveal).
  • layoutId counts the layouts the camera frames afresh: each new epoch, and a carried-on layout with a new refit. The canvas re-fits the camera once per layoutId, not on every dataset change.
  • Once a layout is on screen, every new dataset glides in (see Gliding to a new layout).

Once a layout is on screen, a new one never plays out as the simulation runs. A saved view, a setting that moves nodes, Re-run layout and a change to the map’s data all glide from the picture on screen to the new layout (lib/layout-glide.ts, run by VizCanvas):

  1. In the render that hands force-graph a new dataset, VizCanvas records a Glide: the camera, where it goes, and the outgoing picture. The picture keeps the outgoing nodes, a node painter rebuilt from what the previous render drew with (its inputs frozen, PaintInputs), each node’s look (nodeLook) and each drawn line’s width and greyed state. That render passes warmupTicks: 300 and cooldownTicks: 0, so force-graph runs all 300 ticks synchronously as it takes the data, and nothing ticks after.
  2. Until the glide starts, every frame draws the outgoing picture exactly as it was (paintPicture). The new nodes, lines and hit areas are held back. force-graph repaints whenever a styling prop changes, which is every render, so without this the frame after a settings change showed the new colours with no lines.
  3. The glide starts on the first frame (onRenderFramePre) once d3 has taken the data (it numbers the nodes as it does) and, in Bridges, once betweenness has been worked out for the new picture (awaitingBetweenness), so sizes don’t jump part way. Each node’s settled position is its end. Its start is where it was on screen, or for a node arriving the middle of its neighbours on screen, or a meta-node’s members (glideStarts).
  4. The camera ends where cameraTo says: the saved viewport for a saved view, a fit of the new layout for another fresh layout, or where it is for a layout carrying on. A layout carrying on before the map was first framed gets that framing instead.
  5. Each frame over GLIDE_DURATION_MS (900 ms), eased in and out, it moves the camera (centre in a straight line, zoom by ratio, cameraAt) and sets every node’s x/y. Each node goes in a straight line across the screen: its graph position is worked back from its screen offset and the camera that frame (graphPoint). The node painter blends size, colour, greying and top-bridge labels from the recorded look (NodeGlide). A line both pictures draw blends its width and opacity. Arriving nodes and lines fade in. Departing ones, and lines no longer drawn, fade out where they were, drawn by the outgoing painter.
  6. It lands by setting the end positions and camera exactly: the same picture as the layout settling on its own, so a saved view frames as it did when it was saved.

While it runs, autoPauseRedraw is off, and node drag, zoom and pan are disabled. onEngineStop and the pendingViewport effect leave the camera alone. A new dataset before d3 has taken the previous one settles as the simulation runs instead. With prefers-reduced-motion, a glide lands on its first frame.

getGraph returns every node, placed or not. scopeGraph builds graph from it, client-side, with no refetch, in this order:

  1. It drops unplaced nodes while config.include_unplaced_nodes (default true) is off, then nodes that fail config.node_scope, each with every edge touching it.
  2. It drops edges a config.connection_exclusions rule matches (graph/connection-rules.ts), into excludedEdges.
  3. With config.leave_out_unconnected, it drops nodes left with no defined edge, as unconnected.

revealedNodeId (a found node, or one Table mode asked for) is exempt from the node rules. Option lists and the details panel read rawGraph, so no rule changes what they offer. The memo keys on nodeCriteriaKey and connectionRulesKey.

node_scope and filter.nodes are NodeCriteria, matched by graph/node-criteria.ts. effectiveNodeCriteria first drops ids that aren’t a node tag or measure of the map any more, empty conditions and ranges that narrow nothing, so a stale condition never blanks the map. The memos key on nodeCriteriaKey. A graph with the same members as the committed one is kept (sameGraphMembers), so a range edit that leaves out nothing new doesn’t relay the map.

The node conditions UI is one component, conditions/node-conditions.tsx, used by both Visualisation → Nodes and the filter with different wording. Its cards group tags by Field.category (lib/node-facets.ts, shared with Capabilities’ Cluster by). A tag card with nothing picked is local UI state and isn’t saved. Number and date boxes commit on blur or Enter (conditions/fields.tsx).

When nothing is left to draw, EmptyState says why (no-nodes, all-unplaced, all-left-out). The panel stays enabled for the last two, with Nodes and Settings live. FilterStatusPill, top left of the canvas, shows the filter’s counts and how many nodes are left out, and opens the panel through VizSidebar’s openRequest.

A saved view stores visualisation_config and viewport_state as jsonb on app.saved_view, and its step. It never stores graph data or node positions. server/lib/saved-views.ts holds every read and write, for the server functions and the public API alike. It lists steps first, by step, then the rest, newest first, and stamps updated_at on every write. RLS (app.user_can_access_map) and assertMapAccess make every view on a map visible to, and editable by, every member who can access the map.

  • Naming. nextDefaultViewName (lib/saved-view-naming.ts) picks default, then view 2, view 3, and so on, skipping names already taken. Saving never prompts.
  • Save calls updateSavedView on the active view, or createSavedView if none is active. Its tooltip and accessible name are Save view until a view exists, then Update "<name>".
  • Save as a new view (the plus) always calls createSavedView. It appears once a view exists.
  • One view. The toolbar shows the picker, rename and delete only with more than one view. An effect calls applyView whenever there is exactly one view and it isn’t active, which also covers deleting down to one.
  • Last viewed. lib/saved-view-storage.ts keeps the last active view id per map in localStorage. On open, the visualiser applies it once, after savedViews first resolves.

applyView merges the stored config over DEFAULT_VISUALISATION_CONFIG, runs migrateLegacyFilterMode and migrateLegacyClusterField, sets pendingViewport, and starts a fresh layout marked as the view’s (freshLayout). Stepping between two steps of a reveal, or re-applying a view changed elsewhere, carries the layout on instead (see Progressive reveal).

Which overlays are open (the About this map panel and both legends) is saved in the config’s overlays. The visualiser holds it in its own overlaysOpen state, not in config, so opening or closing one doesn’t re-run anything that reads the display settings. Save writes { ...config, overlays: overlaysOpen }, and applyView splits overlays back out, filling gaps from DEFAULT_OVERLAYS_OPEN. Edit mode’s About panel keeps its own state.

A reveal builds a map up over numbered saved views (step), each drawn without some nodes and connections. A step’s rules are ordinary config keys: node_scope, connection_exclusions and leave_out_unconnected (see Node scope).

ConnectionRule is { types?, from?, to? }: connection type ids and node criteria for each end. A directed connection reads source to target. An undirected or mutual one matches either way round. effectiveConnectionRules drops stale ids. A rule that loses every type or every condition on an end it named is dropped whole, so a stale rule leaves out less, never more. A rule with no part left matches nothing.

weight_config.exclude is different: a type it leaves out stays in graph with weight 0, so it still counts for faded nodes and leave_out_unconnected. Use a rule to take connections out of the graph.

The toolbar’s picker lists views in step order without showing the number; the position is the step. Dragging a view there calls reorderSavedViews, which numbers every view 1, 2, 3… in the new order, writing only the views whose step changes. Stepping from one step to another carries the layout on (see Layout continuity): nodes already on screen move, new ones start beside their neighbours, and the glide frames the result. Where nodes end up depends on the steps taken, so a step loaded any other way lays out from scratch.

describeVisualisation says what the rules leave out (“It leaves out the 4 connections from nodes not tagged Client …”), since that’s the point of a step.

Shift + Alt + P in Visualise opens raw-view-parameters.tsx, every view parameter in a full-height sheet on the right (Dialog with side="right", over a light backdrop), so the map stays in view while it changes. The shortcut is matched on event.code, since Alt changes the character on a Mac, and ignored while typing or with a dialog open. Nothing in the UI links to it.

It’s generated from z.toJSONSchema(viewParametersSchema). The View tab has one row per key, with the control its meta.control names. The Forces tab edits forces: a slider per parameter each lens reads, the lens on screen first, with a reset where the view overrides the default. A reference row offers the map’s names (vocabularyOf, lib/view-references.ts). A json row is checked against that key’s schema on blur and shows the issues without changing anything when it fails. Edits go straight into config (or overlaysOpen), and Save keeps them. The view’s name and step are set here too, through renameSavedView and setSavedViewStep.

The visualiser passes the saved viewport to VizCanvas as pendingViewport, because fgRef is still empty while the lazy instance loads. VizCanvas applies it from a callback ref (setFgRef) when the instance mounts, or from an effect if the instance already exists. It then marks the first auto-zoomToFit as done and applies the camera again on the first onEngineStop, after the layout settles. A viewport with zoom 0 (a view made through the public API) means no camera: it’s skipped, and the map is fitted. When the view glides in, the glide eases the camera there instead (see Gliding to a new layout).

setFgRef and applyPendingViewport must be memoised with useCallback. A new callback-ref identity each render makes React detach and reattach it. ForceGraph2D treats that as a remount and resets, undoing the restore.

VizCanvas implements export (exportPng, on the VizCanvasHandle it exposes through useImperativeHandle). The visualiser only triggers the download and shows any toast.

  1. computeExportScale (lib/png-export.ts) aims for 300 ÷ 96 ≈ 3.125× the on-screen size. It caps each side at 8,192 px and the total at 30 million pixels, and never goes below 1×. clamped reports a cap.
  2. A second, hidden ForceGraph2D mounts at that size and zoom, with the same painters and pinGraphForExport positions, so it draws the same picture.
  3. html-to-image rasterises the overlays (legends and the About this map panel) at the same scale, and the two bitmaps are composited on the page background. The container carries data-exporting meanwhile, so the overlays are laid out open and in full (see Workspace UI).
  4. withPngDpi writes a pHYs chunk for 300 DPI straight after IHDR, replacing any existing one. Without it the file reports 72 DPI.

pngExportFilename builds the name from the map name, the active view’s name and the date.

VisualisationConfig (types/visualisation.ts) is shared by client and server. The database doesn’t enforce its shape. Fields added after the first release are optional, and code reads them with a default:

Field Default when absent
weight_config { kind: 'presence' } (weightConfigFor, lib/viz-config.ts)
link_width_scale 1
link_width_by_weight false
link_width_range 4 (DEFAULT_LINK_WIDTH_RANGE)
link_width_log false (linear)
community_colouring on
colour_nodes_by_group none. A tag group colours nodes by their tag’s colour (groupColoursFor, lib/appearance.ts), after clusters and communities. Kept apart from colour_nodes_by, which other versions read as a field id. A group that no longer exists falls back to default colours.
colour_nodes_by none. A measure colours along the indigo scale (sequentialColor). A node tag goes through groupColoursFor with that one tag: its nodes in the tag’s colour, everything else (false or absent) UNTAGGED_COLOUR, keyed by buildTagLegendEntries. Any other value colours categorically.
cluster_tag_ids no tags chosen
cluster_method exact (clusterMethodOf)
filter, filter.valueRanges, filter.time, filter.nodes no filter
node_scope every node
connection_exclusions no connection left out
leave_out_unconnected false
forces and each lens and parameter in it DEFAULT_FORCE_PARAMETERS
overlays and each of its keys About collapsed, legends open (DEFAULT_OVERLAYS_OPEN)

lib/view-parameters.ts describes VisualisationConfig as one zod schema. The public API’s view routes and the raw parameters panel are both built from it. Every key has .describe() text and .meta(): control (select, switch, slider, reference or json) and, for a key that names a catalogue entry, ref (tag, tag_group, measure or connection_type). Ids nested deeper carry their own ref. z.toJSONSchema keeps all of it.

  • storedViewParametersSchema reads stored configs. Every key is optional, unknown keys survive, and the legacy Filter weight mode is accepted.
  • viewParametersSchema checks input and refuses unknown keys at every level.
  • buildViewParametersSchema({ resolve }) is the strict form with every reference passed through resolve while parsing, record keys included.

ViewParametersParity fails pnpm typecheck when the schema and VisualisationConfig drift apart. A new config key goes in both.

migrateLegacyFilterMode (lib/viz-config.ts) converts a stored weight_config: { kind: 'filter', fieldId } into presence plus filter: { fieldIds: [fieldId] }.

migrateLegacyClusterField (lib/viz-config.ts) converts a stored cluster_metric_id into cluster_tag_ids: [id] for a tag, [] for a measure or a field the map no longer has. It drops cluster_metric_id, so saving the view again doesn’t write it.