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:
rawGraph: every node and connection, read from the shared collections withuseLiveQuery.graph:scopeGraph(rawGraph, …)(graph/modes.ts), the nodes and connections the map is drawn from (see Node scope).vizGraph:toVizModeGraph(graph, weightConfig). Defined connections only, each with aweightand astrength.tagClusters:clusterByTags(vizGraph.nodes, tags, method), in Capabilities only (see Capabilities clusters).forceData:toForceGraphData(vizGraph, …), the nodes and links the simulation runs on.visibleLinkIds:visibleEdgeIds(vizGraph.edges, visibilityFilter), which links are drawn.visibilityFilterholds the type, value-range and time checks andnodeMatch, the nodes the filter’s node conditions pick out.hiddenConnectionGroups:hiddenConnections(rawGraph.edges, vizGraph.edges, …), every connection that isn’t drawn and why, for the details panel.
The rendering seam
Section titled “The rendering seam”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/yfrom the Edit-canvas position, or from the previous layout (see Layout continuity). It never setsfx/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 withisMetaNodeId. Each real node gets its cluster’sclusterColor, and either theclusterIdit gathers into or thehubIdsit sits between. - In Bridges it tags each node with
communityId(not for a community of one) and each link that joins two communities withcrossesCommunity.
Visualisation modes
Section titled “Visualisation modes”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.
Capabilities clusters
Section titled “Capabilities clusters”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.
The weight function
Section titled “The weight function”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 underpresenceandcount.typeIsDrawnis the type-level version. The visibility filter uses it to ignore types the map isn’t drawn from.excludeUnvaluedontypeandscoredrops connections with no usable amount (hasValue: missing, blank or unparseable;0is an amount) while weighing by value. Otherwise an unknown amount counts at the floor.- In
score, each type contributes its scorec(clamped to 1) once per pair. A type inbyValuemultipliescby the pair’s total for that type over the largest pair total. With every type at 1,scoregives the same weights aspresence. With one type at 1, it matchestype. A missingbyValuescales 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.
From weights to edges
Section titled “From weights to edges”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.
Strength and line thickness
Section titled “Strength and line thickness”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 isLINK_BASE_WIDTH × link_width_scale. - With it on,
strengthPositionplaces 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 tolink_width_rangetimes it. The extent covers every counted link, not only visible ones, so filtering never changes the widths.
Forces
Section titled “Forces”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.
Bridges
Section titled “Bridges”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.
Faded nodes
Section titled “Faded nodes”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.
Selection emphasis
Section titled “Selection emphasis”While a node is selected (selectedIdRef), the painters in lib/paint-node.ts:
- grow it and its neighbours to at least
SELECTED_MIN_RADIUS, twiceBASE_RADIUS, throughselectionRadius. 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 bylinkDimmed.
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.
Selection transitions
Section titled “Selection transitions”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.
Hidden connections
Section titled “Hidden connections”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 fromgraphas unplaced.node-left-out: an end was left out bynode_scope, or for having no connection left.scopeGraph’sexcludedmap says which; unplaced wins when the ends differ.connection-left-out: aconnection_exclusionsrule left it out (scopeGraph’sexcludedEdges).
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.
Finding a node
Section titled “Finding a node”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.
Layout continuity
Section titled “Layout continuity”A settings change continues the layout from where it is instead of restarting it.
vizGraphdepends only ongraphandconfig.weight_config. If the new graph has the same nodes, edge ids, weights and strengths (sameVizGraph), the committed one is kept and nothing restarts.forceDatais keyed onvizGraph,tagClusters(only in Capabilities), the communities (only in Bridges) andfreshLayout.tagClustersis 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
forceDatachanges, 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 whenrawGraphchanges.freshLayout.savedViewmarks a saved view’s epoch, whose camera is the view’s. - Loading a view with a
stepwhile another view with astepis active doesn’t change the epoch, so the layout carries on and nodes migrate between steps.applyViewclearspendingViewportand bumpsrefitinstead (see Progressive reveal). layoutIdcounts the layouts the camera frames afresh: each new epoch, and a carried-on layout with a newrefit. The canvas re-fits the camera once perlayoutId, not on every dataset change.- Once a layout is on screen, every new dataset glides in (see Gliding to a new layout).
Gliding to a new layout
Section titled “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):
- In the render that hands force-graph a new dataset,
VizCanvasrecords aGlide: 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 passeswarmupTicks: 300andcooldownTicks: 0, so force-graph runs all 300 ticks synchronously as it takes the data, and nothing ticks after. - 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. - 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). - The camera ends where
cameraTosays: 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. - 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’sx/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. - 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.
Node scope
Section titled “Node scope”getGraph returns every node, placed or not. scopeGraph builds graph from it,
client-side, with no refetch, in this order:
- It drops unplaced nodes while
config.include_unplaced_nodes(defaulttrue) is off, then nodes that failconfig.node_scope, each with every edge touching it. - It drops edges a
config.connection_exclusionsrule matches (graph/connection-rules.ts), intoexcludedEdges. - With
config.leave_out_unconnected, it drops nodes left with no defined edge, asunconnected.
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.
Saved views
Section titled “Saved views”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) picksdefault, thenview 2,view 3, and so on, skipping names already taken. Saving never prompts. - Save calls
updateSavedViewon the active view, orcreateSavedViewif none is active. Its tooltip and accessible name are Save view until a view exists, thenUpdate "<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
applyViewwhenever there is exactly one view and it isn’t active, which also covers deleting down to one. - Last viewed.
lib/saved-view-storage.tskeeps the last active view id per map inlocalStorage. On open, the visualiser applies it once, aftersavedViewsfirst 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.
Progressive reveal
Section titled “Progressive reveal”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.
Raw parameters panel
Section titled “Raw parameters panel”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.
Viewport restore
Section titled “Viewport restore”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.
PNG export
Section titled “PNG export”VizCanvas implements export (exportPng, on the VizCanvasHandle it exposes
through useImperativeHandle).
The visualiser only triggers the download and shows any toast.
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×.clampedreports a cap.- A second, hidden
ForceGraph2Dmounts at that size and zoom, with the same painters andpinGraphForExportpositions, so it draws the same picture. html-to-imagerasterises 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 carriesdata-exportingmeanwhile, so the overlays are laid out open and in full (see Workspace UI).withPngDpiwrites apHYschunk for 300 DPI straight afterIHDR, 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.
Config persistence and defaults
Section titled “Config persistence and defaults”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) |
View parameters schema
Section titled “View parameters schema”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.
storedViewParametersSchemareads stored configs. Every key is optional, unknown keys survive, and the legacy Filter weight mode is accepted.viewParametersSchemachecks input and refuses unknown keys at every level.buildViewParametersSchema({ resolve })is the strict form with every reference passed throughresolvewhile 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.