Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mapped-filtered-transforms.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,10 +279,16 @@ export {
getFlattenedGraph,
flatten,
getSubgraph,
getMappedGraph,
getFilteredGraph,
getLineGraph,
getReversedGraph,
reverseGraph,
} from './transforms';
export type {
MappedGraphOptions,
FilteredGraphOptions,
} from './transforms';
Comment thread
davidkpiano marked this conversation as resolved.
export { getNeighborhood } from './neighborhood';

// Set operations
Expand Down
129 changes: 129 additions & 0 deletions src/transforms.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
EdgeConfig,
Graph,
GraphEdge,
GraphNode,
Expand Down Expand Up @@ -300,6 +301,8 @@ export function getSubgraph<N, E, G, P>(
.filter((e) => nodeIdSet.has(e.sourceId) && nodeIdSet.has(e.targetId))
.map(toEdgeConfig),
data: graph.data,
direction: graph.direction,
style: graph.style,
});
}

Expand Down Expand Up @@ -351,6 +354,132 @@ export function getReversedGraph<N, E, G>(
return config;
}),
data: graph.data,
direction: graph.direction,
style: graph.style,
});
}

// Map & filter transforms

export interface MappedGraphOptions<N, E, P, N2, E2> {
/** Map each node's `data`. All other fields and the structure are preserved. */
node?: (node: GraphNode<N, P>) => N2;
/** Map each edge's `data`. All other fields and the structure are preserved. */
edge?: (edge: GraphEdge<E>) => E2;
}

export interface FilteredGraphOptions<N, E, P> {
/** Keep only nodes passing this predicate. Incident edges of dropped nodes are removed. */
node?: (node: GraphNode<N, P>) => boolean;
/** Keep only edges passing this predicate. Endpoints are unaffected. */
edge?: (edge: GraphEdge<E>) => 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<N, E, G, P, N2 = N, E2 = E>(
graph: Graph<N, E, G, P>,
options: MappedGraphOptions<N, E, P, N2, E2>,
): Graph<N2, E2, G, P> {
return createGraph({
id: graph.id,
mode: graph.mode,
initialNodeId: graph.initialNodeId ?? undefined,
nodes: graph.nodes.map((n) => {
const config = toNodeConfig(n) as NodeConfig<unknown, P>;
if (options.node) {
const data = options.node(n);
if (data === undefined) delete config.data;
else config.data = data;
}
return config as NodeConfig<N2, P>;
}),
edges: graph.edges.map((e) => {
const config = toEdgeConfig(e) as EdgeConfig<unknown>;
if (options.edge) {
const data = options.edge(e);
if (data === undefined) delete config.data;
else config.data = data;
}
return config as EdgeConfig<E2>;
}),
data: graph.data,
direction: graph.direction,
style: graph.style,
});
Comment thread
davidkpiano marked this conversation as resolved.
}

/**
* 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<N, E, G, P>(
graph: Graph<N, E, G, P>,
options: FilteredGraphOptions<N, E, P>,
): Graph<N, E, G, P> {
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,
direction: graph.direction,
style: graph.style,
});
}

Expand Down
132 changes: 132 additions & 0 deletions tests/transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest';
import {
createGraph,
getFlattenedGraph,
getMappedGraph,
getFilteredGraph,
getShortestPaths,
getTopologicalSort,
isAcyclic,
Expand Down Expand Up @@ -532,3 +534,133 @@ 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);
});
});

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' });
});
});
Loading