Skip to content

Commit 66b3828

Browse files
davidkpianoclaude
andauthored
Add getMappedGraph and getFilteredGraph transforms (#33)
* Add getMappedGraph and getFilteredGraph transforms Predicate-based filtering and data-mapping transforms that preserve graph structure, closing the map/filter API gap with effect/graph (mapNodes/mapEdges/filterNodes/filterEdges). Dropping a node drops its incident edges and strips dangling parent/initial references, matching getSubgraph semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATzbKkoDMTaWLqD8xZBQNv * Preserve graph direction/style in transforms; add changeset Forward graph-level direction and style in getMappedGraph, getFilteredGraph, getSubgraph, and getReversedGraph so transformed graphs keep their top-level drawing settings. Adds a changeset for the new transforms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATzbKkoDMTaWLqD8xZBQNv --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3baa78c commit 66b3828

5 files changed

Lines changed: 273 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@statelyai/graph': minor
3+
---
4+
5+
Add `getMappedGraph()` and `getFilteredGraph()` structural transforms. `getMappedGraph()` returns a new graph with node/edge `data` transformed by mapping functions while preserving all structure; `getFilteredGraph()` returns a new graph keeping only nodes and edges that pass the given predicates, dropping incident edges of removed nodes. `getSubgraph()`, `getReversedGraph()`, and the new transforms now also preserve graph-level `direction` and `style`.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,7 @@ Beyond classic graph algorithms, the library also includes utilities for evolvin
408408

409409
- `getDiff()`, `getPatches()`, `getPatchedGraph()` (immutable), and `updateGraphWithPatches()` (mutable) for graph change tracking
410410
- `genRandomWalk()`, `genWeightedRandomWalk()`, and coverage helpers for model-based testing and simulation
411-
- `getSubgraph()`, `getNeighborhood()`, `getReversedGraph()`, and `getLineGraph()` for structural transforms
411+
- `getSubgraph()`, `getFilteredGraph()`, `getMappedGraph()`, `getNeighborhood()`, `getReversedGraph()`, and `getLineGraph()` for structural transforms
412412
- `getGraphUnion()`, `getGraphIntersection()`, `getGraphDifference()`, `getGraphSymmetricDifference()`, `getDisjointUnion()`, and `getGraphComplement()` for graph set operations
413413

414414
Binary set operations match nodes and edges by stable ID, require matching graph modes, and retain graph metadata from the left operand. Union and intersection use right-side entity data when IDs conflict. Disjoint union keeps left IDs and deterministically remaps right-side collisions.

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,10 +279,16 @@ export {
279279
getFlattenedGraph,
280280
flatten,
281281
getSubgraph,
282+
getMappedGraph,
283+
getFilteredGraph,
282284
getLineGraph,
283285
getReversedGraph,
284286
reverseGraph,
285287
} from './transforms';
288+
export type {
289+
MappedGraphOptions,
290+
FilteredGraphOptions,
291+
} from './transforms';
286292
export { getNeighborhood } from './neighborhood';
287293

288294
// Set operations

src/transforms.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
EdgeConfig,
23
Graph,
34
GraphEdge,
45
GraphNode,
@@ -300,6 +301,8 @@ export function getSubgraph<N, E, G, P>(
300301
.filter((e) => nodeIdSet.has(e.sourceId) && nodeIdSet.has(e.targetId))
301302
.map(toEdgeConfig),
302303
data: graph.data,
304+
direction: graph.direction,
305+
style: graph.style,
303306
});
304307
}
305308

@@ -351,6 +354,132 @@ export function getReversedGraph<N, E, G>(
351354
return config;
352355
}),
353356
data: graph.data,
357+
direction: graph.direction,
358+
style: graph.style,
359+
});
360+
}
361+
362+
// Map & filter transforms
363+
364+
export interface MappedGraphOptions<N, E, P, N2, E2> {
365+
/** Map each node's `data`. All other fields and the structure are preserved. */
366+
node?: (node: GraphNode<N, P>) => N2;
367+
/** Map each edge's `data`. All other fields and the structure are preserved. */
368+
edge?: (edge: GraphEdge<E>) => E2;
369+
}
370+
371+
export interface FilteredGraphOptions<N, E, P> {
372+
/** Keep only nodes passing this predicate. Incident edges of dropped nodes are removed. */
373+
node?: (node: GraphNode<N, P>) => boolean;
374+
/** Keep only edges passing this predicate. Endpoints are unaffected. */
375+
edge?: (edge: GraphEdge<E>) => boolean;
376+
}
377+
378+
/**
379+
* Returns a new graph with node and/or edge `data` transformed by the given
380+
* mapping functions. Structure (IDs, endpoints, hierarchy, ports, layout) is
381+
* preserved; only `data` changes. Returning `undefined` clears `data`.
382+
*
383+
* Keep mapped data JSON-serializable — no functions, classes, or symbols.
384+
*
385+
* @example
386+
* ```ts
387+
* import { createGraph, getMappedGraph } from '@statelyai/graph';
388+
*
389+
* const graph = createGraph({
390+
* nodes: [{ id: 'a', data: 1 }, { id: 'b', data: 2 }],
391+
* edges: [{ id: 'ab', sourceId: 'a', targetId: 'b', data: 'x' }],
392+
* });
393+
*
394+
* const doubled = getMappedGraph(graph, {
395+
* node: (n) => n.data * 2,
396+
* edge: (e) => e.data.toUpperCase(),
397+
* });
398+
* // doubled node data: 2, 4; edge data: 'X'
399+
* ```
400+
*/
401+
export function getMappedGraph<N, E, G, P, N2 = N, E2 = E>(
402+
graph: Graph<N, E, G, P>,
403+
options: MappedGraphOptions<N, E, P, N2, E2>,
404+
): Graph<N2, E2, G, P> {
405+
return createGraph({
406+
id: graph.id,
407+
mode: graph.mode,
408+
initialNodeId: graph.initialNodeId ?? undefined,
409+
nodes: graph.nodes.map((n) => {
410+
const config = toNodeConfig(n) as NodeConfig<unknown, P>;
411+
if (options.node) {
412+
const data = options.node(n);
413+
if (data === undefined) delete config.data;
414+
else config.data = data;
415+
}
416+
return config as NodeConfig<N2, P>;
417+
}),
418+
edges: graph.edges.map((e) => {
419+
const config = toEdgeConfig(e) as EdgeConfig<unknown>;
420+
if (options.edge) {
421+
const data = options.edge(e);
422+
if (data === undefined) delete config.data;
423+
else config.data = data;
424+
}
425+
return config as EdgeConfig<E2>;
426+
}),
427+
data: graph.data,
428+
direction: graph.direction,
429+
style: graph.style,
430+
});
431+
}
432+
433+
/**
434+
* Returns a new graph keeping only nodes and edges that pass the given
435+
* predicates. Dropping a node also drops its incident edges; parent and
436+
* initial-node references to dropped nodes are removed (as in
437+
* {@link getSubgraph}).
438+
*
439+
* @example
440+
* ```ts
441+
* import { createGraph, getFilteredGraph } from '@statelyai/graph';
442+
*
443+
* const graph = createGraph({
444+
* nodes: [{ id: 'a', data: 1 }, { id: 'b', data: 2 }, { id: 'c', data: 3 }],
445+
* edges: [
446+
* { id: 'ab', sourceId: 'a', targetId: 'b' },
447+
* { id: 'bc', sourceId: 'b', targetId: 'c' },
448+
* ],
449+
* });
450+
*
451+
* const filtered = getFilteredGraph(graph, { node: (n) => n.data < 3 });
452+
* // filtered.nodes: [a, b], filtered.edges: [ab]
453+
* ```
454+
*/
455+
export function getFilteredGraph<N, E, G, P>(
456+
graph: Graph<N, E, G, P>,
457+
options: FilteredGraphOptions<N, E, P>,
458+
): Graph<N, E, G, P> {
459+
const nodes = options.node
460+
? graph.nodes.filter((n) => options.node!(n))
461+
: graph.nodes;
462+
const nodeIdSet = new Set(nodes.map((n) => n.id));
463+
464+
return createGraph({
465+
id: graph.id,
466+
mode: graph.mode,
467+
initialNodeId:
468+
graph.initialNodeId && nodeIdSet.has(graph.initialNodeId)
469+
? graph.initialNodeId
470+
: undefined,
471+
nodes: nodes.map((n) => toScopedNodeConfig(n, nodeIdSet)),
472+
edges: graph.edges
473+
.filter(
474+
(e) =>
475+
nodeIdSet.has(e.sourceId) &&
476+
nodeIdSet.has(e.targetId) &&
477+
(options.edge ? options.edge(e) : true),
478+
)
479+
.map(toEdgeConfig),
480+
data: graph.data,
481+
direction: graph.direction,
482+
style: graph.style,
354483
});
355484
}
356485

tests/transforms.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest';
22
import {
33
createGraph,
44
getFlattenedGraph,
5+
getMappedGraph,
6+
getFilteredGraph,
57
getShortestPaths,
68
getTopologicalSort,
79
isAcyclic,
@@ -532,3 +534,133 @@ describe('getFlattenedGraph', () => {
532534
expect(edges).toContain('b2->end');
533535
});
534536
});
537+
538+
describe('getMappedGraph', () => {
539+
it('maps node and edge data while preserving structure', () => {
540+
const g = createGraph({
541+
id: 'g',
542+
initialNodeId: 'a',
543+
nodes: [
544+
{ id: 'a', data: 1, x: 5, y: 6 },
545+
{ id: 'b', data: 2, parentId: 'a' },
546+
],
547+
edges: [
548+
{ id: 'ab', sourceId: 'a', targetId: 'b', data: 'x', weight: 3 },
549+
],
550+
data: { name: 'meta' },
551+
});
552+
553+
const mapped = getMappedGraph(g, {
554+
node: (n) => n.data * 10,
555+
edge: (e) => e.data.toUpperCase(),
556+
});
557+
558+
expect(mapped.nodes.map((n) => n.data)).toEqual([10, 20]);
559+
expect(mapped.edges[0].data).toBe('X');
560+
// structure and metadata preserved
561+
expect(mapped.id).toBe('g');
562+
expect(mapped.initialNodeId).toBe('a');
563+
expect(mapped.nodes[0].x).toBe(5);
564+
expect(mapped.nodes[1].parentId).toBe('a');
565+
expect(mapped.edges[0].weight).toBe(3);
566+
expect(mapped.data).toEqual({ name: 'meta' });
567+
// original untouched
568+
expect(g.nodes[0].data).toBe(1);
569+
expect(g.edges[0].data).toBe('x');
570+
});
571+
572+
it('mapping only one entity kind leaves the other unchanged', () => {
573+
const g = createGraph({
574+
nodes: [{ id: 'a', data: 1 }],
575+
edges: [],
576+
});
577+
const mapped = getMappedGraph(g, {});
578+
expect(mapped.nodes[0].data).toBe(1);
579+
});
580+
581+
it('returning undefined clears data', () => {
582+
const g = createGraph({
583+
nodes: [{ id: 'a', data: 1 }],
584+
edges: [],
585+
});
586+
const mapped = getMappedGraph(g, { node: () => undefined });
587+
expect(mapped.nodes[0].data).toBeNull();
588+
});
589+
});
590+
591+
describe('getFilteredGraph', () => {
592+
const make = () =>
593+
createGraph({
594+
id: 'g',
595+
initialNodeId: 'a',
596+
nodes: [
597+
{ id: 'a', data: 1 },
598+
{ id: 'b', data: 2, parentId: 'a', initialNodeId: 'a' },
599+
{ id: 'c', data: 3, parentId: 'b' },
600+
],
601+
edges: [
602+
{ id: 'ab', sourceId: 'a', targetId: 'b', weight: 1 },
603+
{ id: 'bc', sourceId: 'b', targetId: 'c', weight: 2 },
604+
{ id: 'ca', sourceId: 'c', targetId: 'a', weight: 3 },
605+
],
606+
});
607+
608+
it('filters nodes and drops incident edges', () => {
609+
const filtered = getFilteredGraph(make(), { node: (n) => n.data < 3 });
610+
expect(filtered.nodes.map((n) => n.id)).toEqual(['a', 'b']);
611+
expect(filtered.edges.map((e) => e.id)).toEqual(['ab']);
612+
expect(filtered.initialNodeId).toBe('a');
613+
});
614+
615+
it('filters edges without touching nodes', () => {
616+
const filtered = getFilteredGraph(make(), { edge: (e) => e.weight! < 3 });
617+
expect(filtered.nodes).toHaveLength(3);
618+
expect(filtered.edges.map((e) => e.id)).toEqual(['ab', 'bc']);
619+
});
620+
621+
it('combines node and edge predicates', () => {
622+
const filtered = getFilteredGraph(make(), {
623+
node: (n) => n.id !== 'c',
624+
edge: (e) => e.weight! > 100,
625+
});
626+
expect(filtered.nodes.map((n) => n.id)).toEqual(['a', 'b']);
627+
expect(filtered.edges).toEqual([]);
628+
});
629+
630+
it('strips dangling parent/initial references and graph initialNodeId', () => {
631+
const filtered = getFilteredGraph(make(), { node: (n) => n.id !== 'a' });
632+
expect(filtered.initialNodeId).toBeNull();
633+
const b = filtered.nodes.find((n) => n.id === 'b')!;
634+
expect(b.parentId).toBeUndefined();
635+
expect(b.initialNodeId).toBeUndefined();
636+
const c = filtered.nodes.find((n) => n.id === 'c')!;
637+
expect(c.parentId).toBe('b');
638+
expect(filtered.edges.map((e) => e.id)).toEqual(['bc']);
639+
});
640+
641+
it('no predicates returns an equivalent copy', () => {
642+
const g = make();
643+
const filtered = getFilteredGraph(g, {});
644+
expect(filtered.nodes).toHaveLength(3);
645+
expect(filtered.edges).toHaveLength(3);
646+
});
647+
});
648+
649+
describe('transform metadata preservation', () => {
650+
it('getMappedGraph and getFilteredGraph keep graph direction and style', () => {
651+
const g = createGraph({
652+
nodes: [{ id: 'a', data: 1 }, { id: 'b', data: 2 }],
653+
edges: [{ id: 'ab', sourceId: 'a', targetId: 'b' }],
654+
direction: 'right',
655+
style: { stroke: 'red' },
656+
});
657+
658+
const mapped = getMappedGraph(g, { node: (n) => n.data * 2 });
659+
expect(mapped.direction).toBe('right');
660+
expect(mapped.style).toEqual({ stroke: 'red' });
661+
662+
const filtered = getFilteredGraph(g, { node: (n) => n.data < 2 });
663+
expect(filtered.direction).toBe('right');
664+
expect(filtered.style).toEqual({ stroke: 'red' });
665+
});
666+
});

0 commit comments

Comments
 (0)