From 1b0d8c3e593282095f79154b468d50641f26445d Mon Sep 17 00:00:00 2001 From: Ib Green <7025232+ibgreen@users.noreply.github.com> Date: Wed, 12 Nov 2025 07:43:46 -0500 Subject: [PATCH 1/3] refactor(graph-layer): streamline data intake --- .../api-reference/layers/graph-layer.md | 18 ++- docs/upgrade-guide.md | 4 +- docs/whats-new.md | 2 +- .../graph-data/columnar-graph-data-builder.ts | 24 +++ .../graph-layers/src/graph-data/graph-data.ts | 13 ++ .../graph-layers/src/layers/graph-layer.ts | 96 +++++++----- .../src/loaders/json-tabular-graph-loader.ts | 29 +--- .../test/layers/graph-layer-data.spec.ts | 142 ++++++++++++++++++ .../test/loaders/json-loader.spec.ts | 33 ++-- 9 files changed, 278 insertions(+), 83 deletions(-) create mode 100644 modules/graph-layers/test/layers/graph-layer-data.spec.ts diff --git a/docs/modules/graph-layers/api-reference/layers/graph-layer.md b/docs/modules/graph-layers/api-reference/layers/graph-layer.md index c1afed5d..2fa79339 100644 --- a/docs/modules/graph-layers/api-reference/layers/graph-layer.md +++ b/docs/modules/graph-layers/api-reference/layers/graph-layer.md @@ -31,11 +31,12 @@ const layer = new GraphLayer({ ``` `GraphLayer` treats the `data` prop as its single entry point. Provide a -`GraphEngine`, a [`Graph`](../graph.md), or raw graph payloads (arrays of edges -or `{nodes, edges}` objects). When the layer receives new data it rebuilds the -internal `GraphEngine`, re-runs the layout, and updates interactions -automatically. Supplying raw data requires a `layout` so the layer can derive -positions for you. +`GraphEngine`, a [`Graph`](../graph.md), pre-normalized `GraphData`, +`ColumnarGraphColumns`, `ArrowGraphData`, or raw graph +payloads (arrays of edges or `{nodes, edges}` objects). When the layer receives +new data it rebuilds the internal `GraphEngine`, re-runs the layout, and updates +interactions automatically. Supplying raw data requires a `layout` so the layer +can derive positions for you. ## Properties @@ -64,10 +65,11 @@ releases will remove this prop. #### `graphLoader` (function, optional) -Custom loader that converts raw `data` into a `Graph`. Defaults to the bundled +Custom loader that converts raw `data` into `GraphData` or +`ArrowGraphData`. Defaults to the bundled `JSONLoader`, which accepts arrays of edges or `{nodes, edges}` collections and -automatically synthesizes missing nodes. Graph instances are no longer -normalized by the loader—pass them directly to `data`. +returns `GraphData`. Graph instances are no longer normalized by the +loader—pass them directly to `data`. #### `engine` (`GraphEngine`, optional) diff --git a/docs/upgrade-guide.md b/docs/upgrade-guide.md index d29255d3..db09f672 100644 --- a/docs/upgrade-guide.md +++ b/docs/upgrade-guide.md @@ -15,6 +15,6 @@ Please refer the documentation of each module for detailed upgrade guides. - Replace `nodeStyle` / `edgeStyle` with `stylesheet.nodes` and `stylesheet.edges` - Deprecation: `graph` prop on `GraphLayer` is being phased out. Provide graphs via the `data` prop instead (supports `GraphEngine`, `Graph`, or raw `{nodes, edges}`/edge arrays) and supply a `layout` when the layer must build the engine for you. -- Breaking change: `JSONLoader` only normalizes raw JSON payloads. Pass `Graph` instances directly to `GraphLayer.data` rather than - routing them through the loader. +- Breaking change: `JSONLoader` only normalizes raw JSON payloads into `GraphData`. Pass `Graph` instances directly to + `GraphLayer.data` rather than routing them through the loader. diff --git a/docs/whats-new.md b/docs/whats-new.md index b914b4c0..655ddbea 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -28,7 +28,7 @@ Highlights: - `GraphLayerProps` - NEW `data` prop (no longer requires applications to provide `engine: GraphEngine`). - `GraphLayer` now accepts `GraphEngine`, `Graph`, or raw JSON via its `data` prop (including async URLs), automatically builds a `GraphEngine` when given raw payloads, and deprecates the legacy `graph` prop. - - `JSONLoader` normalizes edge arrays or `{nodes, edges}` objects and no longer accepts `Graph` instances directly. + - `JSONLoader` normalizes edge arrays or `{nodes, edges}` objects into `GraphData` and no longer accepts `Graph` instances directly. - `GraphLayerProps` - NEW `stylesheet` prop that accepts a unified stylesheet containing all for node, edge, and decorator styles. - `GraphStylesheet` - NEW edge decorator `'arrow'` that renders arrows on directional edges. - `GraphStylesheet` - constants can now be defined using simple string literals (no need to import `NODE_TYPE` etc). diff --git a/modules/graph-layers/src/graph-data/columnar-graph-data-builder.ts b/modules/graph-layers/src/graph-data/columnar-graph-data-builder.ts index faa564ad..5aaccd41 100644 --- a/modules/graph-layers/src/graph-data/columnar-graph-data-builder.ts +++ b/modules/graph-layers/src/graph-data/columnar-graph-data-builder.ts @@ -44,6 +44,30 @@ export interface ColumnarGraphColumns { edges: ColumnarGraphEdgeColumns; } +export function isColumnarGraphColumns(value: unknown): value is ColumnarGraphColumns { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as ColumnarGraphColumns & {type?: string}; + const {nodes, edges} = candidate; + + if (candidate.type === 'columnar-graph-data') { + return Boolean(nodes && edges); + } + + if (!nodes || !edges) { + return false; + } + + return ( + Array.isArray(nodes.id) && + Array.isArray(edges.id) && + Array.isArray(edges.sourceId) && + Array.isArray(edges.targetId) + ); +} + type MutableNodeColumns = { id: (string | number)[]; state: NodeState[]; diff --git a/modules/graph-layers/src/graph-data/graph-data.ts b/modules/graph-layers/src/graph-data/graph-data.ts index 8ec5c239..d48fa7f6 100644 --- a/modules/graph-layers/src/graph-data/graph-data.ts +++ b/modules/graph-layers/src/graph-data/graph-data.ts @@ -42,3 +42,16 @@ type GraphDataShape = { export type GraphData = GraphDataShape & { type?: 'graph-data'; }; + +export function isGraphData(value: unknown): value is GraphData { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as GraphData; + if ((candidate as {type?: string}).type === 'graph-data') { + return true; + } + + return Array.isArray(candidate.nodes) || Array.isArray(candidate.edges); +} diff --git a/modules/graph-layers/src/layers/graph-layer.ts b/modules/graph-layers/src/layers/graph-layer.ts index 429bc505..9d164257 100644 --- a/modules/graph-layers/src/layers/graph-layer.ts +++ b/modules/graph-layers/src/layers/graph-layer.ts @@ -10,9 +10,13 @@ import {PolygonLayer} from '@deck.gl/layers'; import type {Graph, NodeInterface} from '../graph/graph'; import {ClassicGraph} from '../graph/classic-graph'; +import {createGraphFromData} from '../graph/create-graph-from-data'; import {GraphLayout, type GraphLayoutEventDetail} from '../core/graph-layout'; import type {GraphRuntimeLayout} from '../core/graph-runtime-layout'; import {GraphEngine} from '../core/graph-engine'; +import {isGraphData, type GraphData} from '../graph-data/graph-data'; +import {isColumnarGraphColumns, type ColumnarGraphColumns} from '../graph-data/columnar-graph-data-builder'; +import {isArrowGraphData, type ArrowGraphData} from '../graph-data/arrow-graph-data'; import { GraphStylesheetEngine, @@ -28,7 +32,7 @@ import { type RankAccessor } from '../utils/rank-grid'; -import {warn} from '../utils/log'; +import {log, warn} from '../utils/log'; import { DEFAULT_GRAPH_LAYER_STYLESHEET, @@ -104,8 +108,6 @@ const LAYOUT_REQUIRED_MESSAGE = let NODE_STYLE_DEPRECATION_WARNED = false; let EDGE_STYLE_DEPRECATION_WARNED = false; -let GRAPH_PROP_DEPRECATION_WARNED = false; -let LAYOUT_REQUIRED_WARNED = false; export type GraphLayerRawData = { name?: string; @@ -116,11 +118,16 @@ export type GraphLayerRawData = { export type GraphLayerDataInput = | GraphEngine | Graph + | GraphData + | ColumnarGraphColumns + | ArrowGraphData | GraphLayerRawData | unknown[] | string | null; +type GraphLayerLoaderResult = Graph | GraphData | ColumnarGraphColumns | ArrowGraphData | null; + export type GraphLayerProps = CompositeLayerProps & _GraphLayerProps & { data?: GraphLayerDataInput | Promise; @@ -138,7 +145,7 @@ type EngineResolutionFlags = { export type _GraphLayerProps = { graph?: Graph; layout?: GraphLayout | GraphRuntimeLayout; - graphLoader?: (opts: {json: unknown}) => Graph | null; + graphLoader?: (opts: {json: unknown}) => GraphLayerLoaderResult; engine?: GraphEngine; onLayoutStart?: (detail?: GraphLayoutEventDetail) => void; @@ -452,7 +459,7 @@ export class GraphLayer extends CompositeLayer { return {engine: null, shouldReplace: true}; } - this._warnGraphProp(); + log.warn(GRAPH_PROP_DEPRECATION_MESSAGE)(); return { engine: this._buildEngineFromGraph(props.graph, props.layout), shouldReplace: force || graphChanged || layoutChanged @@ -462,6 +469,26 @@ export class GraphLayer extends CompositeLayer { private _deriveEngineFromData( data: GraphLayerDataInput, props: GraphLayerProps + ): GraphEngine | null | undefined { + const immediate = this._getImmediateEngineResult(data, props); + if (typeof immediate !== 'undefined') { + return immediate; + } + + if (typeof data === 'string') { + return undefined; + } + + if (!Array.isArray(data) && !this._isPlainObject(data)) { + return null; + } + + return this._loadEngineFromJsonLike(data, props); + } + + private _getImmediateEngineResult( + data: GraphLayerDataInput, + props: GraphLayerProps ): GraphEngine | null | undefined { if (data === null || typeof data === 'undefined') { return null; @@ -475,25 +502,30 @@ export class GraphLayer extends CompositeLayer { return data; } - const graphCandidate = this._coerceGraph(data); - if (graphCandidate) { - return this._buildEngineFromGraph(graphCandidate, props.layout); + const graph = this._coerceGraph(data) ?? this._createGraphFromDataValue(data); + if (graph) { + return this._buildEngineFromGraph(graph, props.layout); } - if (typeof data === 'string') { - return undefined; + return undefined; + } + + private _loadEngineFromJsonLike( + data: GraphLayerRawData | unknown[], + props: GraphLayerProps + ): GraphEngine | null { + const loader = props.graphLoader ?? GraphLayer.defaultProps.graphLoader; + const loaded = loader({json: data}); + if (!loaded) { + return null; } - if (Array.isArray(data) || this._isPlainObject(data)) { - const loader = props.graphLoader ?? JSONTabularGraphLoader; - const graph = loader({json: data}); - if (!graph) { - return null; - } - return this._buildEngineFromGraph(graph, props.layout); + const graph = this._coerceGraph(loaded) ?? this._createGraphFromDataValue(loaded); + if (!graph) { + return null; } - return null; + return this._buildEngineFromGraph(graph, props.layout); } private _buildEngineFromGraph( @@ -505,7 +537,7 @@ export class GraphLayer extends CompositeLayer { } if (!layout) { - this._warnLayoutRequired(); + log.warn(LAYOUT_REQUIRED_MESSAGE)(); return null; } @@ -518,7 +550,7 @@ export class GraphLayer extends CompositeLayer { if (legacyGraph) { return new GraphEngine({graph: legacyGraph, layout}); } - this._warnLayoutRequired(); + log.warn(LAYOUT_REQUIRED_MESSAGE)(); return null; } @@ -526,7 +558,7 @@ export class GraphLayer extends CompositeLayer { return new GraphEngine({graph, layout}); } - this._warnLayoutRequired(); + log.warn(LAYOUT_REQUIRED_MESSAGE)(); return null; } @@ -543,20 +575,6 @@ export class GraphLayer extends CompositeLayer { }); } - private _warnGraphProp() { - if (!GRAPH_PROP_DEPRECATION_WARNED) { - warn(GRAPH_PROP_DEPRECATION_MESSAGE); - GRAPH_PROP_DEPRECATION_WARNED = true; - } - } - - private _warnLayoutRequired() { - if (!LAYOUT_REQUIRED_WARNED) { - warn(LAYOUT_REQUIRED_MESSAGE); - LAYOUT_REQUIRED_WARNED = true; - } - } - private _isGraph(value: unknown): value is Graph { if (!value || typeof value !== 'object') { return false; @@ -580,6 +598,14 @@ export class GraphLayer extends CompositeLayer { return null; } + private _createGraphFromDataValue(value: unknown): Graph | null { + if (isGraphData(value) || isColumnarGraphColumns(value) || isArrowGraphData(value)) { + return createGraphFromData(value); + } + + return null; + } + private _convertToClassicGraph(graph: Graph): ClassicGraph | null { if (graph instanceof ClassicGraph) { return graph; diff --git a/modules/graph-layers/src/loaders/json-tabular-graph-loader.ts b/modules/graph-layers/src/loaders/json-tabular-graph-loader.ts index 6b1eb151..86a79697 100644 --- a/modules/graph-layers/src/loaders/json-tabular-graph-loader.ts +++ b/modules/graph-layers/src/loaders/json-tabular-graph-loader.ts @@ -3,10 +3,7 @@ // Copyright (c) vis.gl contributors import type {NodeState, EdgeState} from '../core/constants'; -import type {TabularGraph} from '../graph/tabular-graph'; -import type {GraphNodeData, GraphEdgeData} from '../graph-data/graph-data'; -import {ColumnarGraphDataBuilder} from '../graph-data/columnar-graph-data-builder'; -import {createTabularGraphFromData} from '../graph/create-tabular-graph-from-data'; +import type {GraphData, GraphNodeData, GraphEdgeData} from '../graph-data/graph-data'; import {basicNodeParser} from './node-parsers'; import {basicEdgeParser} from './edge-parsers'; import {error} from '../utils/log'; @@ -42,7 +39,7 @@ export function JSONTabularGraphLoader({ json, nodeParser = basicNodeParser, edgeParser = basicEdgeParser -}: JSONTabularGraphLoaderOptions): TabularGraph | null { +}: JSONTabularGraphLoaderOptions): GraphData | null { const nodes = json?.nodes ?? null; const edges = json?.edges ?? null; if (!Array.isArray(nodes)) { @@ -52,22 +49,12 @@ export function JSONTabularGraphLoader({ const normalizedNodes = parseNodes(nodes, nodeParser); const normalizedEdges = parseEdges(Array.isArray(edges) ? edges : [], edgeParser); - - const builder = new ColumnarGraphDataBuilder({ - nodeCapacity: normalizedNodes.length, - edgeCapacity: normalizedEdges.length, - version: json?.version - }); - - for (const node of normalizedNodes) { - builder.addNode(node); - } - - for (const edge of normalizedEdges) { - builder.addEdge(edge); - } - - return createTabularGraphFromData(builder.build()); + return { + type: 'graph-data', + version: json?.version, + nodes: normalizedNodes, + edges: normalizedEdges + }; } function parseNodes( diff --git a/modules/graph-layers/test/layers/graph-layer-data.spec.ts b/modules/graph-layers/test/layers/graph-layer-data.spec.ts new file mode 100644 index 00000000..6786e6f9 --- /dev/null +++ b/modules/graph-layers/test/layers/graph-layer-data.spec.ts @@ -0,0 +1,142 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest'; + +const windowStub = vi.hoisted(() => ({} as Record)); + +vi.mock('global', () => ({window: windowStub})); + +import {GraphLayer} from '../../src/layers/graph-layer'; +import {GraphEngine} from '../../src/core/graph-engine'; +import {SimpleLayout} from '../../src/layouts/simple-layout'; +import type {GraphData} from '../../src/graph-data/graph-data'; +import type {ArrowGraphData} from '../../src/graph-data/arrow-graph-data'; + +let originalWindow: unknown; + +beforeAll(() => { + originalWindow = (globalThis as {window?: unknown}).window; + (globalThis as {window?: unknown}).window = windowStub; +}); + +afterAll(() => { + if (typeof originalWindow === 'undefined') { + delete (globalThis as {window?: unknown}).window; + } else { + (globalThis as {window?: unknown}).window = originalWindow; + } +}); + +describe('GraphLayer data handling', () => { + it('builds a graph engine from GraphData inputs', () => { + const layout = new SimpleLayout(); + const layer = new GraphLayer({id: 'graph-data', layout, data: null} as any); + + const graphData: GraphData = { + type: 'graph-data', + version: 1, + nodes: [{type: 'graph-node-data', id: 'a'}], + edges: [] + }; + + const engine = (layer as any)._deriveEngineFromData(graphData, layer.props); + + expect(engine).toBeInstanceOf(GraphEngine); + }); + + it('builds a graph engine from ArrowGraphData inputs', () => { + const layout = new SimpleLayout(); + const layer = new GraphLayer({id: 'arrow-data', layout, data: null} as any); + const arrowData = createArrowGraphData(); + + const engine = (layer as any)._deriveEngineFromData(arrowData, layer.props); + + expect(engine).toBeInstanceOf(GraphEngine); + }); + + it('builds a graph engine when the loader resolves to GraphData', () => { + const layout = new SimpleLayout(); + const loaderGraphData: GraphData = { + type: 'graph-data', + nodes: [{type: 'graph-node-data', id: 'a'}], + edges: [{type: 'graph-edge-data', id: 'edge', sourceId: 'a', targetId: 'a'}] + }; + + const layer = new GraphLayer({ + id: 'loader-graph', + layout, + data: null, + graphLoader: () => loaderGraphData + } as any); + + const rawData = {nodes: [], edges: []}; + const engine = (layer as any)._deriveEngineFromData(rawData, layer.props); + + expect(engine).toBeInstanceOf(GraphEngine); + }); + + it('builds a graph engine when the loader resolves to ArrowGraphData', () => { + const layout = new SimpleLayout(); + const arrowData = createArrowGraphData(); + + const layer = new GraphLayer({ + id: 'loader-arrow', + layout, + data: null, + graphLoader: () => arrowData + } as any); + + const rawData = {nodes: [], edges: []}; + const engine = (layer as any)._deriveEngineFromData(rawData, layer.props); + + expect(engine).toBeInstanceOf(GraphEngine); + }); +}); + +function createArrowGraphData(): ArrowGraphData { + return { + type: 'arrow-graph-data', + version: 1, + nodes: createArrowTable({ + id: ['a'], + state: ['default'], + selectable: [true], + highlightConnectedEdges: [false], + data: [JSON.stringify({label: 'Node'})] + }), + edges: createArrowTable({ + id: ['edge'], + sourceId: ['a'], + targetId: ['a'], + directed: [false], + state: ['default'], + data: [JSON.stringify({})] + }) + }; +} + +function createArrowTable(columns: Record): any { + const vectors: Record = {}; + for (const [columnName, values] of Object.entries(columns)) { + vectors[columnName] = { + length: values.length, + get(index: number) { + return values[index]; + }, + toArray() { + return [...values]; + } + }; + } + + return { + getColumn(name: string) { + return vectors[name] ?? null; + }, + schema: { + fields: Object.keys(columns).map((name) => ({name})) + } + }; +} diff --git a/modules/graph-layers/test/loaders/json-loader.spec.ts b/modules/graph-layers/test/loaders/json-loader.spec.ts index fc0b4bcc..9b3f495e 100644 --- a/modules/graph-layers/test/loaders/json-loader.spec.ts +++ b/modules/graph-layers/test/loaders/json-loader.spec.ts @@ -22,22 +22,23 @@ describe('loaders/node-parsers', () => { describe('JSONTabularGraphLoader', () => { it('should work with default parsers', () => { - const graph = JSONTabularGraphLoader({json: SAMPLE_GRAPH1}); - expect(graph).not.toBeNull(); - if (!graph) { - throw new Error('Expected graph to be defined'); + const data = JSONTabularGraphLoader({json: SAMPLE_GRAPH1}); + expect(data).not.toBeNull(); + if (!data) { + throw new Error('Expected graph data to be defined'); } - expect(Array.from(graph.getEdges(), (e) => e.getId())).toEqual( - expect.arrayContaining(SAMPLE_GRAPH1.edges.map((e) => e.id)) - ); - expect(Array.from(graph.getNodes(), (n) => n.getId())).toEqual( + expect(data.type).toBe('graph-data'); + expect(data.nodes?.map((node) => node.id)).toEqual( expect.arrayContaining(SAMPLE_GRAPH1.nodes.map((n) => n.id)) ); + expect(data.edges?.map((edge) => edge.id)).toEqual( + expect.arrayContaining(SAMPLE_GRAPH1.edges.map((e) => e.id)) + ); }); it('should work with custom parsers', () => { - const graph = JSONTabularGraphLoader({ + const data = JSONTabularGraphLoader({ json: SAMPLE_GRAPH2, nodeParser: (node) => ({id: node.name}), edgeParser: (edge) => ({ @@ -47,17 +48,17 @@ describe('JSONTabularGraphLoader', () => { targetId: edge.target }) }); - expect(graph).not.toBeNull(); - if (!graph) { - throw new Error('Expected graph to be defined'); + expect(data).not.toBeNull(); + if (!data) { + throw new Error('Expected graph data to be defined'); } - expect(Array.from(graph.getEdges(), (n) => n.getId())).toEqual( - expect.arrayContaining(SAMPLE_GRAPH2.edges.map((e) => e.name)) - ); - expect(Array.from(graph.getNodes(), (n) => n.getId())).toEqual( + expect(data.nodes?.map((node) => node.id)).toEqual( expect.arrayContaining(SAMPLE_GRAPH2.nodes.map((n) => n.name)) ); + expect(data.edges?.map((edge) => edge.id)).toEqual( + expect.arrayContaining(SAMPLE_GRAPH2.edges.map((e) => e.name)) + ); }); }); From 9e7af8d59625f3fa633f0310da4ba4e0223d1d5b Mon Sep 17 00:00:00 2001 From: Ib Green <7025232+ibgreen@users.noreply.github.com> Date: Wed, 12 Nov 2025 08:37:16 -0500 Subject: [PATCH 2/3] refactor(graph): drop columnar graph data path --- .../api-reference/layers/graph-layer.md | 2 +- modules/graph-layers/src/core/graph-layout.ts | 1 + .../src/core/graph-runtime-layout.ts | 9 + .../graph-data/columnar-graph-data-builder.ts | 274 ------------------ .../graph-layers/src/graph/classic-graph.ts | 1 + .../src/graph/create-graph-from-data.ts | 3 +- .../graph/create-tabular-graph-from-data.ts | 201 ++++++------- modules/graph-layers/src/index.ts | 7 - .../graph-layers/src/layers/graph-layer.ts | 49 +--- .../src/loaders/json-tabular-graph-loader.ts | 43 ++- .../columnar-graph-data-builder.spec.ts | 75 ----- .../graph-data/create-graph-from-data.spec.ts | 26 -- .../test/graph-data/graph-data.spec.ts | 35 +-- 13 files changed, 140 insertions(+), 586 deletions(-) delete mode 100644 modules/graph-layers/src/graph-data/columnar-graph-data-builder.ts delete mode 100644 modules/graph-layers/test/graph-data/columnar-graph-data-builder.spec.ts diff --git a/docs/modules/graph-layers/api-reference/layers/graph-layer.md b/docs/modules/graph-layers/api-reference/layers/graph-layer.md index 2fa79339..d6582f00 100644 --- a/docs/modules/graph-layers/api-reference/layers/graph-layer.md +++ b/docs/modules/graph-layers/api-reference/layers/graph-layer.md @@ -32,7 +32,7 @@ const layer = new GraphLayer({ `GraphLayer` treats the `data` prop as its single entry point. Provide a `GraphEngine`, a [`Graph`](../graph.md), pre-normalized `GraphData`, -`ColumnarGraphColumns`, `ArrowGraphData`, or raw graph +`ArrowGraphData`, or raw graph payloads (arrays of edges or `{nodes, edges}` objects). When the layer receives new data it rebuilds the internal `GraphEngine`, re-runs the layout, and updates interactions automatically. Supplying raw data requires a `layout` so the layer diff --git a/modules/graph-layers/src/core/graph-layout.ts b/modules/graph-layers/src/core/graph-layout.ts index 6c5618b4..21d6b501 100644 --- a/modules/graph-layers/src/core/graph-layout.ts +++ b/modules/graph-layers/src/core/graph-layout.ts @@ -34,6 +34,7 @@ export const GRAPH_LAYOUT_DEFAULT_PROPS: Readonly> = export abstract class GraphLayout< PropsT extends GraphLayoutProps = GraphLayoutProps > implements GraphRuntimeLayout { + readonly type = 'graph-runtime-layout'; /** Name of the layout. */ protected readonly _name: string = 'GraphLayout'; /** Extra configuration props of the layout. */ diff --git a/modules/graph-layers/src/core/graph-runtime-layout.ts b/modules/graph-layers/src/core/graph-runtime-layout.ts index 3d774427..a3e64a24 100644 --- a/modules/graph-layers/src/core/graph-runtime-layout.ts +++ b/modules/graph-layers/src/core/graph-runtime-layout.ts @@ -8,6 +8,7 @@ import type {GraphLayoutProps, GraphLayoutState} from './graph-layout'; import type {EdgeInterface, Graph, NodeInterface} from '../graph/graph'; export interface GraphRuntimeLayout { + readonly type: 'graph-runtime-layout'; readonly version: number; readonly state: GraphLayoutState; getProps(): GraphLayoutProps; @@ -27,3 +28,11 @@ export interface GraphRuntimeLayout { } export type TabularGraphLayout = GraphRuntimeLayout; + +export function isGraphRuntimeLayout(value: unknown): value is GraphRuntimeLayout { + return Boolean( + value && + typeof value === 'object' && + (value as {type?: unknown}).type === 'graph-runtime-layout' + ); +} diff --git a/modules/graph-layers/src/graph-data/columnar-graph-data-builder.ts b/modules/graph-layers/src/graph-data/columnar-graph-data-builder.ts deleted file mode 100644 index 5aaccd41..00000000 --- a/modules/graph-layers/src/graph-data/columnar-graph-data-builder.ts +++ /dev/null @@ -1,274 +0,0 @@ -// deck.gl-community -// SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors - -import type {NodeState, EdgeState} from '../core/constants'; -import type {GraphNodeData, GraphEdgeData} from './graph-data'; -import { - cloneDataColumn, - cloneRecord, - normalizeEdgeState, - normalizeNodeState, - normalizeVersion -} from '../graph/graph-normalization'; - -export type ColumnarGraphDataBuilderOptions = { - nodeCapacity?: number; - edgeCapacity?: number; - version?: number; -}; - -export interface ColumnarGraphNodeColumns { - id: (string | number)[]; - state?: NodeState[]; - selectable?: boolean[]; - highlightConnectedEdges?: boolean[]; - data?: Record[]; - [columnName: string]: unknown; -} - -export interface ColumnarGraphEdgeColumns { - id: (string | number)[]; - sourceId: (string | number)[]; - targetId: (string | number)[]; - directed?: boolean[]; - state?: EdgeState[]; - data?: Record[]; - [columnName: string]: unknown; -} - -export interface ColumnarGraphColumns { - type?: 'columnar-graph-data'; - version?: number; - nodes: ColumnarGraphNodeColumns; - edges: ColumnarGraphEdgeColumns; -} - -export function isColumnarGraphColumns(value: unknown): value is ColumnarGraphColumns { - if (!value || typeof value !== 'object') { - return false; - } - - const candidate = value as ColumnarGraphColumns & {type?: string}; - const {nodes, edges} = candidate; - - if (candidate.type === 'columnar-graph-data') { - return Boolean(nodes && edges); - } - - if (!nodes || !edges) { - return false; - } - - return ( - Array.isArray(nodes.id) && - Array.isArray(edges.id) && - Array.isArray(edges.sourceId) && - Array.isArray(edges.targetId) - ); -} - -type MutableNodeColumns = { - id: (string | number)[]; - state: NodeState[]; - selectable: boolean[]; - highlightConnectedEdges: boolean[]; - data: Record[]; -}; - -type MutableEdgeColumns = { - id: (string | number)[]; - sourceId: (string | number)[]; - targetId: (string | number)[]; - directed: boolean[]; - state: EdgeState[]; - data: Record[]; -}; - -export class ColumnarGraphDataBuilder { - private nodeColumns: MutableNodeColumns; - private edgeColumns: MutableEdgeColumns; - - private nodeCapacity: number; - private edgeCapacity: number; - - private nodeLength = 0; - private edgeLength = 0; - - private _version: number; - - constructor(options: ColumnarGraphDataBuilderOptions = {}) { - this.nodeCapacity = Math.max(0, options.nodeCapacity ?? 0); - this.edgeCapacity = Math.max(0, options.edgeCapacity ?? 0); - this.nodeColumns = createMutableNodeColumns(this.nodeCapacity); - this.edgeColumns = createMutableEdgeColumns(this.edgeCapacity); - this._version = normalizeVersion(options.version); - } - - get version(): number { - return this._version; - } - - setVersion(version: unknown): void { - this._version = normalizeVersion(version); - } - - get nodeCount(): number { - return this.nodeLength; - } - - get edgeCount(): number { - return this.edgeLength; - } - - addNode(node: GraphNodeData): number { - if (typeof node?.id === 'undefined') { - throw new Error('Graph node requires an "id" field.'); - } - - this.ensureNodeCapacity(this.nodeLength + 1); - - const index = this.nodeLength++; - const attributes = cloneRecord(node.attributes); - - if (typeof node.label !== 'undefined') { - attributes.label = node.label; - } - - if (typeof node.weight !== 'undefined') { - attributes.weight = node.weight; - } - - const stateCandidate = node.state ?? (attributes.state as NodeState | undefined); - const selectableCandidate = node.selectable ?? (attributes.selectable as boolean | undefined); - const highlightCandidate = - node.highlightConnectedEdges ?? (attributes.highlightConnectedEdges as boolean | undefined); - - this.nodeColumns.id[index] = node.id; - this.nodeColumns.state[index] = normalizeNodeState(stateCandidate); - this.nodeColumns.selectable[index] = Boolean(selectableCandidate); - this.nodeColumns.highlightConnectedEdges[index] = Boolean(highlightCandidate); - this.nodeColumns.data[index] = attributes; - - return index; - } - - addEdge(edge: GraphEdgeData): number { - if ( - typeof edge?.id === 'undefined' || - typeof edge?.sourceId === 'undefined' || - typeof edge?.targetId === 'undefined' - ) { - throw new Error('Graph edge requires "id", "sourceId", and "targetId" fields.'); - } - - this.ensureEdgeCapacity(this.edgeLength + 1); - - const index = this.edgeLength++; - const attributes = cloneRecord(edge.attributes); - - if (typeof edge.label !== 'undefined') { - attributes.label = edge.label; - } - - if (typeof edge.weight !== 'undefined') { - attributes.weight = edge.weight; - } - - const stateCandidate = edge.state ?? (attributes.state as EdgeState | undefined); - const directedCandidate = edge.directed ?? (attributes.directed as boolean | undefined); - - this.edgeColumns.id[index] = edge.id; - this.edgeColumns.sourceId[index] = edge.sourceId; - this.edgeColumns.targetId[index] = edge.targetId; - this.edgeColumns.directed[index] = Boolean(directedCandidate); - this.edgeColumns.state[index] = normalizeEdgeState(stateCandidate); - this.edgeColumns.data[index] = attributes; - - return index; - } - - build(): ColumnarGraphColumns { - return { - type: 'columnar-graph-data', - version: this._version, - nodes: { - id: this.nodeColumns.id.slice(0, this.nodeLength), - state: this.nodeColumns.state.slice(0, this.nodeLength), - selectable: this.nodeColumns.selectable.slice(0, this.nodeLength), - highlightConnectedEdges: this.nodeColumns.highlightConnectedEdges.slice(0, this.nodeLength), - data: cloneDataColumn(this.nodeColumns.data, this.nodeLength) - }, - edges: { - id: this.edgeColumns.id.slice(0, this.edgeLength), - sourceId: this.edgeColumns.sourceId.slice(0, this.edgeLength), - targetId: this.edgeColumns.targetId.slice(0, this.edgeLength), - directed: this.edgeColumns.directed.slice(0, this.edgeLength), - state: this.edgeColumns.state.slice(0, this.edgeLength), - data: cloneDataColumn(this.edgeColumns.data, this.edgeLength) - } - }; - } - - private ensureNodeCapacity(minCapacity: number): void { - if (this.nodeCapacity >= minCapacity) { - return; - } - - const nextCapacity = Math.max(8, this.nodeCapacity * 2, minCapacity); - const nextColumns = createMutableNodeColumns(nextCapacity); - - for (let i = 0; i < this.nodeLength; i++) { - nextColumns.id[i] = this.nodeColumns.id[i]; - nextColumns.state[i] = this.nodeColumns.state[i]; - nextColumns.selectable[i] = this.nodeColumns.selectable[i]; - nextColumns.highlightConnectedEdges[i] = this.nodeColumns.highlightConnectedEdges[i]; - nextColumns.data[i] = this.nodeColumns.data[i]; - } - - this.nodeColumns = nextColumns; - this.nodeCapacity = nextCapacity; - } - - private ensureEdgeCapacity(minCapacity: number): void { - if (this.edgeCapacity >= minCapacity) { - return; - } - - const nextCapacity = Math.max(8, this.edgeCapacity * 2, minCapacity); - const nextColumns = createMutableEdgeColumns(nextCapacity); - - for (let i = 0; i < this.edgeLength; i++) { - nextColumns.id[i] = this.edgeColumns.id[i]; - nextColumns.sourceId[i] = this.edgeColumns.sourceId[i]; - nextColumns.targetId[i] = this.edgeColumns.targetId[i]; - nextColumns.directed[i] = this.edgeColumns.directed[i]; - nextColumns.state[i] = this.edgeColumns.state[i]; - nextColumns.data[i] = this.edgeColumns.data[i]; - } - - this.edgeColumns = nextColumns; - this.edgeCapacity = nextCapacity; - } -} - -function createMutableNodeColumns(capacity: number): MutableNodeColumns { - return { - id: new Array(capacity), - state: new Array(capacity), - selectable: new Array(capacity), - highlightConnectedEdges: new Array(capacity), - data: new Array>(capacity) - }; -} - -function createMutableEdgeColumns(capacity: number): MutableEdgeColumns { - return { - id: new Array(capacity), - sourceId: new Array(capacity), - targetId: new Array(capacity), - directed: new Array(capacity), - state: new Array(capacity), - data: new Array>(capacity) - }; -} diff --git a/modules/graph-layers/src/graph/classic-graph.ts b/modules/graph-layers/src/graph/classic-graph.ts index 9dbc5c43..2cd5fdb7 100644 --- a/modules/graph-layers/src/graph/classic-graph.ts +++ b/modules/graph-layers/src/graph/classic-graph.ts @@ -377,6 +377,7 @@ export class ClassicGraph extends Graph { } export class ClassicGraphLayoutAdapter implements GraphRuntimeLayout { + readonly type = 'graph-runtime-layout'; private readonly layout: GraphLayout; constructor(layout: GraphLayout) { diff --git a/modules/graph-layers/src/graph/create-graph-from-data.ts b/modules/graph-layers/src/graph/create-graph-from-data.ts index 43c09b1b..6c5d1dac 100644 --- a/modules/graph-layers/src/graph/create-graph-from-data.ts +++ b/modules/graph-layers/src/graph/create-graph-from-data.ts @@ -4,12 +4,11 @@ import type {Graph, GraphProps} from './graph'; import type {GraphData} from '../graph-data/graph-data'; -import type {ColumnarGraphColumns} from '../graph-data/columnar-graph-data-builder'; import {type ArrowGraphData, isArrowGraphData} from '../graph-data/arrow-graph-data'; import {ArrowGraph} from './arrow-graph'; import {createTabularGraphFromData} from './create-tabular-graph-from-data'; -export function createGraphFromData(data: GraphData | ColumnarGraphColumns | ArrowGraphData, props: GraphProps = {}): Graph { +export function createGraphFromData(data: GraphData | ArrowGraphData, props: GraphProps = {}): Graph { if (isArrowGraphData(data)) { return new ArrowGraph(data, props); } diff --git a/modules/graph-layers/src/graph/create-tabular-graph-from-data.ts b/modules/graph-layers/src/graph/create-tabular-graph-from-data.ts index 84493da5..6b196e41 100644 --- a/modules/graph-layers/src/graph/create-tabular-graph-from-data.ts +++ b/modules/graph-layers/src/graph/create-tabular-graph-from-data.ts @@ -3,77 +3,15 @@ // Copyright (c) vis.gl contributors import type {NodeState, EdgeState} from '../core/constants'; -import type {GraphData} from '../graph-data/graph-data'; -import type { - ColumnarGraphColumns, - ColumnarGraphNodeColumns, - ColumnarGraphEdgeColumns -} from '../graph-data/columnar-graph-data-builder'; -import {ColumnarGraphDataBuilder} from '../graph-data/columnar-graph-data-builder'; -import { - normalizeBooleanColumn, - normalizeDataColumn, - normalizeEdgeStateColumn, - normalizeNodeStateColumn, - normalizeVersion -} from './graph-normalization'; -import type { - TabularGraphSource, - TabularNodeAccessors, - TabularEdgeAccessors -} from './tabular-graph'; +import type {GraphData, GraphEdgeData, GraphNodeData} from '../graph-data/graph-data'; +import {cloneRecord, normalizeEdgeState, normalizeNodeState, normalizeVersion} from './graph-normalization'; +import type {TabularGraphSource, TabularNodeAccessors, TabularEdgeAccessors} from './tabular-graph'; import {TabularGraph} from './tabular-graph'; -export function createTabularGraphFromData(data: GraphData | ColumnarGraphColumns): TabularGraph { - if (isColumnarGraphColumns(data)) { - return createTabularGraphFromColumnarData(data); - } - - const builder = new ColumnarGraphDataBuilder({ - nodeCapacity: Array.isArray(data.nodes) ? data.nodes.length : 0, - edgeCapacity: Array.isArray(data.edges) ? data.edges.length : 0, - version: data.version - }); - - if (Array.isArray(data.nodes)) { - for (const node of data.nodes) { - builder.addNode(node); - } - } - - if (Array.isArray(data.edges)) { - for (const edge of data.edges) { - builder.addEdge(edge); - } - } - - return createTabularGraphFromColumnarData(builder.build()); -} - -type NodeHandle = number; -type EdgeHandle = number; - -type NormalizedNodeColumns = { - id: (string | number)[]; - state: NodeState[]; - selectable: boolean[]; - highlightConnectedEdges: boolean[]; - data: Record[]; -}; - -type NormalizedEdgeColumns = { - id: (string | number)[]; - sourceId: (string | number)[]; - targetId: (string | number)[]; - directed: boolean[]; - state: EdgeState[]; - data: Record[]; -}; - -function createTabularGraphFromColumnarData(data: ColumnarGraphColumns): TabularGraph { +export function createTabularGraphFromData(data: GraphData): TabularGraph { const version = normalizeVersion(data.version); - const nodes = normalizeNodeColumns(data.nodes); - const edges = normalizeEdgeColumns(data.edges); + const nodes = normalizeNodes(Array.isArray(data.nodes) ? data.nodes : []); + const edges = normalizeEdges(Array.isArray(data.edges) ? data.edges : []); const nodeCount = nodes.id.length; const edgeCount = edges.id.length; @@ -140,61 +78,102 @@ function createTabularGraphFromColumnarData(data: ColumnarGraphColumns): Tabular return new TabularGraph(source); } -function isColumnarGraphColumns(value: GraphData | ColumnarGraphColumns): value is ColumnarGraphColumns { - if (!value || typeof value !== 'object') { - return false; - } +type NodeHandle = number; +type EdgeHandle = number; - const typed = value as {type?: string | undefined}; - if (typed.type === 'graph-data') { - return false; - } - if (typed.type === 'columnar-graph-data') { - return true; - } +type NormalizedNodeColumns = { + id: (string | number)[]; + state: NodeState[]; + selectable: boolean[]; + highlightConnectedEdges: boolean[]; + data: Record[]; +}; + +type NormalizedEdgeColumns = { + id: (string | number)[]; + sourceId: (string | number)[]; + targetId: (string | number)[]; + directed: boolean[]; + state: EdgeState[]; + data: Record[]; +}; + +function normalizeNodes(nodes: GraphNodeData[]): NormalizedNodeColumns { + const normalized: NormalizedNodeColumns = { + id: [], + state: [], + selectable: [], + highlightConnectedEdges: [], + data: [] + }; + + for (const node of nodes) { + if (typeof node?.id === 'undefined') { + throw new Error('Graph node requires an "id" field.'); + } + + const attributes = cloneRecord(node.attributes); + if (typeof node.label !== 'undefined') { + attributes.label = node.label; + } + if (typeof node.weight !== 'undefined') { + attributes.weight = node.weight; + } + + const stateCandidate = node.state ?? (attributes.state as NodeState | undefined); + const selectableCandidate = node.selectable ?? (attributes.selectable as boolean | undefined); + const highlightCandidate = + node.highlightConnectedEdges ?? (attributes.highlightConnectedEdges as boolean | undefined); - const maybeGraphData = value as GraphData; - if (Array.isArray(maybeGraphData.nodes) || Array.isArray(maybeGraphData.edges)) { - return false; + normalized.id.push(node.id); + normalized.state.push(normalizeNodeState(stateCandidate)); + normalized.selectable.push(Boolean(selectableCandidate)); + normalized.highlightConnectedEdges.push(Boolean(highlightCandidate)); + normalized.data.push(attributes); } - const maybeColumnar = value as ColumnarGraphColumns; - return Array.isArray(maybeColumnar.nodes?.id) && Array.isArray(maybeColumnar.edges?.id); + return normalized; } -function normalizeNodeColumns(columns: ColumnarGraphNodeColumns): NormalizedNodeColumns { - const id = Array.isArray(columns.id) ? columns.id.slice() : []; - const length = id.length; - - return { - id, - state: normalizeNodeStateColumn(columns.state, length), - selectable: normalizeBooleanColumn(columns.selectable, length, false), - highlightConnectedEdges: normalizeBooleanColumn(columns.highlightConnectedEdges, length, false), - data: normalizeDataColumn(columns.data, length) +function normalizeEdges(edges: GraphEdgeData[]): NormalizedEdgeColumns { + const normalized: NormalizedEdgeColumns = { + id: [], + sourceId: [], + targetId: [], + directed: [], + state: [], + data: [] }; -} -function normalizeEdgeColumns(columns: ColumnarGraphEdgeColumns): NormalizedEdgeColumns { - const id = Array.isArray(columns.id) ? columns.id.slice() : []; - const length = id.length; + for (const edge of edges) { + if ( + typeof edge?.id === 'undefined' || + typeof edge?.sourceId === 'undefined' || + typeof edge?.targetId === 'undefined' + ) { + throw new Error('Graph edge requires "id", "sourceId", and "targetId" fields.'); + } - if (!Array.isArray(columns.sourceId) || columns.sourceId.length !== length) { - throw new Error('Columnar graph edge data requires a sourceId column matching the id column length.'); - } + const attributes = cloneRecord(edge.attributes); + if (typeof edge.label !== 'undefined') { + attributes.label = edge.label; + } + if (typeof edge.weight !== 'undefined') { + attributes.weight = edge.weight; + } - if (!Array.isArray(columns.targetId) || columns.targetId.length !== length) { - throw new Error('Columnar graph edge data requires a targetId column matching the id column length.'); + const stateCandidate = edge.state ?? (attributes.state as EdgeState | undefined); + const directedCandidate = edge.directed ?? (attributes.directed as boolean | undefined); + + normalized.id.push(edge.id); + normalized.sourceId.push(edge.sourceId); + normalized.targetId.push(edge.targetId); + normalized.directed.push(Boolean(directedCandidate)); + normalized.state.push(normalizeEdgeState(stateCandidate)); + normalized.data.push(attributes); } - return { - id, - sourceId: columns.sourceId.slice(0, length), - targetId: columns.targetId.slice(0, length), - directed: normalizeBooleanColumn(columns.directed, length, false), - state: normalizeEdgeStateColumn(columns.state, length), - data: normalizeDataColumn(columns.data, length) - }; + return normalized; } function createIndexArray(length: number): number[] { diff --git a/modules/graph-layers/src/index.ts b/modules/graph-layers/src/index.ts index 7221fa26..f00e3996 100644 --- a/modules/graph-layers/src/index.ts +++ b/modules/graph-layers/src/index.ts @@ -26,13 +26,6 @@ export { export {createTabularGraphFromData} from './graph/create-tabular-graph-from-data'; export {createGraphFromData} from './graph/create-graph-from-data'; export {GraphDataBuilder, type GraphDataBuilderOptions} from './graph-data/graph-data-builder'; -export { - ColumnarGraphDataBuilder, - type ColumnarGraphColumns, - type ColumnarGraphNodeColumns, - type ColumnarGraphEdgeColumns, - type ColumnarGraphDataBuilderOptions -} from './graph-data/columnar-graph-data-builder'; export { ArrowGraphDataBuilder, type ArrowGraphDataBuilderOptions diff --git a/modules/graph-layers/src/layers/graph-layer.ts b/modules/graph-layers/src/layers/graph-layer.ts index 9d164257..b84f7304 100644 --- a/modules/graph-layers/src/layers/graph-layer.ts +++ b/modules/graph-layers/src/layers/graph-layer.ts @@ -15,7 +15,6 @@ import {GraphLayout, type GraphLayoutEventDetail} from '../core/graph-layout'; import type {GraphRuntimeLayout} from '../core/graph-runtime-layout'; import {GraphEngine} from '../core/graph-engine'; import {isGraphData, type GraphData} from '../graph-data/graph-data'; -import {isColumnarGraphColumns, type ColumnarGraphColumns} from '../graph-data/columnar-graph-data-builder'; import {isArrowGraphData, type ArrowGraphData} from '../graph-data/arrow-graph-data'; import { @@ -61,6 +60,7 @@ import {EdgeAttachmentHelper} from './edge-attachment-helper'; import {GridLayer, type GridLayerProps} from './common-layers/grid-layer/grid-layer'; import {JSONTabularGraphLoader} from '../loaders/json-loader'; +import {isGraphRuntimeLayout} from '../core/graph-runtime-layout'; const NODE_LAYER_MAP = { 'rectangle': RectangleLayer, @@ -119,14 +119,13 @@ export type GraphLayerDataInput = | GraphEngine | Graph | GraphData - | ColumnarGraphColumns | ArrowGraphData | GraphLayerRawData | unknown[] | string | null; -type GraphLayerLoaderResult = Graph | GraphData | ColumnarGraphColumns | ArrowGraphData | null; +type GraphLayerLoaderResult = Graph | GraphData | ArrowGraphData | null; export type GraphLayerProps = CompositeLayerProps & _GraphLayerProps & { @@ -479,11 +478,11 @@ export class GraphLayer extends CompositeLayer { return undefined; } - if (!Array.isArray(data) && !this._isPlainObject(data)) { - return null; + if (Array.isArray(data) || this._isPlainObject(data)) { + return this._loadEngineFromJsonLike(data as unknown[] | GraphLayerRawData, props); } - return this._loadEngineFromJsonLike(data, props); + return null; } private _getImmediateEngineResult( @@ -494,19 +493,19 @@ export class GraphLayer extends CompositeLayer { return null; } - if (typeof (data as PromiseLike)?.then === 'function') { - return undefined; - } - if (data instanceof GraphEngine) { return data; } - const graph = this._coerceGraph(data) ?? this._createGraphFromDataValue(data); + const graph = this._coerceGraph(data); if (graph) { return this._buildEngineFromGraph(graph, props.layout); } + if (isGraphData(data) || isArrowGraphData(data)) { + return this._buildEngineFromGraph(createGraphFromData(data), props.layout); + } + return undefined; } @@ -520,7 +519,9 @@ export class GraphLayer extends CompositeLayer { return null; } - const graph = this._coerceGraph(loaded) ?? this._createGraphFromDataValue(loaded); + const graph = + this._coerceGraph(loaded) || + (isGraphData(loaded) || isArrowGraphData(loaded) ? createGraphFromData(loaded) : null); if (!graph) { return null; } @@ -554,7 +555,7 @@ export class GraphLayer extends CompositeLayer { return null; } - if (this._isGraphRuntimeLayout(layout)) { + if (isGraphRuntimeLayout(layout)) { return new GraphEngine({graph, layout}); } @@ -598,14 +599,6 @@ export class GraphLayer extends CompositeLayer { return null; } - private _createGraphFromDataValue(value: unknown): Graph | null { - if (isGraphData(value) || isColumnarGraphColumns(value) || isArrowGraphData(value)) { - return createGraphFromData(value); - } - - return null; - } - private _convertToClassicGraph(graph: Graph): ClassicGraph | null { if (graph instanceof ClassicGraph) { return graph; @@ -623,20 +616,6 @@ export class GraphLayer extends CompositeLayer { return null; } - private _isGraphRuntimeLayout(value: unknown): value is GraphRuntimeLayout { - if (!value || typeof value !== 'object') { - return false; - } - - const layout = value as GraphRuntimeLayout; - return ( - typeof layout.initializeGraph === 'function' && - typeof layout.getNodePosition === 'function' && - typeof layout.getEdgePosition === 'function' && - typeof layout.setProps === 'function' - ); - } - private _isPlainObject(value: unknown): value is Record { if (!value || typeof value !== 'object') { return false; diff --git a/modules/graph-layers/src/loaders/json-tabular-graph-loader.ts b/modules/graph-layers/src/loaders/json-tabular-graph-loader.ts index 86a79697..276019be 100644 --- a/modules/graph-layers/src/loaders/json-tabular-graph-loader.ts +++ b/modules/graph-layers/src/loaders/json-tabular-graph-loader.ts @@ -4,6 +4,7 @@ import type {NodeState, EdgeState} from '../core/constants'; import type {GraphData, GraphNodeData, GraphEdgeData} from '../graph-data/graph-data'; +import {GraphDataBuilder} from '../graph-data/graph-data-builder'; import {basicNodeParser} from './node-parsers'; import {basicEdgeParser} from './edge-parsers'; import {error} from '../utils/log'; @@ -16,6 +17,7 @@ type GraphJSON = { export type JSONTabularGraphLoaderOptions = { json: GraphJSON; + builder?: GraphDataBuilder; nodeParser?: (node: any) => { id: string | number; state?: NodeState; @@ -37,6 +39,7 @@ export type JSONTabularGraphLoaderOptions = { export function JSONTabularGraphLoader({ json, + builder, nodeParser = basicNodeParser, edgeParser = basicEdgeParser }: JSONTabularGraphLoaderOptions): GraphData | null { @@ -47,22 +50,21 @@ export function JSONTabularGraphLoader({ return null; } - const normalizedNodes = parseNodes(nodes, nodeParser); - const normalizedEdges = parseEdges(Array.isArray(edges) ? edges : [], edgeParser); - return { - type: 'graph-data', - version: json?.version, - nodes: normalizedNodes, - edges: normalizedEdges - }; + const graphBuilder = builder ?? new GraphDataBuilder(); + graphBuilder.setVersion(json?.version); + + parseNodes(nodes, nodeParser, graphBuilder); + parseEdges(Array.isArray(edges) ? edges : [], edgeParser, graphBuilder); + + return graphBuilder.build(); } function parseNodes( nodes: unknown[], - nodeParser: JSONTabularGraphLoaderOptions['nodeParser'] -): GraphNodeData[] { - const parsedNodes: GraphNodeData[] = []; - + nodeParser: JSONTabularGraphLoaderOptions['nodeParser'], + builder: GraphDataBuilder +): void { + for (const node of nodes) { const parsed = nodeParser?.(node); if (parsed && typeof parsed.id !== 'undefined') { @@ -78,18 +80,16 @@ function parseNodes( weight: parsed.weight ?? (attributes.weight as number | undefined), attributes }; - parsedNodes.push(nodeRecord); + builder.addNode(nodeRecord); } } - - return parsedNodes; } function parseEdges( edges: unknown[], - edgeParser: JSONTabularGraphLoaderOptions['edgeParser'] -): GraphEdgeData[] { - const handles: GraphEdgeData[] = []; + edgeParser: JSONTabularGraphLoaderOptions['edgeParser'], + builder: GraphDataBuilder +): void { for (const edge of edges) { const parsed = edgeParser?.(edge); @@ -99,7 +99,7 @@ function parseEdges( typeof parsed.targetId !== 'undefined' ) { const attributes = cloneRecord(edge); - handles.push({ + const edgeRecord: GraphEdgeData = { type: 'graph-edge-data', id: parsed.id, directed: parsed.directed ?? (attributes.directed as boolean | undefined), @@ -109,11 +109,10 @@ function parseEdges( label: parsed.label ?? (attributes.label as string | undefined), weight: parsed.weight ?? (attributes.weight as number | undefined), attributes - }); + }; + builder.addEdge(edgeRecord); } } - - return handles; } function cloneRecord(value: unknown): Record { diff --git a/modules/graph-layers/test/graph-data/columnar-graph-data-builder.spec.ts b/modules/graph-layers/test/graph-data/columnar-graph-data-builder.spec.ts deleted file mode 100644 index dab09ba0..00000000 --- a/modules/graph-layers/test/graph-data/columnar-graph-data-builder.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -// deck.gl-community -// SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors - -import {describe, it, expect} from 'vitest'; - -import {ColumnarGraphDataBuilder} from '../../src/graph-data/columnar-graph-data-builder'; -import type {GraphData} from '../../src/graph-data/graph-data'; - -const SAMPLE_GRAPH_DATA: GraphData = { - type: 'graph-data', - version: 3, - nodes: [ - { - type: 'graph-node-data', - id: 'a', - label: 'Node A', - state: 'hover', - selectable: true, - highlightConnectedEdges: true, - weight: 1, - attributes: {category: 'alpha'} - }, - {type: 'graph-node-data', id: 'b', attributes: {label: 'Node B'}} - ], - edges: [ - { - type: 'graph-edge-data', - id: 'a-b', - sourceId: 'a', - targetId: 'b', - directed: true, - state: 'selected', - label: 'forward', - weight: 2, - attributes: {color: 'red'} - }, - { - type: 'graph-edge-data', - id: 'b-a', - sourceId: 'b', - targetId: 'a', - attributes: {directed: true, label: 'reverse'} - } - ] -}; - -describe('ColumnarGraphDataBuilder', () => { - it('builds columnar graph tables', () => { - const builder = new ColumnarGraphDataBuilder({nodeCapacity: 1, edgeCapacity: 1}); - builder.setVersion(SAMPLE_GRAPH_DATA.version); - - for (const node of SAMPLE_GRAPH_DATA.nodes ?? []) { - builder.addNode(node); - } - - for (const edge of SAMPLE_GRAPH_DATA.edges ?? []) { - builder.addEdge(edge); - } - - const columnar = builder.build(); - - expect(columnar.version).toBe(3); - expect(columnar.type).toBe('columnar-graph-data'); - expect(columnar.nodes.id).toEqual(['a', 'b']); - expect(columnar.edges.sourceId).toEqual(['a', 'b']); - const nodeData = columnar.nodes.data ?? []; - const edgeData = columnar.edges.data ?? []; - - expect(nodeData).toHaveLength(2); - expect(edgeData).toHaveLength(2); - expect(nodeData.map((record) => record.label)).toEqual(['Node A', 'Node B']); - expect(edgeData.map((record) => record.label)).toEqual(['forward', 'reverse']); - }); -}); diff --git a/modules/graph-layers/test/graph-data/create-graph-from-data.spec.ts b/modules/graph-layers/test/graph-data/create-graph-from-data.spec.ts index 23345bc0..22a8fb56 100644 --- a/modules/graph-layers/test/graph-data/create-graph-from-data.spec.ts +++ b/modules/graph-layers/test/graph-data/create-graph-from-data.spec.ts @@ -8,7 +8,6 @@ import {createGraphFromData} from '../../src/graph/create-graph-from-data'; import {TabularGraph} from '../../src/graph/tabular-graph'; import {ArrowGraph} from '../../src/graph/arrow-graph'; import type {GraphData} from '../../src/graph-data/graph-data'; -import type {ColumnarGraphColumns} from '../../src/graph-data/columnar-graph-data-builder'; import type {ArrowGraphData} from '../../src/graph-data/arrow-graph-data'; describe('createGraphFromData', () => { @@ -19,11 +18,6 @@ describe('createGraphFromData', () => { expect(graph.props.onNodeAdded).toBe(onNodeAdded); }); - it('creates a TabularGraph from columnar graph columns', () => { - const graph = createGraphFromData(COLUMNAR_GRAPH_DATA); - expect(graph).toBeInstanceOf(TabularGraph); - }); - it('creates an ArrowGraph from ArrowGraphData', () => { const onEdgeAdded = vi.fn(); const graph = createGraphFromData(createArrowGraphData(), {onEdgeAdded}); @@ -44,26 +38,6 @@ const ROW_GRAPH_DATA: GraphData = { ] }; -const COLUMNAR_GRAPH_DATA: ColumnarGraphColumns = { - type: 'columnar-graph-data', - version: 2, - nodes: { - id: ['a', 'b'], - state: ['default', 'hover'], - selectable: [true, false], - highlightConnectedEdges: [false, false], - data: [{}, {}] - }, - edges: { - id: ['edge'], - sourceId: ['a'], - targetId: ['b'], - directed: [true], - state: ['default'], - data: [{}] - } -}; - function createArrowGraphData(): ArrowGraphData { return { type: 'arrow-graph-data', diff --git a/modules/graph-layers/test/graph-data/graph-data.spec.ts b/modules/graph-layers/test/graph-data/graph-data.spec.ts index 08b02df7..741da475 100644 --- a/modules/graph-layers/test/graph-data/graph-data.spec.ts +++ b/modules/graph-layers/test/graph-data/graph-data.spec.ts @@ -4,10 +4,6 @@ import {describe, it, expect} from 'vitest'; -import { - ColumnarGraphDataBuilder, - type ColumnarGraphColumns -} from '../../src/graph-data/columnar-graph-data-builder'; import type {GraphData} from '../../src/graph-data/graph-data'; import {createTabularGraphFromData} from '../../src/graph/create-tabular-graph-from-data'; @@ -63,11 +59,8 @@ const SAMPLE_GRAPH_DATA: GraphData = { }; describe('createTabularGraphFromData', () => { - it('loads columnar graph data into a TabularGraph', () => { - const columnar = buildColumnarGraph(SAMPLE_GRAPH_DATA); - expect(columnar.type).toBe('columnar-graph-data'); - - const graph = createTabularGraphFromData(columnar); + it('normalizes GraphData into a TabularGraph', () => { + const graph = createTabularGraphFromData(SAMPLE_GRAPH_DATA); const nodes = Array.from(graph.getNodes()); const edges = Array.from(graph.getEdges()); @@ -139,28 +132,4 @@ describe('createTabularGraphFromData', () => { expect(graph.findNodeById?.('a')?.getId()).toBe('a'); }); - it('converts row-oriented GraphData inputs', () => { - const graph = createTabularGraphFromData(SAMPLE_GRAPH_DATA); - - expect(Array.from(graph.getNodes(), (node) => node.getId())).toEqual(['a', 'b']); - expect(Array.from(graph.getEdges(), (edge) => edge.getId())).toEqual(['a-b', 'b-a']); - }); }); - -function buildColumnarGraph(data: GraphData): ColumnarGraphColumns { - const builder = new ColumnarGraphDataBuilder({ - nodeCapacity: data.nodes?.length ?? 0, - edgeCapacity: data.edges?.length ?? 0, - version: data.version - }); - - for (const node of data.nodes ?? []) { - builder.addNode(node); - } - - for (const edge of data.edges ?? []) { - builder.addEdge(edge); - } - - return builder.build(); -} From 514a558a00b68196a36234534109a23b1fc20ac0 Mon Sep 17 00:00:00 2001 From: Ib Green <7025232+ibgreen@users.noreply.github.com> Date: Wed, 12 Nov 2025 09:26:05 -0500 Subject: [PATCH 3/3] fix(graph): allow arrow conversion from graph data --- .../convert-tabular-graph-to-arrow-graph.ts | 25 +++++++- ...nvert-tabular-graph-to-arrow-graph.spec.ts | 57 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/modules/graph-layers/src/graph/convert-tabular-graph-to-arrow-graph.ts b/modules/graph-layers/src/graph/convert-tabular-graph-to-arrow-graph.ts index fb875feb..06e05b87 100644 --- a/modules/graph-layers/src/graph/convert-tabular-graph-to-arrow-graph.ts +++ b/modules/graph-layers/src/graph/convert-tabular-graph-to-arrow-graph.ts @@ -5,6 +5,9 @@ import {ArrowGraph} from './arrow-graph'; import type {ArrowGraphDataBuilderOptions} from '../graph-data/arrow-graph-data-builder'; import {ArrowGraphDataBuilder} from '../graph-data/arrow-graph-data-builder'; +import type {GraphData} from '../graph-data/graph-data'; +import {isGraphData} from '../graph-data/graph-data'; +import {createTabularGraphFromData} from './create-tabular-graph-from-data'; import type {TabularGraph, TabularNode, TabularEdge} from './tabular-graph'; const NODE_ATTRIBUTE_KEYS_TO_REMOVE = [ @@ -41,9 +44,11 @@ type SanitizedEdgeData = { export type ConvertTabularGraphToArrowGraphOptions = ArrowGraphDataBuilderOptions; export function convertTabularGraphToArrowGraph( - tabularGraph: TabularGraph, + source: TabularGraph | GraphData, options?: ConvertTabularGraphToArrowGraphOptions ): ArrowGraph { + const {tabularGraph, shouldDestroy} = resolveTabularGraph(source); + const builder = new ArrowGraphDataBuilder({ ...options, version: options?.version ?? tabularGraph.version @@ -82,7 +87,23 @@ export function convertTabularGraphToArrowGraph( }); } - return new ArrowGraph(builder.finish(), tabularGraph.props); + try { + return new ArrowGraph(builder.finish(), tabularGraph.props); + } finally { + if (shouldDestroy) { + tabularGraph.destroy?.(); + } + } +} + +function resolveTabularGraph( + source: TabularGraph | GraphData +): {tabularGraph: TabularGraph; shouldDestroy: boolean} { + if (isGraphData(source)) { + return {tabularGraph: createTabularGraphFromData(source), shouldDestroy: true}; + } + + return {tabularGraph: source, shouldDestroy: false}; } function sanitizeNodeData(data: Record): SanitizedNodeData { diff --git a/modules/graph-layers/test/graph/convert-tabular-graph-to-arrow-graph.spec.ts b/modules/graph-layers/test/graph/convert-tabular-graph-to-arrow-graph.spec.ts index 5a50636e..a76570ab 100644 --- a/modules/graph-layers/test/graph/convert-tabular-graph-to-arrow-graph.spec.ts +++ b/modules/graph-layers/test/graph/convert-tabular-graph-to-arrow-graph.spec.ts @@ -8,6 +8,7 @@ import {convertTabularGraphToArrowGraph} from '../../src/graph/convert-tabular-g import {TabularGraph} from '../../src/graph/tabular-graph'; import type {TabularGraphSource, TabularGraphAccessors} from '../../src/graph/tabular-graph'; import type {NodeState, EdgeState} from '../../src/core/constants'; +import type {GraphData} from '../../src/graph-data/graph-data'; import SAMPLE_GRAPH1 from '../data/__fixtures__/graph1.json'; @@ -178,4 +179,60 @@ describe('convertTabularGraphToArrowGraph', () => { expect.arrayContaining(SAMPLE_GRAPH1.edges.map((edge) => edge.id)) ); }); + + it('builds ArrowGraph instances from GraphData sources', () => { + const graphData: GraphData = { + type: 'graph-data', + version: 3, + nodes: [ + { + type: 'graph-node-data', + id: '0', + label: 'Zero', + selectable: true, + highlightConnectedEdges: true, + state: 'hover', + attributes: {label: 'Zero', weight: 5, custom: 'value'} + }, + { + type: 'graph-node-data', + id: '1', + attributes: {label: 'One'} + } + ], + edges: [ + { + type: 'graph-edge-data', + id: 'edge-0-1', + sourceId: '0', + targetId: '1', + directed: true, + label: '0-1', + state: 'selected', + attributes: {label: '0-1', capacity: 3} + } + ] + }; + + const arrowGraph = convertTabularGraphToArrowGraph(graphData); + expect(arrowGraph.version).toBe(3); + + const [node0, node1] = Array.from(arrowGraph.getNodes()); + expect(String(node0.getId())).toBe('0'); + expect(node0.getState()).toBe('hover'); + expect(node0.isSelectable()).toBe(true); + expect(node0.shouldHighlightConnectedEdges()).toBe(true); + expect(node0.getPropertyValue('custom')).toBe('value'); + expect(node0.getPropertyValue('label')).toBe('Zero'); + + expect(String(node1.getId())).toBe('1'); + expect(node1.getPropertyValue('label')).toBe('One'); + + const [edge] = Array.from(arrowGraph.getEdges()); + expect(String(edge.getId())).toBe('edge-0-1'); + expect(edge.isDirected()).toBe(true); + expect(edge.getState()).toBe('selected'); + expect(edge.getPropertyValue('capacity')).toBe(3); + expect(edge.getPropertyValue('label')).toBe('0-1'); + }); });