The single source for cross-panel behaviour. Every panel obeys it. Four contracts (§1–4: bitmap-truth, mask-only, effective-type, Lock), then the reference material they rest on: the filter slots, the selection caps, the upstream-write policy, and the anti-patterns. A compliance checklist closes the page.
Per-panel specifics — what each panel reads and writes — live in the code:
frontend/src/panels/ and the registry index.js.
Common state lives in two Pinia stores, filters and selection, exposed as three
public bitmaps that every panel consumes via
usePanelContext:
| Bitmap | Source store | Built in | Predicate |
|---|---|---|---|
activeNodeMask |
filters |
useFilteredModel |
isActive(id) |
activeEdgeMask |
filters |
useFilteredModel |
isEdgeActive(edgeId) |
selectedMask / selectedEdgeMask |
selection |
usePanelContext |
isSelected(id) / isEdgeSelected(edgeId) |
The contract is "consume the mask", not "be internally a bitmap": a panel complies
if these masks drive its narrowing, however it stores its own data. No panel reads
filters.* raw to skip a mask; the only allowed raw read is showing the state of a
widget the panel itself edits (e.g. ConnectedComponents reads wccFilter to
highlight its Top-N button). Panel-private controls (log scale, top-N, bin size)
touch no bitmap.
Cross-panel influence is implicit: A influences B only if A writes
filters/selection and B consumes the resulting bitmap — which every panel does.
Today four panels write filters.*: connectivity (wccFilter), degree (degree,
via plot brush), timeline_node / timeline_edge (temporalFilter), plus
AttributeFilters (the sidebar editor). Everything else writes selection.
Derived quantities must stay anchored to the full graph; filters only dim the marks. Two exceptions, by explicit design — both count views, where the point is to see counts shift under a filter:
type_mixing— recomputes matrix counts underactiveEdgeMaskclient-side (Newman r stays full-graph);edge_flow— recomputes flow tuples(src_type, edge_type, dst_type, count)underactiveEdgeMask.
timeline_node / timeline_edge are not exceptions: each bin ships its canonical
SoA indices and the panel counts idx ∧ activeMask per bin, so the timelines react to
every filter like any other panel.
Any new proposal that breaks mask-only MUST be raised and discussed as an issue before any code is written.
When schema.auto_promoted.{node|edge} is non-null, the effective type is the
discriminator attribute, not the raw Node Type / Edge Type. Panels that group,
color, or chip-toggle by type MUST go through
useEffectiveType. Filter writes
against the type chip group propagate to the effective set via useFilteredModel.
Per-panel full freeze. The snapshot holds the filters JSON, both selection arrays, and
clones of both active masks. While frozen, usePanelContext reads all derived state
from the snapshot until Unlock. Lock is the only per-panel freeze mechanism; it
replaced an earlier, weaker Pin mechanism.
Slots live in stores/filters.js; mask construction lives in one place,
useFilteredModel (node mask first, then edge mask ANDed with it). The only
first-class editor is AttributeFilters.vue (mounted twice, mode='node'|'edge', in
GuideSidebar): type chip group + structural section + per-type attribute accordions.
Top-level slots:
nodeTypes/edgeTypes: string[]— allow-lists, effective-type aware. EmptynodeTypes= no nodes pass.degree: {mode:'absolute', value:[lo,hi]}— node-side range; the%toggle is UI-only. Edited from the sidebar slider and from a brush on theDegreeDistributionplot.weight: {...}— edge-side range, weighted graphs only. NaN weights pass (intentional: edges that lack a weight are not dropped).hideIsolated— drop degree-0 nodes.hideSelfLoops— dropsource === targetedges; shown whenschema.self_loops > 0.wccFilter: number[] | null— component-id allow-list (0-based, size-desc, matching/components/); written byConnectedComponents, no sidebar UI.temporalFilter: {attr, scope, range} | null— single brushed window fromActivityTimeline; applies only whenscopematches the side being masked.
Per-type attribute slots: nodeAttrs and edgeAttrs, shaped
{[type]: {[attr]: AttrFilterSpec}}. Each type is an AND-chain: an item passes iff
its type has no entry or matches ALL of them. Spec kinds: categorical {values},
numeric/date {range}, boolean {value}, and text {query, mode} for
high-cardinality identifiers, scanned client-side via attrIndex.bitsetFor — this is
what makes identifier-only types filterable.
No panel reads a filter slot back to recompute — they all see the trimmed mask.
Filter history (filterHistory.js): 20-entry debounced ring buffer, filters
only. Restore intersects the snapshot with the current schema's types. Undo/redo in
GraphHeaderStrip.
Slots that don't exist on purpose: degreeDirection (in/out/total), per-type
degree filter, selectionAsFilter (deferred — see the upstream-write policy below).
Aggregate broadcasts MUST cap on write via selection.replaceCapped(ids, cap): it
dedups, caps, and records overflow for the "+N more not selected" caption.
| Panel | Cap | Source constant |
|---|---|---|
type_mixing |
100 | SELECTION_CAPS.type_mixing |
edge_flow |
200 | SELECTION_CAPS.edge_flow |
connectivity |
none | cap (500) defined but not applied by design: a component click broadcasts all its active node ids, so the selection matches the filtered view exactly. |
ego_compare (pies) |
4 | MAX_LAYERS (read-time slice) |
Any new aggregate-broadcast proposal must state its cap.
The four filters.* writers listed in §1 are the only ones today. A new upstream
write is a design decision: open an issue first. Decided guidelines:
- Shift-click "lift to filter" only where the gesture is easy to discover. Kept on
degreebins (with a tooltip); dropped fortype_mixingcells andedge_flowarcs. - Explicit "Filter to this" buttons are the preferred pattern everywhere else.
- Two-way attribute writes (a panel writing a filter it also renders under) carry
ping-pong risk: they need a guard and an issue flagged
[?]. - Selection-as-filter is deferred: Lock covers the freeze need. If a real request
comes in, the path is a global lens toggle that ANDs
selectedMaskinto every panel — not a newfilters.*slot.
The concrete open proposals are tracked as issues.
- Two-way binding without a guard — any bidirectional sync needs an
isLocalUpdate-style flag (egois the reference). - Bypassing
selection.replace/add/toggle— direct mutations break the store's invariants (string IDs, dedup). - Bypassing Pinia on
filters.*— every mutation must go through the store sofilterHistoryundo/redo sees it. - Capping via
replace+ manual.slice()— usereplaceCappedsooverflowis tracked.
- Narrowing is driven by the masks from
usePanelContext; no rawfilters.*read, except to show the state of a widget the panel itself edits. - Metrics stay anchored to the full graph (mask-only); a new exception needs an issue first.
- Type grouping/colouring goes through
useEffectiveTypeand the shared colour composables. - Selection writes use
replace/add/toggle; aggregate broadcasts usereplaceCappedwith a stated cap. - New writes to
filters.*follow the upstream-write policy (issue first). - The panel behaves correctly under Lock (reads resolve to the snapshot).