Skip to content

Commit 839e1dd

Browse files
committed
feat(graph): host-legend API + nodeLabelPriority encoding channel
Expose setActiveCategories/getActiveCategories on GraphInstance so custom host legends can drive the persistent multi-select category filter without spec recompilation. Add nodeLabelPriority encoding channel to map a data field to label visibility priority instead of the default degree-based heuristic. Claude-Session: https://claude.ai/code/session_016rS7hi2g4bSzRBfnD1p2ji
1 parent 013ecd2 commit 839e1dd

14 files changed

Lines changed: 295 additions & 7 deletions

File tree

docs/codebase-map.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,16 +118,16 @@
118118
| Arc angle range (half-donut / election donut) | `markDef.startAngle`/`endAngle` (radians, d3 convention) handled in `packages/engine/src/charts/pie/compute.ts` (`computeSweepBounds` fits a partial sweep). |
119119
| Graph spec + layout types (`GraphSpec`, `GraphEncoding`, `GraphAnimationConfig`, `GraphInteractionConfig`, `NodeOverride`) | `packages/core/src/types/spec.ts` (search "GraphSpec"). Per-node visual overrides = `NodeOverride` (fill/radius/stroke/`alwaysShowLabel`). |
120120
| Graph compile (nodes/edges, community detection, encoding, animation + interaction resolve, simulationConfig) | `packages/engine/src/graphs/compile-graph.ts``compileGraph()`. Community detection: `graphs/community.ts`. |
121-
| Graph encoding resolve (nodeColor/edgeColor/nodeSize; category domain via `sort`, default `'ascending'`) | `packages/engine/src/graphs/encoding.ts`. Graph channels default `sort: 'ascending'` (VL-aligned, deterministic); `sort: null` restores insertion order; explicit `scale.domain` wins. |
121+
| Graph encoding resolve (nodeColor/edgeColor/nodeSize/nodeLabelPriority; category domain via `sort`, default `'ascending'`) | `packages/engine/src/graphs/encoding.ts`. Graph channels default `sort: 'ascending'` (VL-aligned, deterministic); `sort: null` restores insertion order; explicit `scale.domain` wins. `nodeLabelPriority` maps a quantitative field to 0-1 label visibility priority (falls back to degree-based when omitted). |
122122
| Graph animation resolve (DEFAULT-ON; the deliberate chart/graph divergence) | `packages/engine/src/graphs/animation.ts``resolveGraphAnimation()`. `undefined`/`true` → full defaults; `false` → no choreography (warmup still applies); per-phase `false` → that phase `null`. |
123123
| Graph interaction resolve (hover mode, cursor repulsion, springy drag, select/flyTo) | `packages/engine/src/graphs/interaction.ts``resolveGraphInteraction()`. |
124-
| Graph mount (lifecycle, events, tooltips, legend, search, camera, highlight, update) | `packages/vanilla/src/graph-mount.ts``createGraph()`. Public API: `GraphInstance` (search/zoomToFit/zoomToNode/flyTo/centerAt/getCamera/selectNode/highlight/getLegend/update/updateVisuals). `suppressEntrance` mount option skips the reveal on a theme/darkMode-only remount (instant fit, warmup still runs). Physics-feel gates: `SPRINGY_DRAG_MAX_NODES` (5000), `CURSOR_FORCE_MAX_NODES` (2000). |
124+
| Graph mount (lifecycle, events, tooltips, legend, search, camera, highlight, update) | `packages/vanilla/src/graph-mount.ts``createGraph()`. Public API: `GraphInstance` (search/zoomToFit/zoomToNode/flyTo/centerAt/getCamera/selectNode/highlight/clearHighlight/getHighlight/setActiveCategories/getActiveCategories/getLegend/update/updateVisuals). `suppressEntrance` mount option skips the reveal on a theme/darkMode-only remount (instant fit, warmup still runs). Physics-feel gates: `SPRINGY_DRAG_MAX_NODES` (5000), `CURSOR_FORCE_MAX_NODES` (2000). |
125125
| Graph force simulation (d3-force wrapper, warmup, tick streaming, sync + worker) | `packages/vanilla/src/graph/simulation.ts``SimulationManager`. Worker protocol: `graph/worker-protocol.ts`; worker URL: `graph/simulation-worker-url.ts`. |
126126
| Graph seeded layout (deterministic initial positions) | `packages/vanilla/src/graph/seed.ts``seedNodePositions()`. Start position is a pure function of `(id, seed, community)`, so the settled layout is reproducible across data reshapes (not d3's order-dependent phyllotaxis). |
127127
| Graph animation scheduler (rAF loop, first-frame arming) | `packages/vanilla/src/graph/scheduler.ts``AnimationScheduler`. Owns running `GraphAnimation`s; the mount's render loop ticks it once/frame and re-arms rAF only while animations are active (zero cost when idle). |
128128
| Graph motion primitives (easings, reduced-motion, generic tween) | `packages/vanilla/src/graph/motion.ts``createTween()`, `resolveEase()`, `prefersReducedMotion()`. Canvas rAF, NOT CSS keyframes; only the easing vocabulary is shared with charts. |
129129
| Graph entrance choreography (reveal alpha, staggered start) | `packages/vanilla/src/graph/entrance.ts`. Turns the mount's 0→1 `entranceProgress` into a per-node reveal alpha, quantized so the canvas renderer still batches fills. The camera pull-back + flight lives in `graph-mount.ts` `startEntrance()`. |
130-
| Graph camera flights (animated zoom/pan) | `packages/vanilla/src/graph/camera.ts``createCameraFlight()`, `CameraFlightOptions`, `clampK`, `K_MIN`/`K_MAX`. Interpolates two `ZoomTransform`s along d3 `interpolateZoom`; supports a moving-target provider so a flight tracks a still-settling node. |
130+
| Graph camera flights (animated zoom/pan) | `packages/vanilla/src/graph/camera.ts``createCameraFlight()`, `createCameraFollow()`, `CameraFlightOptions`, `clampK`, `K_MIN`/`K_MAX`. Interpolates two `ZoomTransform`s along d3 `interpolateZoom`; supports a moving-target provider so a flight tracks a still-settling node. `createCameraFollow()` continuously tracks a moving target (e.g. entrance pull-back). |
131131
| Graph focus model (highlight ∩ search + hover-neighborhood, eased crossfade) | `packages/vanilla/src/graph/focus-transition.ts``FocusTransition`. Composes the three emphasis sources into a snapshot pair the renderer crossfades between (composition, not strict precedence). |
132132
| Graph update diff (unified `update()` path classification) | `packages/vanilla/src/graph/update-diff.ts``diffGraphUpdate()`. Classifies a change as `visualOnly` (same node+edge id sets AND equal simulationConfig → position-preserving) vs structural (reheat). Config equality: `graph/update-diff-config.ts` (compares the full resolved simulationConfig, not just `clustering.field` like the old React heuristic). |
133133
| Graph interactive legend (accessible built-in legend) | `packages/vanilla/src/graph/legend.ts``GraphLegendController`. Node-category rows are `aria-pressed` buttons that toggle emphasis via the focus model; edge-category rows are non-interactive swatches. Turn off with `legend: false` (do this if you render your own). |

packages/core/schema/vizspec.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16938,6 +16938,10 @@
1693816938
"nodeLabel": {
1693916939
"$ref": "#/definitions/GraphEncodingChannel",
1694016940
"description": "Label field for nodes."
16941+
},
16942+
"nodeLabelPriority": {
16943+
"$ref": "#/definitions/GraphEncodingChannel",
16944+
"description": "Label priority mapping for nodes. Maps a quantitative field to a 0-1 priority range; higher values are shown first as the user zooms in. Omit to use the default degree-based priority. `nodeOverrides[id].alwaysShowLabel` still wins as the hard override."
1694116945
}
1694216946
},
1694316947
"additionalProperties": false,

packages/core/src/types/spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,13 @@ export interface GraphEncoding {
790790
edgeStyle?: GraphEncodingChannel;
791791
/** Label field for nodes. */
792792
nodeLabel?: GraphEncodingChannel;
793+
/**
794+
* Label priority mapping for nodes. Maps a quantitative field to a 0-1
795+
* priority range; higher values are shown first as the user zooms in.
796+
* Omit to use the default degree-based priority.
797+
* `nodeOverrides[id].alwaysShowLabel` still wins as the hard override.
798+
*/
799+
nodeLabelPriority?: GraphEncodingChannel;
793800
}
794801

795802
/** Layout algorithm for graph visualization. */

packages/engine/src/compiler/validate.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1191,7 +1191,13 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
11911191
}
11921192
}
11931193

1194-
const nodeChannels = ['nodeColor', 'nodeSize', 'nodeOpacity', 'nodeLabel'] as const;
1194+
const nodeChannels = [
1195+
'nodeColor',
1196+
'nodeSize',
1197+
'nodeOpacity',
1198+
'nodeLabel',
1199+
'nodeLabelPriority',
1200+
] as const;
11951201
for (const channel of nodeChannels) {
11961202
const ch = encoding[channel] as Record<string, unknown> | undefined;
11971203
if (

packages/engine/src/graphs/__tests__/encoding.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,74 @@ describe('resolveNodeVisuals', () => {
141141
const hub = nodes.find((n) => n.id === 'a')!;
142142
expect(hub.labelPriority).toBe(1.0);
143143
});
144+
145+
it('uses field value when nodeLabelPriority is set', () => {
146+
const encoding: GraphEncoding = {
147+
nodeLabelPriority: { field: 'value', type: 'quantitative' },
148+
};
149+
150+
const nodes = resolveNodeVisuals(basicNodes, encoding, basicEdges, theme);
151+
152+
const nodeA = nodes.find((n) => n.id === 'a')!; // value: 10 (min)
153+
const nodeC = nodes.find((n) => n.id === 'c')!; // value: 100 (max)
154+
expect(nodeA.labelPriority).toBeCloseTo(0, 1);
155+
expect(nodeC.labelPriority).toBeCloseTo(1, 1);
156+
});
157+
158+
it('falls back to degree-based priority when nodeLabelPriority omitted', () => {
159+
const hubEdges: GraphEdge[] = [
160+
{ source: 'a', target: 'b' },
161+
{ source: 'a', target: 'c' },
162+
];
163+
164+
const withChannel = resolveNodeVisuals(basicNodes, {}, hubEdges, theme);
165+
const hub = withChannel.find((n) => n.id === 'a')!;
166+
expect(hub.labelPriority).toBe(1.0);
167+
});
168+
169+
it('alwaysShowLabel overrides nodeLabelPriority to Infinity', () => {
170+
const encoding: GraphEncoding = {
171+
nodeLabelPriority: { field: 'value', type: 'quantitative' },
172+
};
173+
const overrides = { a: { alwaysShowLabel: true } };
174+
175+
const nodes = resolveNodeVisuals(basicNodes, encoding, basicEdges, theme, overrides);
176+
177+
const nodeA = nodes.find((n) => n.id === 'a')!;
178+
expect(nodeA.labelPriority).toBe(Infinity);
179+
});
180+
181+
it('respects scale.range override on nodeLabelPriority', () => {
182+
const encoding: GraphEncoding = {
183+
nodeLabelPriority: {
184+
field: 'value',
185+
type: 'quantitative',
186+
scale: { range: [0.5, 1] },
187+
},
188+
};
189+
190+
const nodes = resolveNodeVisuals(basicNodes, encoding, basicEdges, theme);
191+
192+
const nodeA = nodes.find((n) => n.id === 'a')!; // value: 10 (min)
193+
const nodeC = nodes.find((n) => n.id === 'c')!; // value: 100 (max)
194+
expect(nodeA.labelPriority).toBeCloseTo(0.5, 1);
195+
expect(nodeC.labelPriority).toBeCloseTo(1, 1);
196+
});
197+
198+
it('non-numeric field values get priority 0', () => {
199+
const nodesWithString: GraphNode[] = [
200+
{ id: 'a', importance: 'high' },
201+
{ id: 'b', importance: 50 },
202+
];
203+
const encoding: GraphEncoding = {
204+
nodeLabelPriority: { field: 'importance', type: 'quantitative' },
205+
};
206+
207+
const nodes = resolveNodeVisuals(nodesWithString, encoding, [], theme);
208+
209+
const nodeA = nodes.find((n) => n.id === 'a')!;
210+
expect(nodeA.labelPriority).toBe(0);
211+
});
144212
});
145213

146214
describe('label resolution', () => {

packages/engine/src/graphs/encoding.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,26 @@ export function resolveNodeVisuals(
264264
opacityScale = scaleLinear().domain(domain).range(range);
265265
}
266266

267+
// Build label priority scale. Maps a quantitative field to [0, 1] so
268+
// higher values are shown first. Falls back to degree-based priority.
269+
let labelPriorityScale: ((v: number) => number) | undefined;
270+
if (encoding.nodeLabelPriority?.field) {
271+
const field = encoding.nodeLabelPriority.field;
272+
const scaleConfig = encoding.nodeLabelPriority.scale;
273+
const values = nodes.map((n) => Number(n[field])).filter((v) => Number.isFinite(v));
274+
const lpMin = min(values) ?? 0;
275+
const lpMax = max(values) ?? 1;
276+
const domain =
277+
scaleConfig?.domain && scaleConfig.domain.length === 2
278+
? (scaleConfig.domain as [number, number])
279+
: [lpMin, lpMax];
280+
const range =
281+
scaleConfig?.range && scaleConfig.range.length >= 2
282+
? [Number(scaleConfig.range[0]), Number(scaleConfig.range[1])]
283+
: [0, 1];
284+
labelPriorityScale = scaleLinear().domain(domain).range(range);
285+
}
286+
267287
return nodes.map((node) => {
268288
// Radius
269289
let radius = DEFAULT_NODE_RADIUS;
@@ -298,9 +318,15 @@ export function resolveNodeVisuals(
298318
label = node.id;
299319
}
300320

301-
// Label priority: degree / maxDegree (0 to 1)
302-
const degree = degrees.get(node.id) ?? 0;
303-
const labelPriority = maxDegree > 0 ? degree / maxDegree : 0;
321+
// Label priority: field-driven when nodeLabelPriority is set, else degree-based.
322+
let labelPriority: number;
323+
if (labelPriorityScale && encoding.nodeLabelPriority?.field) {
324+
const val = Number(node[encoding.nodeLabelPriority.field]);
325+
labelPriority = Number.isFinite(val) ? labelPriorityScale(val) : 0;
326+
} else {
327+
const degree = degrees.get(node.id) ?? 0;
328+
labelPriority = maxDegree > 0 ? degree / maxDegree : 0;
329+
}
304330

305331
// Data: spread all original node fields
306332
const { id: _id, ...rest } = node;

packages/react/src/Graph.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,12 @@ export const Graph = forwardRef<GraphHandle, GraphProps>(function Graph(
261261
getLegend() {
262262
return graphRef.current?.getLegend() ?? null;
263263
},
264+
setActiveCategories(values: string[]) {
265+
graphRef.current?.setActiveCategories(values);
266+
},
267+
getActiveCategories() {
268+
return graphRef.current?.getActiveCategories() ?? [];
269+
},
264270
updateVisuals(spec: GraphSpec) {
265271
graphRef.current?.updateVisuals(spec);
266272
},

packages/react/src/hooks/useGraph.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export interface UseGraphReturn {
4949
getHighlight: () => string[] | null;
5050
/** Headless snapshot of the legend. */
5151
getLegend: () => GraphLegendData | null;
52+
/** Set the active category filter declaratively (replaces legend toggle state). */
53+
setActiveCategories: (values: string[]) => void;
54+
/** Current active category values (empty = all active, no filter). */
55+
getActiveCategories: () => string[];
5256
}
5357

5458
/** Handle exposed by Graph component via forwardRef. */
@@ -67,6 +71,8 @@ export interface GraphHandle {
6771
clearHighlight: () => void;
6872
getHighlight: () => string[] | null;
6973
getLegend: () => GraphLegendData | null;
74+
setActiveCategories: (values: string[]) => void;
75+
getActiveCategories: () => string[];
7076
/** Re-compile encoding/legend/chrome without restarting the simulation. */
7177
updateVisuals: (spec: GraphSpec) => void;
7278
/** The underlying GraphInstance from the vanilla adapter. */
@@ -150,6 +156,14 @@ export function useGraph(): UseGraphReturn {
150156
return ref.current?.getLegend() ?? null;
151157
}, []);
152158

159+
const setActiveCategories = useCallback((values: string[]) => {
160+
ref.current?.setActiveCategories(values);
161+
}, []);
162+
163+
const getActiveCategories = useCallback((): string[] => {
164+
return ref.current?.getActiveCategories() ?? [];
165+
}, []);
166+
153167
return {
154168
ref,
155169
search,
@@ -166,5 +180,7 @@ export function useGraph(): UseGraphReturn {
166180
clearHighlight,
167181
getHighlight,
168182
getLegend,
183+
setActiveCategories,
184+
getActiveCategories,
169185
};
170186
}

packages/svelte/src/Graph.svelte

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,14 @@ export function getLegend(): GraphLegendData | null {
222222
return instance?.getLegend() ?? null;
223223
}
224224
225+
export function setActiveCategories(values: string[]): void {
226+
instance?.setActiveCategories(values);
227+
}
228+
229+
export function getActiveCategories(): string[] {
230+
return instance?.getActiveCategories() ?? [];
231+
}
232+
225233
export function updateVisuals(nextSpec: GraphSpec): void {
226234
instance?.updateVisuals(nextSpec);
227235
}

packages/svelte/src/composables/useGraph.svelte.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ export interface UseGraphReturn {
9494
getHighlight: () => string[] | null;
9595
/** Headless snapshot of the legend. */
9696
getLegend: () => GraphLegendData | null;
97+
/** Set the active category filter declaratively (replaces legend toggle state). */
98+
setActiveCategories: (values: string[]) => void;
99+
/** Current active category values (empty = all active, no filter). */
100+
getActiveCategories: () => string[];
97101
}
98102

99103
export function useGraph(
@@ -185,5 +189,11 @@ export function useGraph(
185189
getLegend(): GraphLegendData | null {
186190
return instance?.getLegend() ?? null;
187191
},
192+
setActiveCategories(values: string[]) {
193+
instance?.setActiveCategories(values);
194+
},
195+
getActiveCategories(): string[] {
196+
return instance?.getActiveCategories() ?? [];
197+
},
188198
};
189199
}

0 commit comments

Comments
 (0)