From 9be46a990c0995b363e391ccfbd1df282fb8785b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 10:22:27 +0000 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_01ATzbKkoDMTaWLqD8xZBQNv --- README.md | 2 +- src/index.ts | 6 ++ src/transforms.ts | 121 +++++++++++++++++++++++++++++++++++++++ tests/transforms.test.ts | 113 ++++++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3bc387f..8eaaca8 100644 --- a/README.md +++ b/README.md @@ -408,7 +408,7 @@ Beyond classic graph algorithms, the library also includes utilities for evolvin - `getDiff()`, `getPatches()`, `getPatchedGraph()` (immutable), and `updateGraphWithPatches()` (mutable) for graph change tracking - `genRandomWalk()`, `genWeightedRandomWalk()`, and coverage helpers for model-based testing and simulation -- `getSubgraph()`, `getNeighborhood()`, `getReversedGraph()`, and `getLineGraph()` for structural transforms +- `getSubgraph()`, `getFilteredGraph()`, `getMappedGraph()`, `getNeighborhood()`, `getReversedGraph()`, and `getLineGraph()` for structural transforms - `getGraphUnion()`, `getGraphIntersection()`, `getGraphDifference()`, `getGraphSymmetricDifference()`, `getDisjointUnion()`, and `getGraphComplement()` for graph set operations 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. diff --git a/src/index.ts b/src/index.ts index b2b9ac8..c07445b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -279,10 +279,16 @@ export { getFlattenedGraph, flatten, getSubgraph, + getMappedGraph, + getFilteredGraph, getLineGraph, getReversedGraph, reverseGraph, } from './transforms'; +export type { + MappedGraphOptions, + FilteredGraphOptions, +} from './transforms'; export { getNeighborhood } from './neighborhood'; // Set operations diff --git a/src/transforms.ts b/src/transforms.ts index 8dac2e3..3904a35 100644 --- a/src/transforms.ts +++ b/src/transforms.ts @@ -1,4 +1,5 @@ import type { + EdgeConfig, Graph, GraphEdge, GraphNode, @@ -354,6 +355,126 @@ export function getReversedGraph( }); } +// Map & filter transforms + +export interface MappedGraphOptions { + /** Map each node's `data`. All other fields and the structure are preserved. */ + node?: (node: GraphNode) => N2; + /** Map each edge's `data`. All other fields and the structure are preserved. */ + edge?: (edge: GraphEdge) => E2; +} + +export interface FilteredGraphOptions { + /** Keep only nodes passing this predicate. Incident edges of dropped nodes are removed. */ + node?: (node: GraphNode) => boolean; + /** Keep only edges passing this predicate. Endpoints are unaffected. */ + edge?: (edge: GraphEdge) => boolean; +} + +/** + * Returns a new graph with node and/or edge `data` transformed by the given + * mapping functions. Structure (IDs, endpoints, hierarchy, ports, layout) is + * preserved; only `data` changes. Returning `undefined` clears `data`. + * + * Keep mapped data JSON-serializable — no functions, classes, or symbols. + * + * @example + * ```ts + * import { createGraph, getMappedGraph } from '@statelyai/graph'; + * + * const graph = createGraph({ + * nodes: [{ id: 'a', data: 1 }, { id: 'b', data: 2 }], + * edges: [{ id: 'ab', sourceId: 'a', targetId: 'b', data: 'x' }], + * }); + * + * const doubled = getMappedGraph(graph, { + * node: (n) => n.data * 2, + * edge: (e) => e.data.toUpperCase(), + * }); + * // doubled node data: 2, 4; edge data: 'X' + * ``` + */ +export function getMappedGraph( + graph: Graph, + options: MappedGraphOptions, +): Graph { + return createGraph({ + id: graph.id, + mode: graph.mode, + initialNodeId: graph.initialNodeId ?? undefined, + nodes: graph.nodes.map((n) => { + const config = toNodeConfig(n) as NodeConfig; + if (options.node) { + const data = options.node(n); + if (data === undefined) delete config.data; + else config.data = data; + } + return config as NodeConfig; + }), + edges: graph.edges.map((e) => { + const config = toEdgeConfig(e) as EdgeConfig; + if (options.edge) { + const data = options.edge(e); + if (data === undefined) delete config.data; + else config.data = data; + } + return config as EdgeConfig; + }), + data: graph.data, + }); +} + +/** + * Returns a new graph keeping only nodes and edges that pass the given + * predicates. Dropping a node also drops its incident edges; parent and + * initial-node references to dropped nodes are removed (as in + * {@link getSubgraph}). + * + * @example + * ```ts + * import { createGraph, getFilteredGraph } from '@statelyai/graph'; + * + * const graph = createGraph({ + * nodes: [{ id: 'a', data: 1 }, { id: 'b', data: 2 }, { id: 'c', data: 3 }], + * edges: [ + * { id: 'ab', sourceId: 'a', targetId: 'b' }, + * { id: 'bc', sourceId: 'b', targetId: 'c' }, + * ], + * }); + * + * const filtered = getFilteredGraph(graph, { node: (n) => n.data < 3 }); + * // filtered.nodes: [a, b], filtered.edges: [ab] + * ``` + */ +export function getFilteredGraph( + graph: Graph, + options: FilteredGraphOptions, +): Graph { + const nodes = options.node + ? graph.nodes.filter((n) => options.node!(n)) + : graph.nodes; + const nodeIdSet = new Set(nodes.map((n) => n.id)); + + return createGraph({ + id: graph.id, + mode: graph.mode, + initialNodeId: + graph.initialNodeId && nodeIdSet.has(graph.initialNodeId) + ? graph.initialNodeId + : undefined, + nodes: nodes.map((n) => toScopedNodeConfig(n, nodeIdSet)), + edges: graph.edges + .filter( + (e) => + nodeIdSet.has(e.sourceId) && + nodeIdSet.has(e.targetId) && + (options.edge ? options.edge(e) : true), + ) + .map(toEdgeConfig), + data: graph.data, + }); +} + /** * @deprecated Use {@link getReversedGraph}. */ diff --git a/tests/transforms.test.ts b/tests/transforms.test.ts index ca6be39..f193725 100644 --- a/tests/transforms.test.ts +++ b/tests/transforms.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'; import { createGraph, getFlattenedGraph, + getMappedGraph, + getFilteredGraph, getShortestPaths, getTopologicalSort, isAcyclic, @@ -532,3 +534,114 @@ describe('getFlattenedGraph', () => { expect(edges).toContain('b2->end'); }); }); + +describe('getMappedGraph', () => { + it('maps node and edge data while preserving structure', () => { + const g = createGraph({ + id: 'g', + initialNodeId: 'a', + nodes: [ + { id: 'a', data: 1, x: 5, y: 6 }, + { id: 'b', data: 2, parentId: 'a' }, + ], + edges: [ + { id: 'ab', sourceId: 'a', targetId: 'b', data: 'x', weight: 3 }, + ], + data: { name: 'meta' }, + }); + + const mapped = getMappedGraph(g, { + node: (n) => n.data * 10, + edge: (e) => e.data.toUpperCase(), + }); + + expect(mapped.nodes.map((n) => n.data)).toEqual([10, 20]); + expect(mapped.edges[0].data).toBe('X'); + // structure and metadata preserved + expect(mapped.id).toBe('g'); + expect(mapped.initialNodeId).toBe('a'); + expect(mapped.nodes[0].x).toBe(5); + expect(mapped.nodes[1].parentId).toBe('a'); + expect(mapped.edges[0].weight).toBe(3); + expect(mapped.data).toEqual({ name: 'meta' }); + // original untouched + expect(g.nodes[0].data).toBe(1); + expect(g.edges[0].data).toBe('x'); + }); + + it('mapping only one entity kind leaves the other unchanged', () => { + const g = createGraph({ + nodes: [{ id: 'a', data: 1 }], + edges: [], + }); + const mapped = getMappedGraph(g, {}); + expect(mapped.nodes[0].data).toBe(1); + }); + + it('returning undefined clears data', () => { + const g = createGraph({ + nodes: [{ id: 'a', data: 1 }], + edges: [], + }); + const mapped = getMappedGraph(g, { node: () => undefined }); + expect(mapped.nodes[0].data).toBeNull(); + }); +}); + +describe('getFilteredGraph', () => { + const make = () => + createGraph({ + id: 'g', + initialNodeId: 'a', + nodes: [ + { id: 'a', data: 1 }, + { id: 'b', data: 2, parentId: 'a', initialNodeId: 'a' }, + { id: 'c', data: 3, parentId: 'b' }, + ], + edges: [ + { id: 'ab', sourceId: 'a', targetId: 'b', weight: 1 }, + { id: 'bc', sourceId: 'b', targetId: 'c', weight: 2 }, + { id: 'ca', sourceId: 'c', targetId: 'a', weight: 3 }, + ], + }); + + it('filters nodes and drops incident edges', () => { + const filtered = getFilteredGraph(make(), { node: (n) => n.data < 3 }); + expect(filtered.nodes.map((n) => n.id)).toEqual(['a', 'b']); + expect(filtered.edges.map((e) => e.id)).toEqual(['ab']); + expect(filtered.initialNodeId).toBe('a'); + }); + + it('filters edges without touching nodes', () => { + const filtered = getFilteredGraph(make(), { edge: (e) => e.weight! < 3 }); + expect(filtered.nodes).toHaveLength(3); + expect(filtered.edges.map((e) => e.id)).toEqual(['ab', 'bc']); + }); + + it('combines node and edge predicates', () => { + const filtered = getFilteredGraph(make(), { + node: (n) => n.id !== 'c', + edge: (e) => e.weight! > 100, + }); + expect(filtered.nodes.map((n) => n.id)).toEqual(['a', 'b']); + expect(filtered.edges).toEqual([]); + }); + + it('strips dangling parent/initial references and graph initialNodeId', () => { + const filtered = getFilteredGraph(make(), { node: (n) => n.id !== 'a' }); + expect(filtered.initialNodeId).toBeNull(); + const b = filtered.nodes.find((n) => n.id === 'b')!; + expect(b.parentId).toBeUndefined(); + expect(b.initialNodeId).toBeUndefined(); + const c = filtered.nodes.find((n) => n.id === 'c')!; + expect(c.parentId).toBe('b'); + expect(filtered.edges.map((e) => e.id)).toEqual(['bc']); + }); + + it('no predicates returns an equivalent copy', () => { + const g = make(); + const filtered = getFilteredGraph(g, {}); + expect(filtered.nodes).toHaveLength(3); + expect(filtered.edges).toHaveLength(3); + }); +}); From 18277745031ddea8f494fb9c5222fdac37f254b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 10:37:12 +0000 Subject: [PATCH 2/2] 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 Claude-Session: https://claude.ai/code/session_01ATzbKkoDMTaWLqD8xZBQNv --- .changeset/mapped-filtered-transforms.md | 5 +++++ src/transforms.ts | 8 ++++++++ tests/transforms.test.ts | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 .changeset/mapped-filtered-transforms.md diff --git a/.changeset/mapped-filtered-transforms.md b/.changeset/mapped-filtered-transforms.md new file mode 100644 index 0000000..74e7749 --- /dev/null +++ b/.changeset/mapped-filtered-transforms.md @@ -0,0 +1,5 @@ +--- +'@statelyai/graph': minor +--- + +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`. diff --git a/src/transforms.ts b/src/transforms.ts index 3904a35..5cba886 100644 --- a/src/transforms.ts +++ b/src/transforms.ts @@ -301,6 +301,8 @@ export function getSubgraph( .filter((e) => nodeIdSet.has(e.sourceId) && nodeIdSet.has(e.targetId)) .map(toEdgeConfig), data: graph.data, + direction: graph.direction, + style: graph.style, }); } @@ -352,6 +354,8 @@ export function getReversedGraph( return config; }), data: graph.data, + direction: graph.direction, + style: graph.style, }); } @@ -421,6 +425,8 @@ export function getMappedGraph( return config as EdgeConfig; }), data: graph.data, + direction: graph.direction, + style: graph.style, }); } @@ -472,6 +478,8 @@ export function getFilteredGraph( ) .map(toEdgeConfig), data: graph.data, + direction: graph.direction, + style: graph.style, }); } diff --git a/tests/transforms.test.ts b/tests/transforms.test.ts index f193725..c9c903a 100644 --- a/tests/transforms.test.ts +++ b/tests/transforms.test.ts @@ -645,3 +645,22 @@ describe('getFilteredGraph', () => { expect(filtered.edges).toHaveLength(3); }); }); + +describe('transform metadata preservation', () => { + it('getMappedGraph and getFilteredGraph keep graph direction and style', () => { + const g = createGraph({ + nodes: [{ id: 'a', data: 1 }, { id: 'b', data: 2 }], + edges: [{ id: 'ab', sourceId: 'a', targetId: 'b' }], + direction: 'right', + style: { stroke: 'red' }, + }); + + const mapped = getMappedGraph(g, { node: (n) => n.data * 2 }); + expect(mapped.direction).toBe('right'); + expect(mapped.style).toEqual({ stroke: 'red' }); + + const filtered = getFilteredGraph(g, { node: (n) => n.data < 2 }); + expect(filtered.direction).toBe('right'); + expect(filtered.style).toEqual({ stroke: 'red' }); + }); +});