From 81fa229ba7fe7dfbbe6de2dc36e00d2456eab1bb Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 4 Aug 2026 22:49:59 -0400 Subject: [PATCH 1/7] feat(examples): integrate resident GPU graphs with deck.gl --- docs/api-reference/experimental/lugraph.md | 18 + examples/deck/lugraph-explorer/app.ts | 286 ++++++++++++++ .../lugraph-explorer/lugraph-edge-layer.ts | 149 +++++++ .../deck/lugraph-explorer/lugraph-effect.ts | 342 ++++++++++++++++ .../lugraph-explorer/lugraph-node-layer.ts | 226 +++++++++++ .../test/lugraph/lu-graph-deck.node.spec.ts | 158 ++++++++ .../test/lugraph/lu-graph-deck.spec.ts | 372 ++++++++++++++++++ .../examples/deck/lugraph-explorer.mdx | 31 ++ .../content/examples/table-of-contents.json | 5 + website/src/example-thumbnails.ts | 1 + website/src/examples.tsx | 36 ++ 11 files changed, 1624 insertions(+) create mode 100644 examples/deck/lugraph-explorer/app.ts create mode 100644 examples/deck/lugraph-explorer/lugraph-edge-layer.ts create mode 100644 examples/deck/lugraph-explorer/lugraph-effect.ts create mode 100644 examples/deck/lugraph-explorer/lugraph-node-layer.ts create mode 100644 modules/experimental/test/lugraph/lu-graph-deck.node.spec.ts create mode 100644 modules/experimental/test/lugraph/lu-graph-deck.spec.ts create mode 100644 website/content/examples/deck/lugraph-explorer.mdx diff --git a/docs/api-reference/experimental/lugraph.md b/docs/api-reference/experimental/lugraph.md index 26d3958955..4463339a87 100644 --- a/docs/api-reference/experimental/lugraph.md +++ b/docs/api-reference/experimental/lugraph.md @@ -70,6 +70,24 @@ network, dependency map, fraud investigation, or other relationship visualizatio WebGPU-only educational example, not a large-graph performance benchmark: its exact layout costs `O(V² + E)` per force iteration and intentionally uses only 128 vertices. +### Use luGraph from deck.gl without copying graph buffers + +The optional [luGraph + deck.gl network explorer](/examples/deck/lugraph-explorer) demonstrates +the same resident analytics through a real deck.gl `Effect`, custom node and edge layers, +`OrthographicView` navigation, GPU neighborhood selection, progressive layout, pinning, and +asynchronous WebGPU picking. The effect encodes one-time topology, PageRank, and weak components +followed by per-frame breadth-first selection and force integration into deck.gl's own command +encoder; deck.gl remains responsible for queue submission. + +Node positions are simultaneously the layout's writable storage allocation and the node layer's +`float32x2` instance vertex attribute. Importance scores, component labels, hop distances, and the +selection mask remain storage inputs; each nonempty original edge partition gets its own edge layer, +without concatenation, buffer copies, or per-frame graph readback. Only explicitly requested +deck.gl picking results cross to JavaScript. + +The integration lives exclusively under `examples/deck/lugraph-explorer`; neither +`@luma.gl/experimental` nor its optional graph entry point depends on or imports deck.gl. + ## Measure real CPU and WebGPU graph workloads **Question: Does this graph workflow benefit from GPU execution on my actual browser, and what do diff --git a/examples/deck/lugraph-explorer/app.ts b/examples/deck/lugraph-explorer/app.ts new file mode 100644 index 0000000000..5570b334c7 --- /dev/null +++ b/examples/deck/lugraph-explorer/app.ts @@ -0,0 +1,286 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {OrthographicView, type PickingInfo} from '@deck.gl/core'; +import {Buffer, type Device} from '@luma.gl/core'; +import { + ShaderAssembler, + type GLSLShaderAssembler, + type WGSLShaderAssembler +} from '@luma.gl/shadertools'; +import {ArrowDeck} from '../arrow-deck'; +import {getDeckExampleProps, type DeckExampleDeviceOptions} from '../deck-example-device'; +import {LuGraphDeckEffect} from './lugraph-effect'; +import {LuGraphEdgeLayer} from './lugraph-edge-layer'; +import {LuGraphNodeLayer} from './lugraph-node-layer'; + +const DEFAULT_NEIGHBORHOOD_DEPTH = 2; + +type GraphExplorerControls = { + update: () => void; + destroy: () => void; +}; + +/** + * Creates an optional deck.gl explorer using resident luGraph analytics and original edge chunks. + * + * Deck owns the WebGPU frame encoder, rendering, controller, and asynchronous node picking. The + * graph module never depends on deck.gl, and no graph column is downloaded for animation, color, + * sizing, selection, or dragging. + */ +export function createLuGraphExplorerDeck( + parent?: HTMLDivElement, + options: DeckExampleDeviceOptions = {} +): ArrowDeck { + const ownsContainer = !parent; + const container = parent ?? createStandaloneContainer(); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + + let effect: LuGraphDeckEffect | null = null; + let draggedVertex: number | null = null; + let restoreShaderAssembler: (() => void) | null = null; + let deck: ArrowDeck; + const controls = createExplorerControls(container, { + getEffect: () => effect, + redraw: reason => deck?.redraw(reason) + }); + + deck = new ArrowDeck({ + parent: container, + ...getDeckExampleProps({...options, deviceType: 'webgpu'}), + views: new OrthographicView({id: 'lugraph-orthographic'}), + initialViewState: {target: [0, 0, 0], zoom: 7.7, minZoom: 5, maxZoom: 11}, + controller: { + dragPan: true, + scrollZoom: {smooth: true, speed: 0.02}, + doubleClickZoom: true, + touchZoom: true + }, + _animate: true, + pickAsync: 'auto', + layers: [], + effects: [], + onDeviceInitialized: initializedDevice => { + restoreShaderAssembler?.(); + restoreShaderAssembler = installLegacyDeckShaderAssemblerCompatibility(initializedDevice); + }, + onError: error => { + restoreShaderAssembler?.(); + restoreShaderAssembler = null; + throw error; + }, + getTooltip: info => getVertexTooltip(info, effect), + onClick: info => { + effect?.setSelectedVertex(info.picked && info.index >= 0 ? info.index : null); + controls.update(); + deck.redraw('luGraph deck selection changed'); + }, + onDragStart: (info, event) => { + if (!effect || !info.picked || info.index < 0) return; + draggedVertex = info.index; + effect.setSelectedVertex(draggedVertex); + effect.setPinnedVertex(draggedVertex, true); + updateDraggedVertex(effect, draggedVertex, info); + controls.update(); + event.stopPropagation(); + deck.redraw('luGraph vertex drag started'); + }, + onDrag: (info, event) => { + if (!effect || draggedVertex === null) return; + updateDraggedVertex(effect, draggedVertex, info); + event.stopPropagation(); + deck.redraw('luGraph vertex dragged'); + }, + onDragEnd: (_info, event) => { + if (draggedVertex === null) return; + draggedVertex = null; + controls.update(); + event.stopPropagation(); + deck.redraw('luGraph vertex pinned'); + }, + onLoad: ({deck: loadedDeck, device}) => { + if (device.type !== 'webgpu') throw new Error('luGraph deck explorer requires WebGPU'); + effect = new LuGraphDeckEffect(device); + const edgeLayers = effect.graph.sourceVertices.data.flatMap((source, chunkIndex) => { + if (source.length === 0) return []; + const target = effect!.graph.targetVertices.data[chunkIndex]; + return [ + new LuGraphEdgeLayer({ + id: `lugraph-edges-${chunkIndex}`, + data: [], + pickable: false, + positions: effect!.positions, + sourceVertices: source.buffer instanceof Buffer ? source.buffer : source.buffer.buffer, + targetVertices: target.buffer instanceof Buffer ? target.buffer : target.buffer.buffer, + distances: effect!.distances, + edgeCount: source.length, + opacity: 0.85 + }) + ]; + }); + const nodeLayer = new LuGraphNodeLayer({ + id: 'lugraph-nodes', + data: [], + pickable: true, + autoHighlight: true, + positions: effect.positions, + importance: effect.importance, + components: effect.componentLabels, + distances: effect.distances, + selectionMask: effect.selectionMask, + vertexCount: effect.graph.vertexCount, + opacity: 1 + }); + loadedDeck.setProps({effects: [effect], layers: [...edgeLayers, nodeLayer]}); + controls.update(); + loadedDeck.redraw('luGraph deck analytics initialized'); + }, + onFinalize: () => { + restoreShaderAssembler?.(); + restoreShaderAssembler = null; + draggedVertex = null; + controls.destroy(); + if (ownsContainer) container.remove(); + } + }); + + return deck; +} + +/** Bridges exactly one legacy Deck assembler call while preserving strict language separation. */ +function installLegacyDeckShaderAssemblerCompatibility(device: Device): () => void { + const original = ShaderAssembler.getDefaultShaderAssembler; + let restored = false; + + function restore(): void { + if (restored) return; + if (ShaderAssembler.getDefaultShaderAssembler === getLegacyDeckShaderAssembler) { + ShaderAssembler.getDefaultShaderAssembler = original; + } + restored = true; + } + + function getLegacyDeckShaderAssembler(shaderLanguage: 'glsl'): GLSLShaderAssembler; + function getLegacyDeckShaderAssembler(shaderLanguage: 'wgsl'): WGSLShaderAssembler; + function getLegacyDeckShaderAssembler( + shaderLanguage: 'glsl' | 'wgsl' + ): GLSLShaderAssembler | WGSLShaderAssembler; + function getLegacyDeckShaderAssembler( + shaderLanguage?: 'glsl' | 'wgsl' + ): GLSLShaderAssembler | WGSLShaderAssembler { + if (shaderLanguage === undefined) { + // TODO: Remove after deck.gl forwards its known shading language to luma.gl. + // Restore before forwarding so later user calls retain strict explicit-language behavior. + restore(); + return device.info.shadingLanguage === 'wgsl' + ? original.call(ShaderAssembler, 'wgsl') + : original.call(ShaderAssembler, 'glsl'); + } + return shaderLanguage === 'wgsl' + ? original.call(ShaderAssembler, 'wgsl') + : original.call(ShaderAssembler, 'glsl'); + } + + ShaderAssembler.getDefaultShaderAssembler = getLegacyDeckShaderAssembler; + return restore; +} + +/** Updates the same float32x2 allocation bound directly by the node layer's instance attribute. */ +function updateDraggedVertex(effect: LuGraphDeckEffect, vertex: number, info: PickingInfo): void { + const coordinate = info.coordinate; + if (!coordinate || coordinate.length < 2) return; + effect.setVertexPosition(vertex, [coordinate[0], coordinate[1]]); +} + +function getVertexTooltip(info: PickingInfo, effect: LuGraphDeckEffect | null): string | null { + if (!info.picked || info.index < 0 || !effect) return null; + const state = effect.isVertexPinned(info.index) ? 'pinned' : 'movable'; + return `Vertex ${info.index} · ${state}\nGPU PageRank sizing · component color`; +} + +/** Provides explicit selection/reset controls without polling or transferring graph metrics. */ +function createExplorerControls( + container: HTMLDivElement, + props: {getEffect: () => LuGraphDeckEffect | null; redraw: (reason: string) => void} +): GraphExplorerControls { + const panel = document.createElement('section'); + Object.assign(panel.style, { + position: 'absolute', + left: '14px', + top: '14px', + zIndex: '2', + width: '250px', + padding: '12px 14px', + borderRadius: '10px', + background: 'rgba(9, 15, 28, 0.86)', + border: '1px solid rgba(127, 173, 230, 0.2)', + color: '#eaf3ff', + font: '12px/1.5 system-ui, sans-serif' + }); + panel.innerHTML = ` + luGraph + deck.gl +

Resident graph analytics, source-chunk edge layers, + direct instance vertices, and real asynchronous deck.gl picking.

+ +
+ + +
+

Initializing WebGPU graph…

+

Click to inspect · drag to pin · scroll to zoom

`; + container.appendChild(panel); + + const depth = panel.querySelector('[data-lugraph-depth]'); + const reset = panel.querySelector('[data-lugraph-reset]'); + const release = panel.querySelector('[data-lugraph-release]'); + const status = panel.querySelector('[data-lugraph-status]'); + const update = (): void => { + const effect = props.getEffect(); + if (!effect || !status) return; + const selected = effect.currentSelection === null ? 'none' : `${effect.currentSelection}`; + status.textContent = `${effect.graph.vertexCount} vertices · ${effect.graph.edgeCount} chunked edges · selected ${selected}`; + }; + const updateDepth = (): void => { + props.getEffect()?.setNeighborhoodDepth(Number(depth?.value ?? DEFAULT_NEIGHBORHOOD_DEPTH)); + props.redraw('luGraph deck neighborhood depth changed'); + }; + const resetLayout = (): void => { + props.getEffect()?.requestReset(); + props.redraw('luGraph deck deterministic layout reset'); + }; + const clearPins = (): void => { + props.getEffect()?.clearPins(); + update(); + props.redraw('luGraph deck pins released'); + }; + depth?.addEventListener('input', updateDepth); + reset?.addEventListener('click', resetLayout); + release?.addEventListener('click', clearPins); + + return { + update, + destroy: () => { + depth?.removeEventListener('input', updateDepth); + reset?.removeEventListener('click', resetLayout); + release?.removeEventListener('click', clearPins); + panel.remove(); + } + }; +} + +function createStandaloneContainer(): HTMLDivElement { + document.body.style.margin = '0'; + const container = document.createElement('div'); + Object.assign(container.style, { + position: 'fixed', + inset: '0', + overflow: 'hidden', + background: '#070d18' + }); + document.body.appendChild(container); + return container; +} diff --git a/examples/deck/lugraph-explorer/lugraph-edge-layer.ts b/examples/deck/lugraph-explorer/lugraph-edge-layer.ts new file mode 100644 index 0000000000..3e65a9b21f --- /dev/null +++ b/examples/deck/lugraph-explorer/lugraph-edge-layer.ts @@ -0,0 +1,149 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {Layer, project32, type LayerContext, type LayerProps} from '@deck.gl/core'; +import {Buffer, type RenderPass} from '@luma.gl/core'; +import {Model} from '@luma.gl/engine'; + +/** One original aligned source/target GPU chunk and its shared resident graph state. */ +export type LuGraphEdgeLayerProps = LayerProps & { + positions: Buffer; + sourceVertices: Buffer; + targetVertices: Buffer; + distances: Buffer; + edgeCount: number; +}; + +type LuGraphEdgeLayerState = { + model: Model | null; + styleUniforms: Buffer | null; +}; + +const EDGE_BLEND_PARAMETERS = { + depthWriteEnabled: false, + blend: true, + blendColorOperation: 'add', + blendAlphaOperation: 'add', + blendColorSrcFactor: 'src-alpha', + blendColorDstFactor: 'one-minus-src-alpha', + blendAlphaSrcFactor: 'one', + blendAlphaDstFactor: 'one-minus-src-alpha' +} as const; + +/** Chunk-local source/target storage directly addresses the live shared layout positions. */ +export const LUGRAPH_DECK_EDGE_SHADER = /* wgsl */ ` +struct EdgeStyle { + opacity: f32, + _padding1: f32, + _padding2: f32, + _padding3: f32, +}; + +@group(0) @binding(auto) var positions: array>; +@group(0) @binding(auto) var sourceVertices: array; +@group(0) @binding(auto) var targetVertices: array; +@group(0) @binding(auto) var distances: array; +@group(0) @binding(auto) var edgeStyle: EdgeStyle; + +struct EdgeVertexOutput { + @builtin(position) position: vec4, + @location(0) color: vec4, +}; + +@vertex fn vertexMain( + @builtin(vertex_index) vertexIndex: u32, + @builtin(instance_index) instanceIndex: u32 +) -> EdgeVertexOutput { + let sourceVertex = sourceVertices[instanceIndex]; + let targetVertex = targetVertices[instanceIndex]; + let vertex = select(sourceVertex, targetVertex, vertexIndex != 0u); + let position = positions[vertex]; + let sourceReached = distances[sourceVertex] != 0xffffffffu; + let targetReached = distances[targetVertex] != 0xffffffffu; + let isSelectedEdge = sourceReached && targetReached; + + geometry.worldPosition = vec3(position, 0.0); + var output: EdgeVertexOutput; + var clipPosition = project_position_to_clipspace( + vec3(position, 0.0), + vec3(0.0), + vec3(0.0) + ); + // Deck's OpenGL-style projection depth must be converted for WebGPU clipping. + clipPosition.z = (clipPosition.z + clipPosition.w) * 0.5; + output.position = clipPosition; + output.color = select( + vec4(0.32, 0.51, 0.70, 0.20), + vec4(0.95, 0.72, 0.32, 0.82), + isSelectedEdge + ); + return output; +} + +@fragment fn fragmentMain(input: EdgeVertexOutput) -> @location(0) vec4 { + return vec4(input.color.rgb, input.color.a * edgeStyle.opacity); +}`; + +/** Exactly one deck layer per nonempty original edge chunk; no implicit edge packing occurs. */ +export class LuGraphEdgeLayer extends Layer { + static override layerName = 'LuGraphEdgeLayer'; + static override defaultProps = {parameters: EDGE_BLEND_PARAMETERS}; + + override getAttributeManager() { + return null; + } + + /** Reports the original GPUData chunk population instead of the empty placeholder array. */ + override getNumInstances(): number { + return this.props.edgeCount; + } + + override initializeState({device}: LayerContext): void { + if (device.type !== 'webgpu') throw new Error('LuGraphEdgeLayer requires WebGPU'); + const styleUniforms = device.createBuffer({ + id: `${this.id}-style-uniforms`, + byteLength: 16, + usage: Buffer.UNIFORM | Buffer.COPY_DST + }); + const model = new Model(device, { + ...this.getShaders({modules: [project32], source: LUGRAPH_DECK_EDGE_SHADER}), + id: `${this.id}-model`, + topology: 'line-list', + isInstanced: true, + vertexCount: 2, + instanceCount: this.props.edgeCount, + bufferLayout: [], + bindings: { + positions: this.props.positions, + sourceVertices: this.props.sourceVertices, + targetVertices: this.props.targetVertices, + distances: this.props.distances, + edgeStyle: styleUniforms + }, + parameters: EDGE_BLEND_PARAMETERS + }); + this.setState({model, styleUniforms} satisfies LuGraphEdgeLayerState); + } + + override getModels(): Model[] { + const model = (this.state as LuGraphEdgeLayerState).model; + return model ? [model] : []; + } + + override draw({renderPass}: {renderPass: RenderPass}): void { + const {model, styleUniforms} = this.state as LuGraphEdgeLayerState; + if (!model || !styleUniforms) return; + styleUniforms.write(new Float32Array([this.props.opacity ?? 1, 0, 0, 0])); + model.setInstanceCount(this.props.edgeCount); + model.draw(renderPass); + } + + override finalizeState(context: LayerContext): void { + const state = this.state as LuGraphEdgeLayerState; + state.model?.destroy(); + state.styleUniforms?.destroy(); + this.setState({model: null, styleUniforms: null} satisfies LuGraphEdgeLayerState); + super.finalizeState(context); + } +} diff --git a/examples/deck/lugraph-explorer/lugraph-effect.ts b/examples/deck/lugraph-explorer/lugraph-effect.ts new file mode 100644 index 0000000000..768b346c15 --- /dev/null +++ b/examples/deck/lugraph-explorer/lugraph-effect.ts @@ -0,0 +1,342 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Effect, EffectContext} from '@deck.gl/core'; +import {Buffer, type Device} from '@luma.gl/core'; +import {GPUCommandGraph, type CompiledGPUCommandGraph} from '@luma.gl/experimental'; +import { + LuGraph, + LuGraphBreadthFirstSearch, + LuGraphConnectedComponents, + LuGraphForceLayout, + LuGraphPageRank, + LuGraphTopology, + type LuGraphAdjacency +} from '@luma.gl/experimental/lugraph'; +import {GPUData, GPUVector} from '@luma.gl/tables'; +import { + makeGraphExplorerDataset, + type GraphExplorerDataset +} from '../../experimental/lugraph-explorer/graph-data'; + +const SCALAR_BYTE_LENGTH = 4; +const MAXIMUM_NEIGHBORHOOD_DEPTH = 8; +const DEFAULT_NEIGHBORHOOD_DEPTH = 2; + +type ScalarFormat = 'uint32' | 'float32'; + +/** + * Declares resident luGraph analytics and progressive layout inside deck.gl's own render encoder. + * + * Construction compiles persistent graphs but never submits commands or reads a buffer. Original + * source edge batches, including their empty middle partition, remain directly available to Deck + * edge layers. The first ordinary frame encodes topology, PageRank, and weak components once; + * every ordinary frame then encodes bounded neighborhood search and force integration. + */ +export class LuGraphDeckEffect implements Effect { + readonly id = 'lugraph-deck-effect'; + readonly props = {}; + readonly useInPicking = false; + readonly device: Device; + readonly dataset: GraphExplorerDataset; + readonly graph: LuGraph; + readonly topology: LuGraphTopology; + readonly pageRank: LuGraphPageRank; + readonly components: LuGraphConnectedComponents; + readonly search: LuGraphBreadthFirstSearch; + readonly layout: LuGraphForceLayout; + readonly analysisGraph: CompiledGPUCommandGraph; + readonly frameGraph: CompiledGPUCommandGraph; + + private readonly buffers: Buffer[] = []; + private readonly vectors: GPUVector[] = []; + private readonly seeds: GPUVector<'uint32'>; + private readonly seedCount: GPUVector<'uint32'>; + private readonly activeDepth: GPUVector<'uint32'>; + private readonly pinned: GPUVector<'uint32'>; + private readonly reset: GPUVector<'uint32'>; + private readonly pinnedVertices = new Set(); + private selectedVertex: number | null = 0; + private neighborhoodDepth = DEFAULT_NEIGHBORHOOD_DEPTH; + private analyticsPending = true; + private destroyed = false; + + constructor(device: Device, dataset: GraphExplorerDataset = makeGraphExplorerDataset()) { + if (device.type !== 'webgpu') throw new Error('LuGraphDeckEffect requires WebGPU'); + this.device = device; + this.dataset = dataset; + + this.graph = new LuGraph({ + vertexCount: dataset.vertexCount, + directed: true, + sourceVertices: this.createChunkedVector('source-vertices', dataset.sourceChunks), + targetVertices: this.createChunkedVector('target-vertices', dataset.targetChunks) + }); + this.topology = new LuGraphTopology({ + id: 'lugraph-deck-topology', + graph: this.graph, + forward: this.createAdjacency('forward', dataset.vertexCount, this.graph.edgeCount), + reverse: this.createAdjacency('reverse', dataset.vertexCount, this.graph.edgeCount), + invalidEdgeCount: this.createScalarVector('invalid-edges', 'uint32', 1) + }); + this.pageRank = new LuGraphPageRank({ + id: 'lugraph-deck-page-rank', + topology: this.topology, + output: this.createScalarVector('importance', 'float32', dataset.vertexCount), + iterations: 12 + }); + this.components = new LuGraphConnectedComponents({ + id: 'lugraph-deck-components', + topology: this.topology, + output: this.createScalarVector('components', 'uint32', dataset.vertexCount), + iterations: 16 + }); + + this.seeds = this.createScalarVector('seeds', 'uint32', 1, Uint32Array.of(0)); + this.seedCount = this.createScalarVector('seed-count', 'uint32', 1, Uint32Array.of(1)); + this.activeDepth = this.createScalarVector( + 'active-depth', + 'uint32', + 1, + Uint32Array.of(DEFAULT_NEIGHBORHOOD_DEPTH) + ); + this.search = new LuGraphBreadthFirstSearch({ + id: 'lugraph-deck-neighborhood', + topology: this.topology, + seeds: this.seeds, + seedCount: this.seedCount, + distances: this.createScalarVector('distances', 'uint32', dataset.vertexCount), + predecessors: this.createScalarVector('predecessors', 'uint32', dataset.vertexCount), + mask: this.createScalarVector('selection-mask', 'uint32', dataset.vertexCount), + maxDepth: MAXIMUM_NEIGHBORHOOD_DEPTH, + activeDepth: this.activeDepth, + direction: 'both' + }); + + this.pinned = this.createScalarVector('pinned', 'uint32', dataset.vertexCount); + this.reset = this.createScalarVector('reset', 'uint32', 1, Uint32Array.of(0)); + this.layout = new LuGraphForceLayout({ + id: 'lugraph-deck-force-layout', + topology: this.topology, + positions: this.createCoordinateVector('positions', dataset.positions, Buffer.VERTEX), + velocities: this.createCoordinateVector('velocities', dataset.velocities), + pinned: this.pinned, + reset: this.reset, + seed: 0x1a2b3c4d, + iterationsPerFrame: 2, + repulsion: 0.005, + attraction: 0.045, + gravity: 0.025, + damping: 0.85, + maxVelocity: 0.045 + }); + + const analysis = new GPUCommandGraph(device, {id: 'lugraph-deck-analysis'}); + this.topology.addToGraph(analysis); + this.components.addToGraph(analysis); + this.pageRank.addToGraph(analysis); + this.analysisGraph = analysis.compile(); + + const frame = new GPUCommandGraph(device, {id: 'lugraph-deck-frame'}); + this.search.addToGraph(frame); + this.layout.addToGraph(frame); + this.frameGraph = frame.compile(); + } + + /** Supplies the exact progressive allocation also bound as a Deck instance vertex attribute. */ + get positions(): Buffer { + return this.getVectorBuffer(this.layout.positions); + } + + get importance(): Buffer { + return this.getVectorBuffer(this.pageRank.output); + } + + get componentLabels(): Buffer { + return this.getVectorBuffer(this.components.output); + } + + get distances(): Buffer { + return this.getVectorBuffer(this.search.distances); + } + + get selectionMask(): Buffer { + return this.getVectorBuffer(this.search.mask!); + } + + get currentSelection(): number | null { + return this.selectedVertex; + } + + get currentNeighborhoodDepth(): number { + return this.neighborhoodDepth; + } + + setup(_context: EffectContext): void {} + + /** Appends compute to Deck's current frame; Deck remains the sole queue submission owner. */ + preRender(options: Parameters[0]): void { + if (this.destroyed || !options.viewports[0]) return; + if (this.analyticsPending) { + this.analysisGraph.encode(this.device.commandEncoder, {parameters: undefined}); + this.analyticsPending = false; + } + this.frameGraph.encode(this.device.commandEncoder, {parameters: undefined}); + } + + /** Publishes a genuinely picked stable source vertex without reading any graph column. */ + setSelectedVertex(vertex: number | null): void { + if (vertex !== null && !this.isValidVertex(vertex)) return; + this.selectedVertex = vertex; + if (vertex === null) { + this.getVectorBuffer(this.seedCount).write(Uint32Array.of(0)); + } else { + this.getVectorBuffer(this.seeds).write(Uint32Array.of(vertex)); + this.getVectorBuffer(this.seedCount).write(Uint32Array.of(1)); + } + } + + /** Updates the existing GPU-resident dynamic hop limit without recompiling traversal passes. */ + setNeighborhoodDepth(depth: number): void { + this.neighborhoodDepth = Math.max(0, Math.min(MAXIMUM_NEIGHBORHOOD_DEPTH, Math.round(depth))); + this.getVectorBuffer(this.activeDepth).write(Uint32Array.of(this.neighborhoodDepth)); + } + + /** Pins or releases exactly one original source vertex; no other rows are repacked. */ + setPinnedVertex(vertex: number, pinned: boolean): void { + if (!this.isValidVertex(vertex)) return; + this.getVectorBuffer(this.pinned).write( + Uint32Array.of(pinned ? 1 : 0), + vertex * SCALAR_BYTE_LENGTH + ); + if (pinned) this.pinnedVertices.add(vertex); + else this.pinnedVertices.delete(vertex); + } + + isVertexPinned(vertex: number): boolean { + return this.pinnedVertices.has(vertex); + } + + /** Moves the same physical vertex allocation consumed by the active Deck node model. */ + setVertexPosition(vertex: number, position: readonly [number, number]): void { + if (!this.isValidVertex(vertex) || !position.every(Number.isFinite)) return; + this.positions.write(Float32Array.from(position), vertex * 2 * SCALAR_BYTE_LENGTH); + this.getVectorBuffer(this.layout.velocities).write( + Float32Array.of(0, 0), + vertex * 2 * SCALAR_BYTE_LENGTH + ); + } + + clearPins(): void { + this.getVectorBuffer(this.pinned).write(new Uint32Array(this.graph.vertexCount)); + this.pinnedVertices.clear(); + } + + requestReset(): void { + this.getVectorBuffer(this.reset).write(Uint32Array.of(1)); + } + + /** Releases only effect-owned graphs and buffers; aggregate vectors remain borrowing views. */ + cleanup(_context: EffectContext): void { + if (this.destroyed) return; + this.destroyed = true; + this.analysisGraph.destroy(); + this.frameGraph.destroy(); + for (const vector of this.vectors.reverse()) vector.destroy(); + for (const buffer of this.buffers.reverse()) buffer.destroy(); + } + + private isValidVertex(vertex: number): boolean { + return Number.isSafeInteger(vertex) && vertex >= 0 && vertex < this.graph.vertexCount; + } + + /** Preserves all original aligned GPUData partitions, including zero-length source batches. */ + private createChunkedVector(name: string, chunks: Uint32Array[]): GPUVector<'uint32'> { + const data = chunks.map((values, chunkIndex) => { + const buffer = this.device.createBuffer({ + id: `lugraph-deck-${name}-${chunkIndex}`, + data: values.length === 0 ? new Uint32Array(1) : values, + usage: Buffer.STORAGE | Buffer.COPY_DST + }); + this.buffers.push(buffer); + return new GPUData<'uint32'>({ + buffer, + format: 'uint32', + length: values.length, + ownsBuffer: false + }); + }); + const vector = new GPUVector<'uint32'>({ + type: 'data', + name, + format: 'uint32', + data, + ownsData: false + }); + this.vectors.push(vector); + return vector; + } + + private createScalarVector( + name: string, + format: Format, + length: number, + values?: Uint32Array | Float32Array + ): GPUVector { + const buffer = this.device.createBuffer({ + id: `lugraph-deck-${name}`, + byteLength: Math.max(length, 1) * SCALAR_BYTE_LENGTH, + usage: Buffer.STORAGE | Buffer.COPY_SRC | Buffer.COPY_DST + }); + if (values?.length) buffer.write(values); + this.buffers.push(buffer); + const vector = new GPUVector({ + type: 'buffer', + name, + format, + buffer, + length, + ownsBuffer: false + }); + this.vectors.push(vector); + return vector; + } + + private createCoordinateVector( + name: string, + values: Float32Array, + additionalUsage = 0 + ): GPUVector<'float32x2'> { + const buffer = this.device.createBuffer({ + id: `lugraph-deck-${name}`, + data: values, + usage: Buffer.STORAGE | Buffer.COPY_DST | Buffer.COPY_SRC | additionalUsage + }); + this.buffers.push(buffer); + const vector = new GPUVector<'float32x2'>({ + type: 'buffer', + name, + format: 'float32x2', + buffer, + length: values.length / 2, + ownsBuffer: false + }); + this.vectors.push(vector); + return vector; + } + + private createAdjacency(name: string, vertexCount: number, capacity: number): LuGraphAdjacency { + return { + offsets: this.createScalarVector(`${name}-offsets`, 'uint32', vertexCount + 1), + neighbors: this.createScalarVector(`${name}-neighbors`, 'uint32', capacity), + edgeIds: this.createScalarVector(`${name}-edge-ids`, 'uint32', capacity), + count: this.createScalarVector(`${name}-count`, 'uint32', 1), + overflow: this.createScalarVector(`${name}-overflow`, 'uint32', 1) + }; + } + + private getVectorBuffer(vector: GPUVector): Buffer { + return vector.data[0].buffer as Buffer; + } +} diff --git a/examples/deck/lugraph-explorer/lugraph-node-layer.ts b/examples/deck/lugraph-explorer/lugraph-node-layer.ts new file mode 100644 index 0000000000..8641eea707 --- /dev/null +++ b/examples/deck/lugraph-explorer/lugraph-node-layer.ts @@ -0,0 +1,226 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import { + Layer, + picking, + project32, + type LayerContext, + type LayerProps, + type PickingInfo +} from '@deck.gl/core'; +import {Buffer, type RenderPass} from '@luma.gl/core'; +import {Model} from '@luma.gl/engine'; + +/** Actual luGraph allocations consumed by a deck.gl node layer without staging or copying. */ +export type LuGraphNodeLayerProps = LayerProps & { + positions: Buffer; + importance: Buffer; + components: Buffer; + distances: Buffer; + selectionMask: Buffer; + vertexCount: number; +}; + +type LuGraphNodeLayerState = { + model: Model | null; + styleUniforms: Buffer | null; +}; + +const NODE_BLEND_PARAMETERS = { + depthWriteEnabled: false, + blend: true, + blendColorOperation: 'add', + blendAlphaOperation: 'add', + blendColorSrcFactor: 'src-alpha', + blendColorDstFactor: 'one-minus-src-alpha', + blendAlphaSrcFactor: 'one', + blendAlphaDstFactor: 'one-minus-src-alpha' +} as const; + +/** Direct instance vertex fetch and four independently computed resident graph attributes. */ +export const LUGRAPH_DECK_NODE_SHADER = /* wgsl */ ` +struct NodeStyle { + radiusPixels: f32, + opacity: f32, + pickingActive: f32, + vertexCount: f32, +}; + +@group(0) @binding(auto) var importance: array; +@group(0) @binding(auto) var components: array; +@group(0) @binding(auto) var distances: array; +@group(0) @binding(auto) var selectionMask: array; +@group(0) @binding(auto) var nodeStyle: NodeStyle; + +struct NodeVertexOutput { + @builtin(position) position: vec4, + @location(0) corner: vec2, + @location(1) color: vec4, + @location(2) @interpolate(flat) pickingColor: vec3, +}; + +fn getNodeCorner(vertexIndex: u32) -> vec2 { + let corners = array, 6>( + vec2(-1.0, -1.0), vec2(1.0, -1.0), vec2(-1.0, 1.0), + vec2(-1.0, 1.0), vec2(1.0, -1.0), vec2(1.0, 1.0) + ); + return corners[vertexIndex]; +} + +fn getComponentColor(component: u32) -> vec3 { + let colors = array, 6>( + vec3(0.26, 0.79, 1.00), + vec3(1.00, 0.58, 0.28), + vec3(0.74, 0.48, 1.00), + vec3(0.34, 0.92, 0.66), + vec3(1.00, 0.41, 0.66), + vec3(0.96, 0.86, 0.34) + ); + return colors[component % 6u]; +} + +fn encodeNodePickingColor(vertex: u32) -> vec3 { + let index = vertex + 1u; + return vec3( + f32(index % 256u), + f32((index / 256u) % 256u), + f32((index / 65536u) % 256u) + ) / 255.0; +} + +@vertex fn vertexMain( + @location(0) nodePosition: vec2, + @builtin(vertex_index) vertexIndex: u32, + @builtin(instance_index) instanceIndex: u32 +) -> NodeVertexOutput { + let corner = getNodeCorner(vertexIndex); + let pickingColor = encodeNodePickingColor(instanceIndex); + let selected = selectionMask[instanceIndex] != 0u; + let reached = distances[instanceIndex] != 0xffffffffu; + let rank = max(importance[instanceIndex] * nodeStyle.vertexCount, 0.0); + var radius = nodeStyle.radiusPixels * clamp(sqrt(rank), 0.65, 2.3); + + let highlightedColor = picking_normalizeColor(picking.highlightedObjectColor); + let highlighted = picking.isHighlightActive > 0.5 && + distance(pickingColor, highlightedColor) < 0.00001; + if (selected || highlighted) { radius *= 1.35; } + + geometry.worldPosition = vec3(nodePosition, 0.0); + geometry.pickingColor = pickingColor; + var clipPosition = project_position_to_clipspace( + vec3(nodePosition, 0.0), + vec3(0.0), + vec3(0.0) + ); + // Deck's project32 matrices use OpenGL depth; WebGPU clip space requires [0, w]. + clipPosition.z = (clipPosition.z + clipPosition.w) * 0.5; + clipPosition = vec4( + clipPosition.xy + project_pixel_size_to_clipspace(corner * radius), + clipPosition.z, + clipPosition.w + ); + + let componentColor = getComponentColor(components[instanceIndex]); + let accentColor = select(componentColor, vec3(1.0, 0.78, 0.28), selected); + let brightness = select(0.56, 1.0, reached || selected || highlighted); + + var output: NodeVertexOutput; + output.position = clipPosition; + output.corner = corner; + output.color = vec4(accentColor * brightness, 0.95); + output.pickingColor = pickingColor; + return output; +} + +@fragment fn fragmentMain(input: NodeVertexOutput) -> @location(0) vec4 { + let radiusSquared = dot(input.corner, input.corner); + if (radiusSquared > 1.0) { discard; } + if (nodeStyle.pickingActive > 0.5) { + return vec4(input.pickingColor, 1.0); + } + let coverage = 1.0 - smoothstep(0.48, 1.0, radiusSquared); + return vec4(input.color.rgb, input.color.a * nodeStyle.opacity * coverage); +}`; + +/** Deck layer whose actual instance vertex attribute is the progressive luGraph position buffer. */ +export class LuGraphNodeLayer extends Layer { + static override layerName = 'LuGraphNodeLayer'; + static override defaultProps = {parameters: NODE_BLEND_PARAMETERS}; + + override getAttributeManager() { + return null; + } + + /** Keeps Deck picking and lifecycle counts aligned with the resident vertex allocation. */ + override getNumInstances(): number { + return this.props.vertexCount; + } + + override initializeState({device}: LayerContext): void { + if (device.type !== 'webgpu') throw new Error('LuGraphNodeLayer requires WebGPU'); + const styleUniforms = device.createBuffer({ + id: `${this.id}-style-uniforms`, + byteLength: 16, + usage: Buffer.UNIFORM | Buffer.COPY_DST + }); + const model = new Model(device, { + ...this.getShaders({modules: [project32, picking], source: LUGRAPH_DECK_NODE_SHADER}), + id: `${this.id}-model`, + topology: 'triangle-list', + isInstanced: true, + vertexCount: 6, + instanceCount: this.props.vertexCount, + attributes: {nodePosition: this.props.positions}, + bufferLayout: [{name: 'nodePosition', format: 'float32x2', stepMode: 'instance'}], + bindings: { + importance: this.props.importance, + components: this.props.components, + distances: this.props.distances, + selectionMask: this.props.selectionMask, + nodeStyle: styleUniforms + }, + parameters: NODE_BLEND_PARAMETERS + }); + this.setState({model, styleUniforms} satisfies LuGraphNodeLayerState); + } + + override getModels(): Model[] { + const model = (this.state as LuGraphNodeLayerState).model; + return model ? [model] : []; + } + + override draw({ + renderPass, + shaderModuleProps + }: { + renderPass: RenderPass; + shaderModuleProps?: {picking?: {isActive?: number | boolean}}; + }): void { + const {model, styleUniforms} = this.state as LuGraphNodeLayerState; + if (!model || !styleUniforms) return; + styleUniforms.write( + new Float32Array([ + 6, + this.props.opacity ?? 1, + shaderModuleProps?.picking?.isActive ? 1 : 0, + this.props.vertexCount + ]) + ); + model.setInstanceCount(this.props.vertexCount); + model.draw(renderPass); + } + + override getPickingInfo({info}: {info: PickingInfo}): PickingInfo { + return info; + } + + override finalizeState(context: LayerContext): void { + const state = this.state as LuGraphNodeLayerState; + state.model?.destroy(); + state.styleUniforms?.destroy(); + this.setState({model: null, styleUniforms: null} satisfies LuGraphNodeLayerState); + super.finalizeState(context); + } +} diff --git a/modules/experimental/test/lugraph/lu-graph-deck.node.spec.ts b/modules/experimental/test/lugraph/lu-graph-deck.node.spec.ts new file mode 100644 index 0000000000..738e2c30fe --- /dev/null +++ b/modules/experimental/test/lugraph/lu-graph-deck.node.spec.ts @@ -0,0 +1,158 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {existsSync, readFileSync} from 'node:fs'; + +import * as experimentalModule from '@luma.gl/experimental'; +import * as luGraphModule from '@luma.gl/experimental/lugraph'; +import {describe, expect, test} from 'vitest'; + +import {createLuGraphExplorerDeck} from '../../../../examples/deck/lugraph-explorer/app'; +import { + LUGRAPH_DECK_EDGE_SHADER, + LuGraphEdgeLayer +} from '../../../../examples/deck/lugraph-explorer/lugraph-edge-layer'; +import {LuGraphDeckEffect} from '../../../../examples/deck/lugraph-explorer/lugraph-effect'; +import { + LUGRAPH_DECK_NODE_SHADER, + LuGraphNodeLayer +} from '../../../../examples/deck/lugraph-explorer/lugraph-node-layer'; +import {makeGraphExplorerDataset} from '../../../../examples/experimental/lugraph-explorer/graph-data'; +import {getExampleThumbnailPath} from '../../../../website/src/example-thumbnails'; + +type ExampleContentsEntry = { + type: string; + label?: string; + id?: string; + items?: Array; +}; + +describe('optional luGraph deck.gl integration package isolation', () => { + test('keeps deck.gl entirely outside graph production imports and dependencies', () => { + const packageJson = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8') + ) as { + dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + }; + + for (const dependencies of [ + packageJson.dependencies, + packageJson.peerDependencies, + packageJson.optionalDependencies + ]) { + expect(Object.keys(dependencies ?? {}).some(name => name.startsWith('@deck.gl/'))).toBe( + false + ); + } + expect('LuGraphDeckEffect' in experimentalModule).toBe(false); + expect('LuGraphDeckEffect' in luGraphModule).toBe(false); + expect('LuGraphNodeLayer' in luGraphModule).toBe(false); + expect('LuGraphEdgeLayer' in luGraphModule).toBe(false); + }); + + test('does not create an example workspace, package manifest, or integration dependency', () => { + expect( + existsSync( + new URL('../../../../examples/deck/lugraph-explorer/package.json', import.meta.url) + ) + ).toBe(false); + expect(typeof createLuGraphExplorerDeck).toBe('function'); + expect(typeof LuGraphDeckEffect).toBe('function'); + }); +}); + +describe('luGraph native deck.gl resident layers', () => { + test('exposes actual deck.gl node and original-partition edge layer classes', () => { + expect(LuGraphNodeLayer.layerName).toBe('LuGraphNodeLayer'); + expect(LuGraphEdgeLayer.layerName).toBe('LuGraphEdgeLayer'); + + const dataset = makeGraphExplorerDataset(); + expect(dataset.sourceChunks.map(chunk => chunk.length)).toEqual( + dataset.targetChunks.map(chunk => chunk.length) + ); + expect(dataset.sourceChunks).toHaveLength(3); + expect(dataset.sourceChunks[1]).toHaveLength(0); + expect(dataset.sourceChunks.filter(chunk => chunk.length > 0)).toHaveLength(2); + }); + + test('fetches actual position vertices, GPU analytics, and stable original picking identifiers', () => { + expect(LUGRAPH_DECK_NODE_SHADER).toContain('@location(0) nodePosition: vec2'); + expect(LUGRAPH_DECK_NODE_SHADER).toContain('importance: array'); + expect(LUGRAPH_DECK_NODE_SHADER).toContain('components: array'); + expect(LUGRAPH_DECK_NODE_SHADER).toContain('distances: array'); + expect(LUGRAPH_DECK_NODE_SHADER).toContain('selectionMask: array'); + expect(LUGRAPH_DECK_NODE_SHADER).toContain('vertex + 1u'); + expect(LUGRAPH_DECK_NODE_SHADER).toContain('geometry.pickingColor'); + expect(LUGRAPH_DECK_NODE_SHADER).not.toMatch(/atomic\s*<\s*f32\s*>/); + }); + + test('reads source and target edge chunks directly without concatenation or CPU staging', () => { + expect(LUGRAPH_DECK_EDGE_SHADER).toContain('sourceVertices: array'); + expect(LUGRAPH_DECK_EDGE_SHADER).toContain('targetVertices: array'); + expect(LUGRAPH_DECK_EDGE_SHADER).toContain('positions: array>'); + + const effectSource = readFileSync( + new URL('../../../../examples/deck/lugraph-explorer/lugraph-effect.ts', import.meta.url), + 'utf8' + ); + expect(effectSource).toContain('this.device.commandEncoder'); + expect(effectSource).not.toMatch(/\bdevice\.submit\s*\(/); + expect(effectSource).not.toMatch(/\.readAsync\s*\(/); + }); +}); + +describe('optional luGraph deck.gl gallery and API guide', () => { + test('registers lazy loading and a WebGPU-only GPGPU gallery destination', () => { + const examples = readFileSync( + new URL('../../../../website/src/examples.tsx', import.meta.url), + 'utf8' + ); + const contents = JSON.parse( + readFileSync( + new URL('../../../../website/content/examples/table-of-contents.json', import.meta.url), + 'utf8' + ) + ) as ExampleContentsEntry[]; + const gpuCategory = contents.find(category => category.label === 'GPGPU'); + + expect(examples).toContain( + "const loadLuGraphExplorerDeckExample = () => import('../../examples/deck/lugraph-explorer/app')" + ); + expect(examples).toContain('useDeferredExampleModule(loadLuGraphExplorerDeckExample)'); + expect(examples).toContain('createDeck: module.createLuGraphExplorerDeck'); + expect(examples).not.toMatch(/^import\s+\{createLuGraphExplorerDeck\}\s+from/m); + expect( + gpuCategory?.items?.some( + item => typeof item !== 'string' && item.id === 'deck/lugraph-explorer' + ) + ).toBe(true); + }); + + test('provides curated WebGPU metadata, an existing network thumbnail, and optional API guidance', () => { + const examplePage = readFileSync( + new URL('../../../../website/content/examples/deck/lugraph-explorer.mdx', import.meta.url), + 'utf8' + ); + const apiGuide = readFileSync( + new URL('../../../../docs/api-reference/experimental/lugraph.md', import.meta.url), + 'utf8' + ); + const topics = examplePage + .match(/topics:\s*\[([^\]]+)\]/)?.[1] + .split(',') + .map(topic => topic.trim()); + + expect(examplePage).toContain('backends: [webgpu]'); + expect(examplePage).toContain(''); + expect(topics?.length).toBeGreaterThanOrEqual(2); + expect(topics?.length).toBeLessThanOrEqual(5); + expect(new Set(topics).size).toBe(topics?.length); + expect(getExampleThumbnailPath('deck/lugraph-explorer')).toBe('showcase/packet-spraying.jpg'); + expect(apiGuide).toContain('/examples/deck/lugraph-explorer'); + expect(apiGuide).toContain("deck.gl's own command"); + expect(apiGuide).toContain('without concatenation, buffer copies, or per-frame graph readback'); + }); +}); diff --git a/modules/experimental/test/lugraph/lu-graph-deck.spec.ts b/modules/experimental/test/lugraph/lu-graph-deck.spec.ts new file mode 100644 index 0000000000..44a7196487 --- /dev/null +++ b/modules/experimental/test/lugraph/lu-graph-deck.spec.ts @@ -0,0 +1,372 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {EffectContext} from '@deck.gl/core'; +import {Buffer} from '@luma.gl/core'; +import {ShaderAssembler} from '@luma.gl/shadertools'; +import type {GPUVector} from '@luma.gl/tables'; +import {getWebGPUTestDevice} from '@luma.gl/test-utils'; +import test from 'test/utils/vitest-tape'; +import {vi} from 'vitest'; + +import {createLuGraphExplorerDeck} from '../../../../examples/deck/lugraph-explorer/app'; +import {LuGraphDeckEffect} from '../../../../examples/deck/lugraph-explorer/lugraph-effect'; +import {LuGraphEdgeLayer} from '../../../../examples/deck/lugraph-explorer/lugraph-edge-layer'; +import {LuGraphNodeLayer} from '../../../../examples/deck/lugraph-explorer/lugraph-node-layer'; +import {makeGraphExplorerDataset} from '../../../../examples/experimental/lugraph-explorer/graph-data'; + +test('luGraph deck.gl effect composes actual GPU analytics, zero-copy selection, and pinned layout', async tapeTest => { + const device = await getWebGPUTestDevice(); + if (!device) { + tapeTest.comment('WebGPU is not available'); + tapeTest.end(); + return; + } + + let effect: LuGraphDeckEffect | undefined; + const submitSpy = vi.spyOn(device, 'submit'); + try { + effect = new LuGraphDeckEffect(device); + tapeTest.equal( + submitSpy.mock.calls.length, + 0, + 'effect construction never submits hidden GPU work' + ); + submitSpy.mockRestore(); + + const dataset = makeGraphExplorerDataset(); + tapeTest.deepEqual( + effect.graph.sourceVertices.data.map(chunk => chunk.length), + dataset.sourceChunks.map(chunk => chunk.length), + 'original nonempty, empty, and nonempty edge source batches remain intact' + ); + tapeTest.equal( + effect.positions, + effect.layout.positions.data[0].buffer, + 'Deck nodes consume the exact progressive layout allocation without copying' + ); + tapeTest.equal( + effect.positions.usage & (Buffer.STORAGE | Buffer.VERTEX), + Buffer.STORAGE | Buffer.VERTEX, + 'shared graph coordinates are simultaneously writable storage and vertex attributes' + ); + tapeTest.equal( + effect.importance, + effect.pageRank.output.data[0].buffer, + 'PageRank stays resident' + ); + tapeTest.equal( + effect.componentLabels, + effect.components.output.data[0].buffer, + 'weak-component colors read the original GPU output allocation' + ); + tapeTest.equal( + effect.selectionMask, + effect.search.mask!.data[0].buffer, + 'Deck highlighting reads source-aligned GPU neighborhood masks' + ); + + effect.setPinnedVertex(7, true); + effect.setVertexPosition(7, [0.375, -0.25]); + effect.setNeighborhoodDepth(2); + const firstEncoder = device.createCommandEncoder({id: 'lugraph-deck-effect-real-analysis'}); + effect.analysisGraph.encode(firstEncoder, {parameters: undefined}); + effect.frameGraph.encode(firstEncoder, {parameters: undefined}); + device.submit(firstEncoder.finish()); + + const [counts, reverseCounts, componentLabels, importance, distances, mask, pins, positions] = + await Promise.all([ + readUint32Vector(effect.topology.forward.count), + readUint32Vector(effect.topology.reverse!.count), + readUint32Vector(effect.components.output), + readFloat32Vector(effect.pageRank.output), + readUint32Vector(effect.search.distances), + readUint32Vector(effect.search.mask!), + readUint32Vector(effect.layout.pinned!), + readFloat32Coordinates(effect.layout.positions) + ]); + + tapeTest.equal(counts[0], effect.graph.edgeCount, 'GPU builds every original forward edge'); + tapeTest.equal(reverseCounts[0], effect.graph.edgeCount, 'GPU builds exact reverse adjacency'); + tapeTest.equal(componentLabels[0], 0, 'first weak community keeps its stable source ID'); + tapeTest.equal(componentLabels[64], 64, 'disconnected component retains its minimum source ID'); + tapeTest.equal( + componentLabels[effect.graph.vertexCount - 1], + effect.graph.vertexCount - 1, + 'isolated node remains its own weak component' + ); + tapeTest.ok( + Math.abs(importance.reduce((sum, score) => sum + score, 0) - 1) < 5e-5, + 'actual GPU PageRank sizing remains normalized' + ); + tapeTest.equal(distances[0], 0, 'GPU neighborhood root matches the stable selected vertex'); + tapeTest.equal(mask[0], 1, 'source-aligned mask highlights the selected vertex'); + tapeTest.equal( + mask[effect.graph.vertexCount - 1], + 0, + 'disconnected vertices remain unselected' + ); + tapeTest.equal(pins[7], 1, 'dragging pins the requested resident vertex row'); + tapeTest.ok(Math.abs(positions[14] - 0.375) < 1e-6, 'pinned X survives force integration'); + tapeTest.ok(Math.abs(positions[15] + 0.25) < 1e-6, 'pinned Y survives force integration'); + + effect.setSelectedVertex(64); + effect.setNeighborhoodDepth(1); + const secondEncoder = device.createCommandEncoder({id: 'lugraph-deck-effect-selected-root'}); + effect.frameGraph.encode(secondEncoder, {parameters: undefined}); + device.submit(secondEncoder.finish()); + const [updatedDistances, updatedMask] = await Promise.all([ + readUint32Vector(effect.search.distances), + readUint32Vector(effect.search.mask!) + ]); + tapeTest.equal(updatedDistances[64], 0, 'newly picked stable source ID becomes the GPU root'); + tapeTest.equal(updatedMask[64], 1, 'new root directly updates the Deck highlight buffer'); + tapeTest.equal(updatedMask[0], 0, 'unrelated components stop receiving selection highlights'); + } finally { + submitSpy.mockRestore(); + effect?.cleanup({} as EffectContext); + } + + tapeTest.end(); +}); + +test('luGraph deck.gl renders real source-chunk layers and asynchronously picks stable GPU node IDs', async tapeTest => { + const device = await getWebGPUTestDevice(); + if (!device) { + tapeTest.comment('WebGPU is not available'); + tapeTest.end(); + return; + } + + const canvasContext = device.getDefaultCanvasContext(); + const canvas = canvasContext.canvas; + if (!(canvas instanceof HTMLCanvasElement)) { + tapeTest.fail('real deck.gl integration requires an HTML canvas presentation surface'); + tapeTest.end(); + return; + } + + const originalParent = canvas.parentNode; + const originalNextSibling = canvas.nextSibling; + const originalWidth = canvas.width; + const originalHeight = canvas.height; + const originalStyle = canvas.getAttribute('style'); + const originalDrawingBufferSize = canvasContext.getDrawingBufferSize(); + const container = document.createElement('div'); + Object.assign(container.style, { + position: 'fixed', + left: '0', + top: '0', + width: '320px', + height: '240px', + overflow: 'hidden' + }); + document.body.appendChild(container); + container.appendChild(canvas); + canvas.width = 320; + canvas.height = 240; + canvas.style.width = '320px'; + canvas.style.height = '240px'; + canvasContext.setDrawingBufferSize(320, 240); + + let deck: ReturnType | undefined; + const originalShaderAssembler = ShaderAssembler.getDefaultShaderAssembler; + try { + deck = createLuGraphExplorerDeck(container, {device}); + await waitForDeckEffect(deck); + tapeTest.equal( + ShaderAssembler.getDefaultShaderAssembler, + originalShaderAssembler, + 'the real application restores luma.gl shader-assembler isolation before Deck is ready' + ); + + const effect = deck.props.effects?.[0]; + tapeTest.ok( + effect instanceof LuGraphDeckEffect, + 'actual Deck owns a real resident graph effect' + ); + if (!(effect instanceof LuGraphDeckEffect)) { + throw new Error('Deck did not initialize its luGraph WebGPU effect'); + } + + const layers = deck.props.layers ?? []; + const edgeLayers = layers.filter(layer => layer instanceof LuGraphEdgeLayer); + const nodeLayer = layers.find(layer => layer instanceof LuGraphNodeLayer); + tapeTest.equal( + edgeLayers.length, + 2, + 'Deck creates exactly one layer per original nonempty edge chunk' + ); + tapeTest.deepEqual( + edgeLayers.map(layer => layer.id), + ['lugraph-edges-0', 'lugraph-edges-2'], + 'the empty source batch is preserved and not rendered or concatenated' + ); + tapeTest.ok( + nodeLayer instanceof LuGraphNodeLayer, + 'Deck instantiates the actual custom node layer' + ); + if (!(nodeLayer instanceof LuGraphNodeLayer)) { + throw new Error('Deck did not initialize its luGraph node layer'); + } + tapeTest.equal( + nodeLayer.getNumInstances(), + effect.graph.vertexCount, + 'Deck receives the real GPU vertex instance count despite the empty CPU data array' + ); + tapeTest.deepEqual( + edgeLayers.map(layer => layer.getNumInstances()), + [effect.graph.sourceVertices.data[0].length, effect.graph.sourceVertices.data[2].length], + 'Deck receives actual source-chunk edge counts without a CPU edge array' + ); + tapeTest.equal( + nodeLayer.props.positions, + effect.positions, + 'node attributes share exact GPU layout storage' + ); + tapeTest.equal( + edgeLayers[0].props.sourceVertices, + effect.graph.sourceVertices.data[0].buffer, + 'first edge model binds its original caller-owned source allocation' + ); + tapeTest.equal( + edgeLayers[1].props.sourceVertices, + effect.graph.sourceVertices.data[2].buffer, + 'second edge model binds the untouched third source partition' + ); + + await waitForDeckLayerModels([nodeLayer, ...edgeLayers]); + deck.setProps({_animate: false}); + effect.setPinnedVertex(0, true); + effect.setVertexPosition(0, [0, 0]); + const positionsReadSpy = vi.spyOn(effect.positions, 'readAsync'); + const importanceReadSpy = vi.spyOn(effect.importance, 'readAsync'); + try { + deck.redraw('real WebGPU luGraph deck rendering and picking regression'); + tapeTest.ok( + nodeLayer.getModels()[0]?.pipeline, + 'actual node WGSL and vertex pipeline compile' + ); + for (const edgeLayer of edgeLayers) { + tapeTest.ok( + edgeLayer.getModels()[0]?.pipeline, + 'actual original-chunk edge WGSL pipeline compiles' + ); + } + + const projectedOrigin = deck.getViewports()[0].project([0, 0, 0]); + const pick = await deck.pickObjectAsync({ + x: Math.floor(projectedOrigin[0]), + y: Math.floor(projectedOrigin[1]), + radius: 3, + layerIds: ['lugraph-nodes'] + }); + + tapeTest.ok(deck.width > 1 && deck.height > 1, 'Deck uses a real multi-pixel viewport'); + tapeTest.equal( + pick?.index, + 0, + 'real asynchronous WebGPU Deck picking recovers stable source ID zero' + ); + tapeTest.equal( + pick?.layer?.id, + 'lugraph-nodes', + 'GPU picking identifies the actual node layer' + ); + tapeTest.equal( + positionsReadSpy.mock.calls.length, + 0, + 'normal rendering and explicit Deck picking never read graph positions back' + ); + tapeTest.equal( + importanceReadSpy.mock.calls.length, + 0, + 'node sizing and picking never download GPU PageRank scores' + ); + } finally { + positionsReadSpy.mockRestore(); + importanceReadSpy.mockRestore(); + } + } finally { + deck?.finalize(); + canvas.width = originalWidth; + canvas.height = originalHeight; + if (originalStyle === null) canvas.removeAttribute('style'); + else canvas.setAttribute('style', originalStyle); + canvasContext.setDrawingBufferSize(originalDrawingBufferSize[0], originalDrawingBufferSize[1]); + if (originalParent) { + originalParent.insertBefore(canvas, originalNextSibling); + } else { + canvas.remove(); + } + container.remove(); + } + + tapeTest.equal( + ShaderAssembler.getDefaultShaderAssembler, + originalShaderAssembler, + 'finalizing the real Deck application never leaves a global shader-assembler override' + ); + + tapeTest.end(); +}); + +async function waitForDeckEffect( + deck: ReturnType +): Promise { + const deadline = performance.now() + 5_000; + while (!(deck.props.effects?.[0] instanceof LuGraphDeckEffect)) { + if (performance.now() >= deadline) { + throw new Error('The real WebGPU Deck did not finish initializing its luGraph effect'); + } + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } +} + +async function waitForDeckLayerModels( + layers: Array +): Promise { + const deadline = performance.now() + 5_000; + while (layers.some(layer => !getDeckLayerModelState(layer)?.pipeline)) { + if (performance.now() >= deadline) { + throw new Error('The real WebGPU Deck did not initialize its graph node and edge models'); + } + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } + while (layers.some(layer => getDeckLayerModelState(layer)?.pipeline?.linkStatus === 'pending')) { + if (performance.now() >= deadline) { + throw new Error('The real WebGPU graph node and edge pipelines did not finish linking'); + } + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } + for (const layer of layers) { + if (getDeckLayerModelState(layer)?.pipeline?.linkStatus !== 'success') { + throw new Error(`The real WebGPU ${layer.id} graph pipeline failed to link`); + } + } +} + +function getDeckLayerModelState( + layer: LuGraphNodeLayer | LuGraphEdgeLayer +): {pipeline?: {linkStatus?: string}} | undefined { + return (layer as unknown as {state?: {model?: {pipeline?: {linkStatus?: string}}}}).state?.model; +} + +async function readUint32Vector(vector: GPUVector<'uint32'>): Promise { + const chunk = vector.data[0]; + const bytes = await (chunk.buffer as Buffer).readAsync(chunk.byteOffset, vector.length * 4); + return Array.from(new Uint32Array(bytes.buffer, bytes.byteOffset, vector.length)); +} + +async function readFloat32Vector(vector: GPUVector<'float32'>): Promise { + const chunk = vector.data[0]; + const bytes = await (chunk.buffer as Buffer).readAsync(chunk.byteOffset, vector.length * 4); + return Array.from(new Float32Array(bytes.buffer, bytes.byteOffset, vector.length)); +} + +async function readFloat32Coordinates(vector: GPUVector<'float32x2'>): Promise { + const chunk = vector.data[0]; + const bytes = await (chunk.buffer as Buffer).readAsync(chunk.byteOffset, vector.length * 8); + return Array.from(new Float32Array(bytes.buffer, bytes.byteOffset, vector.length * 2)); +} diff --git a/website/content/examples/deck/lugraph-explorer.mdx b/website/content/examples/deck/lugraph-explorer.mdx new file mode 100644 index 0000000000..aaa6936de3 --- /dev/null +++ b/website/content/examples/deck/lugraph-explorer.mdx @@ -0,0 +1,31 @@ +--- +title: luGraph + deck.gl Network Explorer +description: Analyze, progressively lay out, and directly render one GPU-resident property graph using real deck.gl layers and asynchronous WebGPU picking. +sidebar_custom_props: + description: Explore a resident graph through GPU PageRank, components, layout, neighborhood selection, and direct deck.gl layers. + backends: [webgpu] + difficulty: advanced + maturity: experimental + topics: [compute, graphs, visualization, picking, deck.gl] +--- + +import {DeckLuGraphExplorerExample} from '@site/src/examples'; + + + +This optional integration keeps graph analytics inside `@luma.gl/experimental/lugraph` and +deck.gl-specific rendering inside an unpackaged example. A real deck.gl `Effect` builds forward and +reverse GPU adjacency, computes dangling-aware PageRank and weak-component labels once, then +progressively advances bounded neighborhood search and force-directed layout on deck.gl's existing +WebGPU command encoder. + +The custom node layer binds the exact `float32x2` position allocation as its instance vertex +attribute. PageRank scores control node size, weak-component identifiers control color, and +source-aligned breadth-first outputs highlight selected neighborhoods. One custom edge layer reads +each original nonempty source/target GPU chunk directly; the empty partition is retained in the +graph and never repacked. + +Use the orthographic view to pan and zoom. Click a node to select its neighborhood, drag to pin its +GPU-resident coordinates, adjust the hop limit, clear pins, or reset the deterministic layout. +deck.gl's asynchronous WebGPU picking resolves stable original vertex identifiers only when +interaction requests a pick; ordinary animation never reads graph columns back to the CPU. diff --git a/website/content/examples/table-of-contents.json b/website/content/examples/table-of-contents.json index 96debd2f54..35ddbe9e07 100644 --- a/website/content/examples/table-of-contents.json +++ b/website/content/examples/table-of-contents.json @@ -112,6 +112,11 @@ "id": "deck/luspatial-taxi", "label": "luProj + luSpatial: Taxi Explorer" }, + { + "type": "doc", + "id": "deck/lugraph-explorer", + "label": "luGraph + deck.gl: Network Explorer" + }, "experimental/gpt-2" ] } diff --git a/website/src/example-thumbnails.ts b/website/src/example-thumbnails.ts index fd341e5978..2260aa1ffe 100644 --- a/website/src/example-thumbnails.ts +++ b/website/src/example-thumbnails.ts @@ -4,6 +4,7 @@ const EXAMPLE_THUMBNAIL_ALIASES: Readonly> = { 'v10/gpgpu': 'gpu-tables/gpu-vector-storage-particles', + 'deck/lugraph-explorer': 'showcase/packet-spraying', 'experimental/lugraph-explorer': 'showcase/packet-spraying', 'experimental/gpu-trace-scene': 'experimental/gpu-trace-viewer', 'experimental/gpu-scene-graph': 'experimental/gpu-frustum-culling', diff --git a/website/src/examples.tsx b/website/src/examples.tsx index aaf896ccc2..d84b7b7d65 100644 --- a/website/src/examples.tsx +++ b/website/src/examples.tsx @@ -118,6 +118,7 @@ const loadMillionRowCrossfilterExample = () => import('../../examples/showcase/million-row-crossfilter/app'); const loadRasterLabExample = () => import('../../examples/showcase/raster-lab/app'); const loadLuSpatialTaxiExample = () => import('../../examples/deck/luspatial-taxi/app'); +const loadLuGraphExplorerDeckExample = () => import('../../examples/deck/lugraph-explorer/app'); const loadFP64Example = () => import('../../examples/experimental/fp64/app'); type WebsiteExampleProps = React.PropsWithChildren< @@ -468,6 +469,41 @@ export const DeckLuSpatialTaxiExample: React.FC = ({ ); }; +/** Loads the optional deck.gl graph integration only when its WebGPU example is opened. */ +export const DeckLuGraphExplorerExample: React.FC = ({ + embedded = false +}) => { + const {module, errorMessage} = useDeferredExampleModule(loadLuGraphExplorerDeckExample); + + if (!module) { + return ( + + ); + } + + return ( + + ); +}; + type DeckArrowLayerExampleId = 'path' | 'polygon' | 'text'; const DECK_ARROW_LAYER_DOC_EXAMPLES: Array<{ From 2676383f61f0e8197c3247ebde8987a151284c23 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 4 Aug 2026 23:01:51 -0400 Subject: [PATCH 2/7] test(examples): isolate deck WebGPU presentation device --- modules/experimental/test/lugraph/lu-graph-deck.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/experimental/test/lugraph/lu-graph-deck.spec.ts b/modules/experimental/test/lugraph/lu-graph-deck.spec.ts index 44a7196487..5630cd1538 100644 --- a/modules/experimental/test/lugraph/lu-graph-deck.spec.ts +++ b/modules/experimental/test/lugraph/lu-graph-deck.spec.ts @@ -132,7 +132,7 @@ test('luGraph deck.gl effect composes actual GPU analytics, zero-copy selection, }); test('luGraph deck.gl renders real source-chunk layers and asynchronously picks stable GPU node IDs', async tapeTest => { - const device = await getWebGPUTestDevice(); + const device = await getWebGPUTestDevice('core'); if (!device) { tapeTest.comment('WebGPU is not available'); tapeTest.end(); From f7bc8b5bf4a56c108b70db73ae698410983de068 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 4 Aug 2026 23:13:25 -0400 Subject: [PATCH 3/7] test(examples): avoid uncontrolled deck animation during GPU picking --- modules/experimental/test/lugraph/lu-graph-deck.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/experimental/test/lugraph/lu-graph-deck.spec.ts b/modules/experimental/test/lugraph/lu-graph-deck.spec.ts index 5630cd1538..b90f74f1df 100644 --- a/modules/experimental/test/lugraph/lu-graph-deck.spec.ts +++ b/modules/experimental/test/lugraph/lu-graph-deck.spec.ts @@ -174,6 +174,7 @@ test('luGraph deck.gl renders real source-chunk layers and asynchronously picks const originalShaderAssembler = ShaderAssembler.getDefaultShaderAssembler; try { deck = createLuGraphExplorerDeck(container, {device}); + deck.setProps({_animate: false}); await waitForDeckEffect(deck); tapeTest.equal( ShaderAssembler.getDefaultShaderAssembler, @@ -237,7 +238,6 @@ test('luGraph deck.gl renders real source-chunk layers and asynchronously picks ); await waitForDeckLayerModels([nodeLayer, ...edgeLayers]); - deck.setProps({_animate: false}); effect.setPinnedVertex(0, true); effect.setVertexPosition(0, [0, 0]); const positionsReadSpy = vi.spyOn(effect.positions, 'readAsync'); From 82416eeb0485bf90249943f32f130b226e4d855f Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 4 Aug 2026 23:20:29 -0400 Subject: [PATCH 4/7] test(examples): bound software GPU deck presentation workload --- examples/deck/lugraph-explorer/app.ts | 12 +++++++++--- .../experimental/test/lugraph/lu-graph-deck.spec.ts | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/examples/deck/lugraph-explorer/app.ts b/examples/deck/lugraph-explorer/app.ts index 5570b334c7..c45d86609c 100644 --- a/examples/deck/lugraph-explorer/app.ts +++ b/examples/deck/lugraph-explorer/app.ts @@ -11,6 +11,7 @@ import { } from '@luma.gl/shadertools'; import {ArrowDeck} from '../arrow-deck'; import {getDeckExampleProps, type DeckExampleDeviceOptions} from '../deck-example-device'; +import type {GraphExplorerDataset} from '../../experimental/lugraph-explorer/graph-data'; import {LuGraphDeckEffect} from './lugraph-effect'; import {LuGraphEdgeLayer} from './lugraph-edge-layer'; import {LuGraphNodeLayer} from './lugraph-node-layer'; @@ -22,6 +23,10 @@ type GraphExplorerControls = { destroy: () => void; }; +type LuGraphExplorerDeckOptions = DeckExampleDeviceOptions & { + dataset?: GraphExplorerDataset; +}; + /** * Creates an optional deck.gl explorer using resident luGraph analytics and original edge chunks. * @@ -31,8 +36,9 @@ type GraphExplorerControls = { */ export function createLuGraphExplorerDeck( parent?: HTMLDivElement, - options: DeckExampleDeviceOptions = {} + options: LuGraphExplorerDeckOptions = {} ): ArrowDeck { + const {dataset, ...deviceOptions} = options; const ownsContainer = !parent; const container = parent ?? createStandaloneContainer(); if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; @@ -48,7 +54,7 @@ export function createLuGraphExplorerDeck( deck = new ArrowDeck({ parent: container, - ...getDeckExampleProps({...options, deviceType: 'webgpu'}), + ...getDeckExampleProps({...deviceOptions, deviceType: 'webgpu'}), views: new OrthographicView({id: 'lugraph-orthographic'}), initialViewState: {target: [0, 0, 0], zoom: 7.7, minZoom: 5, maxZoom: 11}, controller: { @@ -101,7 +107,7 @@ export function createLuGraphExplorerDeck( }, onLoad: ({deck: loadedDeck, device}) => { if (device.type !== 'webgpu') throw new Error('luGraph deck explorer requires WebGPU'); - effect = new LuGraphDeckEffect(device); + effect = new LuGraphDeckEffect(device, dataset); const edgeLayers = effect.graph.sourceVertices.data.flatMap((source, chunkIndex) => { if (source.length === 0) return []; const target = effect!.graph.targetVertices.data[chunkIndex]; diff --git a/modules/experimental/test/lugraph/lu-graph-deck.spec.ts b/modules/experimental/test/lugraph/lu-graph-deck.spec.ts index b90f74f1df..8dd708189a 100644 --- a/modules/experimental/test/lugraph/lu-graph-deck.spec.ts +++ b/modules/experimental/test/lugraph/lu-graph-deck.spec.ts @@ -173,7 +173,7 @@ test('luGraph deck.gl renders real source-chunk layers and asynchronously picks let deck: ReturnType | undefined; const originalShaderAssembler = ShaderAssembler.getDefaultShaderAssembler; try { - deck = createLuGraphExplorerDeck(container, {device}); + deck = createLuGraphExplorerDeck(container, {device, dataset: makeGraphExplorerDataset(8)}); deck.setProps({_animate: false}); await waitForDeckEffect(deck); tapeTest.equal( From 80d1aa206ace5672458f6bbfa1b7796d89f84045 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 4 Aug 2026 23:23:52 -0400 Subject: [PATCH 5/7] test(examples): isolate deck rendering from unstable GPU presentation --- docs/api-reference/experimental/lugraph.md | 36 ++++-- examples/deck/lugraph-explorer/app.ts | 2 +- .../lugraph-explorer/lugraph-edge-layer.ts | 2 +- .../deck/lugraph-explorer/lugraph-effect.ts | 2 +- .../lugraph-explorer/lugraph-node-layer.ts | 2 +- .../test/lugraph/lu-graph-deck.node.spec.ts | 88 ++++++++++++- .../test/lugraph/lu-graph-deck.spec.ts | 23 +++- .../gpgpu-catalog-navigation.node.spec.ts | 1 + .../examples/deck/lugraph-explorer.mdx | 116 +++++++++++++++--- 9 files changed, 237 insertions(+), 35 deletions(-) diff --git a/docs/api-reference/experimental/lugraph.md b/docs/api-reference/experimental/lugraph.md index 4463339a87..a2b5ff9fb9 100644 --- a/docs/api-reference/experimental/lugraph.md +++ b/docs/api-reference/experimental/lugraph.md @@ -72,18 +72,30 @@ WebGPU-only educational example, not a large-graph performance benchmark: its ex ### Use luGraph from deck.gl without copying graph buffers -The optional [luGraph + deck.gl network explorer](/examples/deck/lugraph-explorer) demonstrates -the same resident analytics through a real deck.gl `Effect`, custom node and edge layers, -`OrthographicView` navigation, GPU neighborhood selection, progressive layout, pinning, and -asynchronous WebGPU picking. The effect encodes one-time topology, PageRank, and weak components -followed by per-frame breadth-first selection and force integration into deck.gl's own command -encoder; deck.gl remains responsible for queue submission. - -Node positions are simultaneously the layout's writable storage allocation and the node layer's -`float32x2` instance vertex attribute. Importance scores, component labels, hop distances, and the -selection mask remain storage inputs; each nonempty original edge partition gets its own edge layer, -without concatenation, buffer copies, or per-frame graph readback. Only explicitly requested -deck.gl picking results cross to JavaScript. +**Question: How can an existing deck.gl application explore a graph without converting GPU +relationships, analytics, or moving node positions into JavaScript objects?** + +The optional [luGraph + deck.gl network explorer](/examples/deck/lugraph-explorer) answers that +question with a real deck.gl `Effect`, custom node and edge `Layer` implementations, an +`OrthographicView`, and deck.gl's existing interaction and asynchronous WebGPU picking systems. +Use it when a social-network, service-dependency, fraud-investigation, or citation visualization +already uses deck.gl and needs GPU graph results to become directly drawable attributes. + +The effect first encodes forward and reverse adjacency, normalized PageRank, and weak components. +Later frames encode bounded neighborhood selection and exact force-directed layout into +deck.gl's own command encoder; deck.gl remains responsible for queue submission. The writable layout +allocation is also the node layer's `float32x2` instance vertex attribute. PageRank scores, +component labels, hop distances, and the selection mask remain GPU storage inputs; each nonempty +original edge partition gets its own edge layer, +without concatenation, buffer copies, or per-frame graph readback. + +The deterministic example fixture is uploaded once. Selecting, pinning, dragging, and changing +neighborhood depth write only the necessary interaction controls or coordinates; they do not +download graph columns. An explicitly requested native deck.gl pick returns the selected original +vertex identifier to JavaScript. Its implementation and transfer size belong to deck.gl; it is +separate from the native explorer's custom **8-byte** integer-picking path above. The example uses +exact `O(V² + E)` layout, not the optional spatial approximation, and does not promise large-graph +throughput. The integration lives exclusively under `examples/deck/lugraph-explorer`; neither `@luma.gl/experimental` nor its optional graph entry point depends on or imports deck.gl. diff --git a/examples/deck/lugraph-explorer/app.ts b/examples/deck/lugraph-explorer/app.ts index c45d86609c..3041c477d1 100644 --- a/examples/deck/lugraph-explorer/app.ts +++ b/examples/deck/lugraph-explorer/app.ts @@ -1,6 +1,6 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import {OrthographicView, type PickingInfo} from '@deck.gl/core'; import {Buffer, type Device} from '@luma.gl/core'; diff --git a/examples/deck/lugraph-explorer/lugraph-edge-layer.ts b/examples/deck/lugraph-explorer/lugraph-edge-layer.ts index 3e65a9b21f..f546214830 100644 --- a/examples/deck/lugraph-explorer/lugraph-edge-layer.ts +++ b/examples/deck/lugraph-explorer/lugraph-edge-layer.ts @@ -1,6 +1,6 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import {Layer, project32, type LayerContext, type LayerProps} from '@deck.gl/core'; import {Buffer, type RenderPass} from '@luma.gl/core'; diff --git a/examples/deck/lugraph-explorer/lugraph-effect.ts b/examples/deck/lugraph-explorer/lugraph-effect.ts index 768b346c15..3480e05338 100644 --- a/examples/deck/lugraph-explorer/lugraph-effect.ts +++ b/examples/deck/lugraph-explorer/lugraph-effect.ts @@ -1,6 +1,6 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import type {Effect, EffectContext} from '@deck.gl/core'; import {Buffer, type Device} from '@luma.gl/core'; diff --git a/examples/deck/lugraph-explorer/lugraph-node-layer.ts b/examples/deck/lugraph-explorer/lugraph-node-layer.ts index 8641eea707..9a5f0ed6d7 100644 --- a/examples/deck/lugraph-explorer/lugraph-node-layer.ts +++ b/examples/deck/lugraph-explorer/lugraph-node-layer.ts @@ -1,6 +1,6 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import { Layer, diff --git a/modules/experimental/test/lugraph/lu-graph-deck.node.spec.ts b/modules/experimental/test/lugraph/lu-graph-deck.node.spec.ts index 738e2c30fe..fd92c80f82 100644 --- a/modules/experimental/test/lugraph/lu-graph-deck.node.spec.ts +++ b/modules/experimental/test/lugraph/lu-graph-deck.node.spec.ts @@ -1,6 +1,6 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import {existsSync, readFileSync} from 'node:fs'; @@ -117,6 +117,10 @@ describe('optional luGraph deck.gl gallery and API guide', () => { ) ) as ExampleContentsEntry[]; const gpuCategory = contents.find(category => category.label === 'GPGPU'); + const graphModulesCategory = gpuCategory?.items?.find( + (item): item is ExampleContentsEntry => + typeof item !== 'string' && item.label === 'GPGPU Graph Modules' + ); expect(examples).toContain( "const loadLuGraphExplorerDeckExample = () => import('../../examples/deck/lugraph-explorer/app')" @@ -125,7 +129,7 @@ describe('optional luGraph deck.gl gallery and API guide', () => { expect(examples).toContain('createDeck: module.createLuGraphExplorerDeck'); expect(examples).not.toMatch(/^import\s+\{createLuGraphExplorerDeck\}\s+from/m); expect( - gpuCategory?.items?.some( + graphModulesCategory?.items?.some( item => typeof item !== 'string' && item.id === 'deck/lugraph-explorer' ) ).toBe(true); @@ -155,4 +159,84 @@ describe('optional luGraph deck.gl gallery and API guide', () => { expect(apiGuide).toContain("deck.gl's own command"); expect(apiGuide).toContain('without concatenation, buffer copies, or per-frame graph readback'); }); + + test('explains when deck.gl graph integration is useful and how to explore it', () => { + const examplePage = readFileSync( + new URL('../../../../website/content/examples/deck/lugraph-explorer.mdx', import.meta.url), + 'utf8' + ); + + for (const section of [ + '## Overview', + '## Why combine luGraph and deck.gl?', + '## When should I use this integration?', + '## How the GPU-resident frame works', + '## Try the controls', + '## What actually stays on the GPU', + '## Boundaries and performance' + ]) { + expect(examplePage, section).toContain(section); + } + + for (const useCase of [ + 'Social and communication networks', + 'Service and package dependencies', + 'Transaction and fraud investigations', + 'Knowledge and citation maps' + ]) { + expect(examplePage, useCase).toContain(useCase); + } + + for (const control of [ + '**Hover a node**', + '**Click a node**', + '**Adjust neighborhood depth**', + '**Drag a node**', + '**Release pins**', + '**Reset layout**', + '**Pan and zoom**' + ]) { + expect(examplePage, control).toContain(control); + } + + expect(examplePage).toContain('128-vertex'); + expect(examplePage).toContain('`O(V² + E)`'); + expect(examplePage).toContain('not a large-graph'); + expect(examplePage).toContain('does not enable the optional spatial approximation'); + expect(examplePage).toContain('/docs/api-reference/experimental/lugraph'); + expect(examplePage).toContain('/examples/experimental/lugraph-explorer'); + }); + + test('documents direct GPU frame ownership and honest asynchronous deck.gl picking', () => { + const examplePage = readFileSync( + new URL('../../../../website/content/examples/deck/lugraph-explorer.mdx', import.meta.url), + 'utf8' + ); + const apiGuide = readFileSync( + new URL('../../../../docs/api-reference/experimental/lugraph.md', import.meta.url), + 'utf8' + ); + + expect(examplePage).toContain('Upload the demonstration fixture once'); + expect(examplePage).toContain('intentionally empty batch'); + expect(examplePage).toContain('LuGraphDeckEffect'); + expect(examplePage).toContain('LuGraphNodeLayer'); + expect(examplePage).toContain('LuGraphEdgeLayer'); + expect(examplePage).toContain('not\n community-detection results'); + expect(examplePage).toContain('deck.gl owns queue'); + expect(examplePage).toContain('`Buffer.STORAGE` and `Buffer.VERTEX`'); + expect(examplePage).toContain('returns a requested selected-vertex result to JavaScript'); + expect(examplePage).toContain('`PickingInfo.index`'); + expect(examplePage).toContain('deck.gl owns that picking implementation and its transfer size'); + + expect(apiGuide).toContain('## Overview'); + expect(apiGuide).toContain('## Why keep a graph on the GPU?'); + expect(apiGuide).toContain('## When should I use luGraph?'); + expect(apiGuide).toContain(''); + expect(apiGuide).toContain(''); + expect(apiGuide).toContain('## Approximate distant forces with LuGraphSpatialForceLayout'); + expect(apiGuide).toContain('### Use luGraph from deck.gl without copying graph buffers'); + expect(apiGuide).toContain("separate from the native explorer's custom **8-byte**"); + expect(apiGuide).toContain('The deterministic example fixture is uploaded once'); + }); }); diff --git a/modules/experimental/test/lugraph/lu-graph-deck.spec.ts b/modules/experimental/test/lugraph/lu-graph-deck.spec.ts index 8dd708189a..3c7743c03a 100644 --- a/modules/experimental/test/lugraph/lu-graph-deck.spec.ts +++ b/modules/experimental/test/lugraph/lu-graph-deck.spec.ts @@ -1,6 +1,6 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import type {EffectContext} from '@deck.gl/core'; import {Buffer} from '@luma.gl/core'; @@ -170,6 +170,18 @@ test('luGraph deck.gl renders real source-chunk layers and asynchronously picks canvas.style.height = '240px'; canvasContext.setDrawingBufferSize(320, 240); + const framebuffer = device.createFramebuffer({ + id: 'lugraph-deck-presentation-test-framebuffer', + width: 320, + height: 240, + colorAttachments: [device.preferredColorFormat], + depthStencilAttachment: 'depth24plus' + }); + // Shared SwiftShader suites can outlive Dawn's external presentation instance. Retain the real + // Deck render pass, layer pipelines, queue submission, and native GPU picking on an owned target. + const currentFramebuffer = vi + .spyOn(canvasContext, 'getCurrentFramebuffer') + .mockReturnValue(framebuffer); let deck: ReturnType | undefined; const originalShaderAssembler = ShaderAssembler.getDefaultShaderAssembler; try { @@ -242,8 +254,14 @@ test('luGraph deck.gl renders real source-chunk layers and asynchronously picks effect.setVertexPosition(0, [0, 0]); const positionsReadSpy = vi.spyOn(effect.positions, 'readAsync'); const importanceReadSpy = vi.spyOn(effect.importance, 'readAsync'); + const submitSpy = vi.spyOn(device, 'submit'); try { deck.redraw('real WebGPU luGraph deck rendering and picking regression'); + tapeTest.ok( + currentFramebuffer.mock.calls.length > 0, + 'real Deck rendering targets an owned WebGPU framebuffer instead of a stale surface' + ); + tapeTest.ok(submitSpy.mock.calls.length > 0, 'real Deck layer passes submit GPU commands'); tapeTest.ok( nodeLayer.getModels()[0]?.pipeline, 'actual node WGSL and vertex pipeline compile' @@ -287,9 +305,12 @@ test('luGraph deck.gl renders real source-chunk layers and asynchronously picks } finally { positionsReadSpy.mockRestore(); importanceReadSpy.mockRestore(); + submitSpy.mockRestore(); } } finally { deck?.finalize(); + currentFramebuffer.mockRestore(); + framebuffer.destroy(); canvas.width = originalWidth; canvas.height = originalHeight; if (originalStyle === null) canvas.removeAttribute('style'); diff --git a/test/examples/gpgpu-catalog-navigation.node.spec.ts b/test/examples/gpgpu-catalog-navigation.node.spec.ts index acad84e607..d508963d85 100644 --- a/test/examples/gpgpu-catalog-navigation.node.spec.ts +++ b/test/examples/gpgpu-catalog-navigation.node.spec.ts @@ -219,6 +219,7 @@ describe('GPGPU example catalog navigation', () => { 'showcase/raster-lab', 'showcase/billion-point-spatial-atlas', 'deck/luspatial-taxi', + 'deck/lugraph-explorer', 'experimental/gpt-2' ]); }); diff --git a/website/content/examples/deck/lugraph-explorer.mdx b/website/content/examples/deck/lugraph-explorer.mdx index aaa6936de3..3c8cdb07f4 100644 --- a/website/content/examples/deck/lugraph-explorer.mdx +++ b/website/content/examples/deck/lugraph-explorer.mdx @@ -11,21 +11,105 @@ sidebar_custom_props: import {DeckLuGraphExplorerExample} from '@site/src/examples'; +## Overview + +A social network, service-dependency map, transaction investigation, or citation graph becomes +easier to understand when its relationships are visible. This WebGPU-only example draws a +deterministic 128-vertex graph with real deck.gl layers while its adjacency, importance scores, +connected groups, neighborhood selection, and moving positions remain on the GPU. + -This optional integration keeps graph analytics inside `@luma.gl/experimental/lugraph` and -deck.gl-specific rendering inside an unpackaged example. A real deck.gl `Effect` builds forward and -reverse GPU adjacency, computes dangling-aware PageRank and weak-component labels once, then -progressively advances bounded neighborhood search and force-directed layout on deck.gl's existing -WebGPU command encoder. - -The custom node layer binds the exact `float32x2` position allocation as its instance vertex -attribute. PageRank scores control node size, weak-component identifiers control color, and -source-aligned breadth-first outputs highlight selected neighborhoods. One custom edge layer reads -each original nonempty source/target GPU chunk directly; the empty partition is retained in the -graph and never repacked. - -Use the orthographic view to pan and zoom. Click a node to select its neighborhood, drag to pin its -GPU-resident coordinates, adjust the hop limit, clear pins, or reset the deterministic layout. -deck.gl's asynchronous WebGPU picking resolves stable original vertex identifiers only when -interaction requests a pick; ordinary animation never reads graph columns back to the CPU. +## Why combine luGraph and deck.gl? + +An application that already owns GPU graph data should not need to download every edge, construct a +JavaScript object for every vertex, and upload changing positions again just to render a network. +luGraph computes relationship data; deck.gl already understands application views, layer +lifecycle, camera controls, picking, and GPU drawing. This integration lets each system keep its +existing responsibility while sharing the same actual GPU allocations. + +A real deck.gl `Effect` schedules luGraph work inside the existing deck.gl frame. Custom node and +edge `Layer` implementations consume its results directly, and an `OrthographicView` provides +familiar pan and zoom. Graph analytics remain in `@luma.gl/experimental/lugraph`; the optional +deck.gl integration stays entirely inside this unpackaged example. + +## When should I use this integration? + +Use this approach when a deck.gl application already has GPU-resident relationship data or will +reuse graph analytics across many interactive frames: + +- **Social and communication networks:** reveal influential accounts, disconnected groups, and + introductions within a selected number of hops. +- **Service and package dependencies:** inspect a failed service's reachable dependencies and + identify structurally important systems. +- **Transaction and fraud investigations:** follow relationships around an account and expose + connected groups of counterparties. +- **Knowledge and citation maps:** compare incoming PageRank influence with visual neighborhoods + and relationship structure. + +A small one-off graph that starts and stays in JavaScript may be simpler to process on the CPU. +Initial GPU upload, pipeline compilation, queue submission, and requested picking still have real +costs; keeping a graph resident does not automatically make every workload faster. + +## How the GPU-resident frame works + +1. **Upload the demonstration fixture once.** Its source and target relationship columns retain + their three original aligned batches: one nonempty batch, one intentionally empty batch, and a + second nonempty batch. +2. **Analyze the graph in the first frame.** `LuGraphDeckEffect` builds forward and reverse + compressed adjacency, computes normalized dangling-aware PageRank, and labels weakly connected + components. Weak components describe connectivity when edge direction is ignored; they are not + community-detection results. +3. **Update interaction and layout in later frames.** GPU breadth-first search highlights the + selected vertex's bounded neighborhood, and exact force-directed layout progressively advances + the existing node positions. +4. **Draw the same allocations through real deck.gl layers.** `LuGraphNodeLayer` binds the actual + `float32x2` layout allocation as an instanced vertex attribute. PageRank determines node size, + weak-component labels determine color, and resident distances and selection masks determine + highlighting. One `LuGraphEdgeLayer` draws each nonempty original source/target batch directly; + the empty middle batch remains in the graph and is never concatenated or repacked. + +The effect records compute into deck.gl's own WebGPU command encoder; deck.gl owns queue +submission. The node-position allocation has both `Buffer.STORAGE` and `Buffer.VERTEX` usage, so +simulation can update the exact same memory that the node layer fetches as an instance attribute. + +## Try the controls + +- **Hover a node** to inspect its stable original vertex identifier and whether it is pinned. +- **Click a node** to select it and highlight its GPU-computed neighborhood; click empty space to + clear the selection. +- **Adjust neighborhood depth** to include between zero and eight unweighted relationship hops. +- **Drag a node** to move its existing GPU coordinates and pin it while the remaining graph moves. +- **Release pins** to let every pinned vertex move again. +- **Reset layout** to restore deterministic initial positions for unpinned vertices. +- **Pan and zoom** by dragging empty space or using the scroll wheel in the orthographic view. + +These controls update small existing GPU selection, depth, pin, or position buffers; they do not +rebuild a JavaScript graph or download vertex columns for every animation frame. + +## What actually stays on the GPU + +After the initial fixture upload, original source and target edge chunks, compressed adjacency, +PageRank scores, weak-component identifiers, breadth-first distances, neighborhood masks, and +progressive layout positions stay resident. Rendering does not concatenate edge batches, copy +positions into a second vertex allocation, or read graph columns back to the CPU. + +Interaction is not magically transfer-free: JavaScript writes explicit selection controls, pin +flags, or dragged coordinates when requested. deck.gl's native asynchronous WebGPU picking also +returns a requested selected-vertex result to JavaScript. The returned `PickingInfo.index` is the +stable original vertex identifier; it is not a download of graph positions, PageRank scores, or +edge columns. deck.gl owns that picking implementation and its transfer size, so this example +does not claim the separate native luGraph explorer's custom integer-readback contract. + +## Boundaries and performance + +This example requires WebGPU and uses exact force-directed layout, which costs `O(V² + E)` per +iteration for `V` vertices and `E` edges. Its deliberately small 128-vertex network demonstrates +correct GPU ownership, native deck.gl integration, and interaction; it is not a large-graph +benchmark and does not enable the optional spatial approximation or provide a CPU fallback. + +See the [luGraph API guide](/docs/api-reference/experimental/lugraph) for graph ownership, +overflow handling, the separate optional spatial-layout approximation, and opt-in live CPU/GPU +benchmarks. The [native luGraph explorer](/examples/experimental/lugraph-explorer) demonstrates a +different renderer, a richer graph-inspector dashboard, and its own explicitly documented picking +path. From b7f1ae04151c64f9fddf3ffb8e429e7fd8ec02f3 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Fri, 7 Aug 2026 09:26:58 -0400 Subject: [PATCH 6/7] refactor(deck): isolate luGraph adapters in private deck package --- docs/api-reference/experimental/lugraph.md | 7 +- examples/deck/lugraph-explorer/app.ts | 18 ++++-- modules/deck-luspatial/README.md | 40 +++++++++++- modules/deck-luspatial/package.json | 5 +- modules/deck-luspatial/src/index.ts | 12 ++++ .../src/lugraph}/lugraph-edge-layer.ts | 0 .../src/lugraph}/lugraph-effect.ts | 22 +++++-- .../src/lugraph}/lugraph-node-layer.ts | 0 .../test}/lu-graph-deck.node.spec.ts | 64 ++++++++++++------- .../test}/lu-graph-deck.spec.ts | 12 ++-- .../test/luspatial-point-layer.node.spec.ts | 3 +- modules/deck-luspatial/tsconfig.json | 3 +- .../examples/deck/lugraph-explorer.mdx | 10 +-- yarn.lock | 1 + 14 files changed, 141 insertions(+), 56 deletions(-) rename {examples/deck/lugraph-explorer => modules/deck-luspatial/src/lugraph}/lugraph-edge-layer.ts (100%) rename {examples/deck/lugraph-explorer => modules/deck-luspatial/src/lugraph}/lugraph-effect.ts (94%) rename {examples/deck/lugraph-explorer => modules/deck-luspatial/src/lugraph}/lugraph-node-layer.ts (100%) rename modules/{experimental/test/lugraph => deck-luspatial/test}/lu-graph-deck.node.spec.ts (81%) rename modules/{experimental/test/lugraph => deck-luspatial/test}/lu-graph-deck.spec.ts (96%) diff --git a/docs/api-reference/experimental/lugraph.md b/docs/api-reference/experimental/lugraph.md index a2b5ff9fb9..b0798da8a0 100644 --- a/docs/api-reference/experimental/lugraph.md +++ b/docs/api-reference/experimental/lugraph.md @@ -76,7 +76,8 @@ WebGPU-only educational example, not a large-graph performance benchmark: its ex relationships, analytics, or moving node positions into JavaScript objects?** The optional [luGraph + deck.gl network explorer](/examples/deck/lugraph-explorer) answers that -question with a real deck.gl `Effect`, custom node and edge `Layer` implementations, an +question with the reusable `LuGraphDeckEffect`, `LuGraphNodeLayer`, and `LuGraphEdgeLayer` +implementations from the existing private `@deck.gl-community/luspatial` adapter package, an `OrthographicView`, and deck.gl's existing interaction and asynchronous WebGPU picking systems. Use it when a social-network, service-dependency, fraud-investigation, or citation visualization already uses deck.gl and needs GPU graph results to become directly drawable attributes. @@ -97,7 +98,9 @@ separate from the native explorer's custom **8-byte** integer-picking path above exact `O(V² + E)` layout, not the optional spatial approximation, and does not promise large-graph throughput. -The integration lives exclusively under `examples/deck/lugraph-explorer`; neither +The reusable graph effect, node and edge layers, and graph integration's deck.gl imports live in +the existing private `@deck.gl-community/luspatial` adapter. The website-only example consumes +those exported symbols without importing `@deck.gl/core` or adding an example package; neither `@luma.gl/experimental` nor its optional graph entry point depends on or imports deck.gl. ## Measure real CPU and WebGPU graph workloads diff --git a/examples/deck/lugraph-explorer/app.ts b/examples/deck/lugraph-explorer/app.ts index 3041c477d1..0a9dbace53 100644 --- a/examples/deck/lugraph-explorer/app.ts +++ b/examples/deck/lugraph-explorer/app.ts @@ -2,7 +2,13 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: Copyright (c) vis.gl contributors -import {OrthographicView, type PickingInfo} from '@deck.gl/core'; +import { + LuGraphDeckEffect, + LuGraphEdgeLayer, + LuGraphNodeLayer, + OrthographicView, + type PickingInfo +} from '@deck.gl-community/luspatial'; import {Buffer, type Device} from '@luma.gl/core'; import { ShaderAssembler, @@ -11,10 +17,10 @@ import { } from '@luma.gl/shadertools'; import {ArrowDeck} from '../arrow-deck'; import {getDeckExampleProps, type DeckExampleDeviceOptions} from '../deck-example-device'; -import type {GraphExplorerDataset} from '../../experimental/lugraph-explorer/graph-data'; -import {LuGraphDeckEffect} from './lugraph-effect'; -import {LuGraphEdgeLayer} from './lugraph-edge-layer'; -import {LuGraphNodeLayer} from './lugraph-node-layer'; +import { + makeGraphExplorerDataset, + type GraphExplorerDataset +} from '../../experimental/lugraph-explorer/graph-data'; const DEFAULT_NEIGHBORHOOD_DEPTH = 2; @@ -107,7 +113,7 @@ export function createLuGraphExplorerDeck( }, onLoad: ({deck: loadedDeck, device}) => { if (device.type !== 'webgpu') throw new Error('luGraph deck explorer requires WebGPU'); - effect = new LuGraphDeckEffect(device, dataset); + effect = new LuGraphDeckEffect(device, dataset ?? makeGraphExplorerDataset()); const edgeLayers = effect.graph.sourceVertices.data.flatMap((source, chunkIndex) => { if (source.length === 0) return []; const target = effect!.graph.targetVertices.data[chunkIndex]; diff --git a/modules/deck-luspatial/README.md b/modules/deck-luspatial/README.md index bad3479d43..bbad0efa46 100644 --- a/modules/deck-luspatial/README.md +++ b/modules/deck-luspatial/README.md @@ -1,6 +1,8 @@ # @deck.gl-community/luspatial -Reusable deck.gl layers for GPU-selected spatial data. +Reusable deck.gl adapters for GPU-native spatial and graph data. + +## Spatial layers `LuSpatialPointLayer` binds caller-owned position and point-ID buffers directly and replays a caller-owned `DrawCommandBuffer`. The layer owns only its render model and style-uniform buffer; @@ -9,8 +11,10 @@ query results, indirect commands, and source positions remain owned by the appli The command buffer must use the non-indexed `draw` layout. Its selected record keeps `vertexCount: 6` and `firstVertex: 0`; GPU queries normally update only `instanceCount`. -The package deliberately does not depend on Arrow, GeoArrow, or the luSpatial query algorithms. -Applications can produce the fixed-width buffers with any ingestion and query pipeline. +The spatial layer does not depend on Arrow, GeoArrow, or the luSpatial query algorithms. +Applications can produce the fixed-width buffers with any ingestion and query pipeline. The separate +graph adapter uses `@luma.gl/experimental/lugraph` without coupling the spatial layer to a particular +query algorithm. ```ts import {LuSpatialPointLayer} from '@deck.gl-community/luspatial'; @@ -37,3 +41,33 @@ metadata without a readback. Deck's RGB24 picking reserves zero for “no object,” so indices through `16_777_214` are pickable. Larger indices continue to render but are intentionally omitted from the picking pass; use compact resident row indices and keep any global corpus-ID mapping in the application. + +## Graph effects and layers + +`LuGraphDeckEffect` composes topology, PageRank, weak components, neighborhood search, and +progressive force layout inside deck.gl's existing frame. Deck owns queue submission; the effect +retains original source and target edge partitions, including empty batches, without staging or +reading graph data back to the CPU. + +```ts +import { + LuGraphDeckEffect, + LuGraphEdgeLayer, + LuGraphNodeLayer, + type LuGraphDeckDataset +} from '@deck.gl-community/luspatial'; + +const dataset: LuGraphDeckDataset = { + vertexCount, + sourceChunks, + targetChunks, + positions, + velocities +}; +const effect = new LuGraphDeckEffect(device, dataset); +``` + +`LuGraphNodeLayer` consumes the exact progressive position allocation alongside resident PageRank, +component, distance, and selection outputs. Create one `LuGraphEdgeLayer` per nonempty original edge +partition to render caller-owned source and target buffers directly. The graph algorithms remain in +`@luma.gl/experimental/lugraph`; only this private adapter package depends on deck.gl. diff --git a/modules/deck-luspatial/package.json b/modules/deck-luspatial/package.json index 2d43fbf99b..c94de8a6ad 100644 --- a/modules/deck-luspatial/package.json +++ b/modules/deck-luspatial/package.json @@ -2,7 +2,7 @@ "name": "@deck.gl-community/luspatial", "version": "9.4.0-alpha.4", "private": true, - "description": "deck.gl layers for GPU-selected luSpatial data", + "description": "deck.gl adapters for GPU-native spatial and graph data", "license": "MIT", "type": "module", "types": "dist/index.d.ts", @@ -26,6 +26,7 @@ "@luma.gl/core": "9.4.0-alpha.4", "@luma.gl/engine": "9.4.0-alpha.4", "@luma.gl/experimental": "9.4.0-alpha.4", - "@luma.gl/shadertools": "9.4.0-alpha.4" + "@luma.gl/shadertools": "9.4.0-alpha.4", + "@luma.gl/tables": "9.4.0-alpha.4" } } diff --git a/modules/deck-luspatial/src/index.ts b/modules/deck-luspatial/src/index.ts index 24ff483665..b16805ee81 100644 --- a/modules/deck-luspatial/src/index.ts +++ b/modules/deck-luspatial/src/index.ts @@ -2,7 +2,19 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: Copyright (c) vis.gl contributors +export {OrthographicView, type PickingInfo} from '@deck.gl/core'; export { LuSpatialPointLayer, type LuSpatialPointLayerProps } from './layers/luspatial-point-layer'; +export {LuGraphDeckEffect, type LuGraphDeckDataset} from './lugraph/lugraph-effect'; +export { + LUGRAPH_DECK_EDGE_SHADER, + LuGraphEdgeLayer, + type LuGraphEdgeLayerProps +} from './lugraph/lugraph-edge-layer'; +export { + LUGRAPH_DECK_NODE_SHADER, + LuGraphNodeLayer, + type LuGraphNodeLayerProps +} from './lugraph/lugraph-node-layer'; diff --git a/examples/deck/lugraph-explorer/lugraph-edge-layer.ts b/modules/deck-luspatial/src/lugraph/lugraph-edge-layer.ts similarity index 100% rename from examples/deck/lugraph-explorer/lugraph-edge-layer.ts rename to modules/deck-luspatial/src/lugraph/lugraph-edge-layer.ts diff --git a/examples/deck/lugraph-explorer/lugraph-effect.ts b/modules/deck-luspatial/src/lugraph/lugraph-effect.ts similarity index 94% rename from examples/deck/lugraph-explorer/lugraph-effect.ts rename to modules/deck-luspatial/src/lugraph/lugraph-effect.ts index 3480e05338..8edd98ae99 100644 --- a/examples/deck/lugraph-explorer/lugraph-effect.ts +++ b/modules/deck-luspatial/src/lugraph/lugraph-effect.ts @@ -15,10 +15,20 @@ import { type LuGraphAdjacency } from '@luma.gl/experimental/lugraph'; import {GPUData, GPUVector} from '@luma.gl/tables'; -import { - makeGraphExplorerDataset, - type GraphExplorerDataset -} from '../../experimental/lugraph-explorer/graph-data'; + +/** Caller-owned graph input preserving original edge partitions and vertex allocations. */ +export type LuGraphDeckDataset = { + /** Number of stable zero-based graph vertices. */ + vertexCount: number; + /** Original directed source-edge batches, including any empty partitions. */ + sourceChunks: Uint32Array[]; + /** Original target-edge batches aligned with the source partitions. */ + targetChunks: Uint32Array[]; + /** Initial directly renderable two-component positions in source-vertex order. */ + positions: Float32Array; + /** Initial progressive two-component velocities in source-vertex order. */ + velocities: Float32Array; +}; const SCALAR_BYTE_LENGTH = 4; const MAXIMUM_NEIGHBORHOOD_DEPTH = 8; @@ -39,7 +49,7 @@ export class LuGraphDeckEffect implements Effect { readonly props = {}; readonly useInPicking = false; readonly device: Device; - readonly dataset: GraphExplorerDataset; + readonly dataset: LuGraphDeckDataset; readonly graph: LuGraph; readonly topology: LuGraphTopology; readonly pageRank: LuGraphPageRank; @@ -62,7 +72,7 @@ export class LuGraphDeckEffect implements Effect { private analyticsPending = true; private destroyed = false; - constructor(device: Device, dataset: GraphExplorerDataset = makeGraphExplorerDataset()) { + constructor(device: Device, dataset: LuGraphDeckDataset) { if (device.type !== 'webgpu') throw new Error('LuGraphDeckEffect requires WebGPU'); this.device = device; this.dataset = dataset; diff --git a/examples/deck/lugraph-explorer/lugraph-node-layer.ts b/modules/deck-luspatial/src/lugraph/lugraph-node-layer.ts similarity index 100% rename from examples/deck/lugraph-explorer/lugraph-node-layer.ts rename to modules/deck-luspatial/src/lugraph/lugraph-node-layer.ts diff --git a/modules/experimental/test/lugraph/lu-graph-deck.node.spec.ts b/modules/deck-luspatial/test/lu-graph-deck.node.spec.ts similarity index 81% rename from modules/experimental/test/lugraph/lu-graph-deck.node.spec.ts rename to modules/deck-luspatial/test/lu-graph-deck.node.spec.ts index fd92c80f82..8b6827e3da 100644 --- a/modules/experimental/test/lugraph/lu-graph-deck.node.spec.ts +++ b/modules/deck-luspatial/test/lu-graph-deck.node.spec.ts @@ -4,22 +4,20 @@ import {existsSync, readFileSync} from 'node:fs'; -import * as experimentalModule from '@luma.gl/experimental'; -import * as luGraphModule from '@luma.gl/experimental/lugraph'; -import {describe, expect, test} from 'vitest'; - -import {createLuGraphExplorerDeck} from '../../../../examples/deck/lugraph-explorer/app'; import { LUGRAPH_DECK_EDGE_SHADER, - LuGraphEdgeLayer -} from '../../../../examples/deck/lugraph-explorer/lugraph-edge-layer'; -import {LuGraphDeckEffect} from '../../../../examples/deck/lugraph-explorer/lugraph-effect'; -import { LUGRAPH_DECK_NODE_SHADER, + LuGraphDeckEffect, + LuGraphEdgeLayer, LuGraphNodeLayer -} from '../../../../examples/deck/lugraph-explorer/lugraph-node-layer'; -import {makeGraphExplorerDataset} from '../../../../examples/experimental/lugraph-explorer/graph-data'; -import {getExampleThumbnailPath} from '../../../../website/src/example-thumbnails'; +} from '@deck.gl-community/luspatial'; +import * as experimentalModule from '@luma.gl/experimental'; +import * as luGraphModule from '@luma.gl/experimental/lugraph'; +import {describe, expect, test} from 'vitest'; + +import {createLuGraphExplorerDeck} from '../../../examples/deck/lugraph-explorer/app'; +import {makeGraphExplorerDataset} from '../../../examples/experimental/lugraph-explorer/graph-data'; +import {getExampleThumbnailPath} from '../../../website/src/example-thumbnails'; type ExampleContentsEntry = { type: string; @@ -31,7 +29,7 @@ type ExampleContentsEntry = { describe('optional luGraph deck.gl integration package isolation', () => { test('keeps deck.gl entirely outside graph production imports and dependencies', () => { const packageJson = JSON.parse( - readFileSync(new URL('../../package.json', import.meta.url), 'utf8') + readFileSync(new URL('../../experimental/package.json', import.meta.url), 'utf8') ) as { dependencies?: Record; peerDependencies?: Record; @@ -53,11 +51,29 @@ describe('optional luGraph deck.gl integration package isolation', () => { expect('LuGraphEdgeLayer' in luGraphModule).toBe(false); }); + test('keeps deck.gl and GPU table dependencies inside the private integration package', () => { + const packageJson = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') + ) as {private?: boolean; dependencies?: Record}; + + expect(packageJson.private).toBe(true); + expect(packageJson.dependencies?.['@deck.gl/core']).toBe('9.3.4'); + expect(packageJson.dependencies?.['@luma.gl/tables']).toBe('9.4.0-alpha.4'); + }); + + test('loads deck.gl graph adapters through the private package boundary', () => { + const exampleSource = readFileSync( + new URL('../../../examples/deck/lugraph-explorer/app.ts', import.meta.url), + 'utf8' + ); + + expect(exampleSource).toContain("from '@deck.gl-community/luspatial'"); + expect(exampleSource).not.toContain('@deck.gl/core'); + }); + test('does not create an example workspace, package manifest, or integration dependency', () => { expect( - existsSync( - new URL('../../../../examples/deck/lugraph-explorer/package.json', import.meta.url) - ) + existsSync(new URL('../../../examples/deck/lugraph-explorer/package.json', import.meta.url)) ).toBe(false); expect(typeof createLuGraphExplorerDeck).toBe('function'); expect(typeof LuGraphDeckEffect).toBe('function'); @@ -95,7 +111,7 @@ describe('luGraph native deck.gl resident layers', () => { expect(LUGRAPH_DECK_EDGE_SHADER).toContain('positions: array>'); const effectSource = readFileSync( - new URL('../../../../examples/deck/lugraph-explorer/lugraph-effect.ts', import.meta.url), + new URL('../src/lugraph/lugraph-effect.ts', import.meta.url), 'utf8' ); expect(effectSource).toContain('this.device.commandEncoder'); @@ -107,12 +123,12 @@ describe('luGraph native deck.gl resident layers', () => { describe('optional luGraph deck.gl gallery and API guide', () => { test('registers lazy loading and a WebGPU-only GPGPU gallery destination', () => { const examples = readFileSync( - new URL('../../../../website/src/examples.tsx', import.meta.url), + new URL('../../../website/src/examples.tsx', import.meta.url), 'utf8' ); const contents = JSON.parse( readFileSync( - new URL('../../../../website/content/examples/table-of-contents.json', import.meta.url), + new URL('../../../website/content/examples/table-of-contents.json', import.meta.url), 'utf8' ) ) as ExampleContentsEntry[]; @@ -137,11 +153,11 @@ describe('optional luGraph deck.gl gallery and API guide', () => { test('provides curated WebGPU metadata, an existing network thumbnail, and optional API guidance', () => { const examplePage = readFileSync( - new URL('../../../../website/content/examples/deck/lugraph-explorer.mdx', import.meta.url), + new URL('../../../website/content/examples/deck/lugraph-explorer.mdx', import.meta.url), 'utf8' ); const apiGuide = readFileSync( - new URL('../../../../docs/api-reference/experimental/lugraph.md', import.meta.url), + new URL('../../../docs/api-reference/experimental/lugraph.md', import.meta.url), 'utf8' ); const topics = examplePage @@ -162,7 +178,7 @@ describe('optional luGraph deck.gl gallery and API guide', () => { test('explains when deck.gl graph integration is useful and how to explore it', () => { const examplePage = readFileSync( - new URL('../../../../website/content/examples/deck/lugraph-explorer.mdx', import.meta.url), + new URL('../../../website/content/examples/deck/lugraph-explorer.mdx', import.meta.url), 'utf8' ); @@ -209,11 +225,11 @@ describe('optional luGraph deck.gl gallery and API guide', () => { test('documents direct GPU frame ownership and honest asynchronous deck.gl picking', () => { const examplePage = readFileSync( - new URL('../../../../website/content/examples/deck/lugraph-explorer.mdx', import.meta.url), + new URL('../../../website/content/examples/deck/lugraph-explorer.mdx', import.meta.url), 'utf8' ); const apiGuide = readFileSync( - new URL('../../../../docs/api-reference/experimental/lugraph.md', import.meta.url), + new URL('../../../docs/api-reference/experimental/lugraph.md', import.meta.url), 'utf8' ); diff --git a/modules/experimental/test/lugraph/lu-graph-deck.spec.ts b/modules/deck-luspatial/test/lu-graph-deck.spec.ts similarity index 96% rename from modules/experimental/test/lugraph/lu-graph-deck.spec.ts rename to modules/deck-luspatial/test/lu-graph-deck.spec.ts index 3c7743c03a..06401c2983 100644 --- a/modules/experimental/test/lugraph/lu-graph-deck.spec.ts +++ b/modules/deck-luspatial/test/lu-graph-deck.spec.ts @@ -3,6 +3,7 @@ // SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import type {EffectContext} from '@deck.gl/core'; +import {LuGraphDeckEffect, LuGraphEdgeLayer, LuGraphNodeLayer} from '@deck.gl-community/luspatial'; import {Buffer} from '@luma.gl/core'; import {ShaderAssembler} from '@luma.gl/shadertools'; import type {GPUVector} from '@luma.gl/tables'; @@ -10,11 +11,8 @@ import {getWebGPUTestDevice} from '@luma.gl/test-utils'; import test from 'test/utils/vitest-tape'; import {vi} from 'vitest'; -import {createLuGraphExplorerDeck} from '../../../../examples/deck/lugraph-explorer/app'; -import {LuGraphDeckEffect} from '../../../../examples/deck/lugraph-explorer/lugraph-effect'; -import {LuGraphEdgeLayer} from '../../../../examples/deck/lugraph-explorer/lugraph-edge-layer'; -import {LuGraphNodeLayer} from '../../../../examples/deck/lugraph-explorer/lugraph-node-layer'; -import {makeGraphExplorerDataset} from '../../../../examples/experimental/lugraph-explorer/graph-data'; +import {createLuGraphExplorerDeck} from '../../../examples/deck/lugraph-explorer/app'; +import {makeGraphExplorerDataset} from '../../../examples/experimental/lugraph-explorer/graph-data'; test('luGraph deck.gl effect composes actual GPU analytics, zero-copy selection, and pinned layout', async tapeTest => { const device = await getWebGPUTestDevice(); @@ -27,7 +25,8 @@ test('luGraph deck.gl effect composes actual GPU analytics, zero-copy selection, let effect: LuGraphDeckEffect | undefined; const submitSpy = vi.spyOn(device, 'submit'); try { - effect = new LuGraphDeckEffect(device); + const dataset = makeGraphExplorerDataset(); + effect = new LuGraphDeckEffect(device, dataset); tapeTest.equal( submitSpy.mock.calls.length, 0, @@ -35,7 +34,6 @@ test('luGraph deck.gl effect composes actual GPU analytics, zero-copy selection, ); submitSpy.mockRestore(); - const dataset = makeGraphExplorerDataset(); tapeTest.deepEqual( effect.graph.sourceVertices.data.map(chunk => chunk.length), dataset.sourceChunks.map(chunk => chunk.length), diff --git a/modules/deck-luspatial/test/luspatial-point-layer.node.spec.ts b/modules/deck-luspatial/test/luspatial-point-layer.node.spec.ts index 07065ccfe2..8c68d140e1 100644 --- a/modules/deck-luspatial/test/luspatial-point-layer.node.spec.ts +++ b/modules/deck-luspatial/test/luspatial-point-layer.node.spec.ts @@ -185,7 +185,8 @@ describe('@deck.gl-community/luspatial package boundary', () => { '@luma.gl/core', '@luma.gl/engine', '@luma.gl/experimental', - '@luma.gl/shadertools' + '@luma.gl/shadertools', + '@luma.gl/tables' ]); }); }); diff --git a/modules/deck-luspatial/tsconfig.json b/modules/deck-luspatial/tsconfig.json index cf5b5bea2a..e6088ee359 100644 --- a/modules/deck-luspatial/tsconfig.json +++ b/modules/deck-luspatial/tsconfig.json @@ -10,6 +10,7 @@ "references": [ {"path": "../core"}, {"path": "../engine"}, - {"path": "../experimental"} + {"path": "../experimental"}, + {"path": "../tables"} ] } diff --git a/website/content/examples/deck/lugraph-explorer.mdx b/website/content/examples/deck/lugraph-explorer.mdx index 3c8cdb07f4..7ebec2e68b 100644 --- a/website/content/examples/deck/lugraph-explorer.mdx +++ b/website/content/examples/deck/lugraph-explorer.mdx @@ -28,10 +28,12 @@ luGraph computes relationship data; deck.gl already understands application view lifecycle, camera controls, picking, and GPU drawing. This integration lets each system keep its existing responsibility while sharing the same actual GPU allocations. -A real deck.gl `Effect` schedules luGraph work inside the existing deck.gl frame. Custom node and -edge `Layer` implementations consume its results directly, and an `OrthographicView` provides -familiar pan and zoom. Graph analytics remain in `@luma.gl/experimental/lugraph`; the optional -deck.gl integration stays entirely inside this unpackaged example. +A reusable `LuGraphDeckEffect` schedules luGraph work inside the existing deck.gl frame. +`LuGraphNodeLayer` and `LuGraphEdgeLayer` consume its results directly, and an `OrthographicView` +provides familiar pan and zoom. Those reusable adapters and all graph integration-specific +deck.gl imports live in the existing private `@deck.gl-community/luspatial` module. Graph +analytics remain in `@luma.gl/experimental/lugraph`, while this unpackaged example imports the +adapter's public symbols without importing `@deck.gl/core` directly. ## When should I use this integration? diff --git a/yarn.lock b/yarn.lock index 9ac9ba693a..cbf74f17eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2703,6 +2703,7 @@ __metadata: "@luma.gl/engine": "npm:9.4.0-alpha.4" "@luma.gl/experimental": "npm:9.4.0-alpha.4" "@luma.gl/shadertools": "npm:9.4.0-alpha.4" + "@luma.gl/tables": "npm:9.4.0-alpha.4" languageName: unknown linkType: soft From e9eed8fa07b06ac9f0e943e2c41ef01a8ded0920 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Fri, 7 Aug 2026 09:59:43 -0400 Subject: [PATCH 7/7] refactor(deck): move graph integration into existing arrow-layers module --- docs/api-reference/experimental/lugraph.md | 4 +- examples/deck/lugraph-explorer/app.ts | 2 +- modules/arrow-layers/README.md | 30 ++++++++++++++ modules/arrow-layers/package.json | 1 + modules/arrow-layers/src/index.ts | 12 ++++++ .../src/lugraph/lugraph-edge-layer.ts | 0 .../src/lugraph/lugraph-effect.ts | 0 .../src/lugraph/lugraph-node-layer.ts | 0 .../test/lu-graph-deck.node.spec.ts | 8 ++-- .../test/lu-graph-deck.spec.ts | 6 ++- modules/arrow-layers/tsconfig.json | 1 + modules/deck-luspatial/README.md | 40 ++----------------- modules/deck-luspatial/package.json | 5 +-- modules/deck-luspatial/src/index.ts | 12 ------ .../test/luspatial-point-layer.node.spec.ts | 3 +- modules/deck-luspatial/tsconfig.json | 3 +- .../examples/deck/lugraph-explorer.mdx | 2 +- yarn.lock | 2 +- 18 files changed, 66 insertions(+), 65 deletions(-) rename modules/{deck-luspatial => arrow-layers}/src/lugraph/lugraph-edge-layer.ts (100%) rename modules/{deck-luspatial => arrow-layers}/src/lugraph/lugraph-effect.ts (100%) rename modules/{deck-luspatial => arrow-layers}/src/lugraph/lugraph-node-layer.ts (100%) rename modules/{deck-luspatial => arrow-layers}/test/lu-graph-deck.node.spec.ts (96%) rename modules/{deck-luspatial => arrow-layers}/test/lu-graph-deck.spec.ts (99%) diff --git a/docs/api-reference/experimental/lugraph.md b/docs/api-reference/experimental/lugraph.md index b0798da8a0..1c864d3839 100644 --- a/docs/api-reference/experimental/lugraph.md +++ b/docs/api-reference/experimental/lugraph.md @@ -77,7 +77,7 @@ relationships, analytics, or moving node positions into JavaScript objects?** The optional [luGraph + deck.gl network explorer](/examples/deck/lugraph-explorer) answers that question with the reusable `LuGraphDeckEffect`, `LuGraphNodeLayer`, and `LuGraphEdgeLayer` -implementations from the existing private `@deck.gl-community/luspatial` adapter package, an +implementations from the existing private `@deck.gl-community/arrow-layers` adapter package, an `OrthographicView`, and deck.gl's existing interaction and asynchronous WebGPU picking systems. Use it when a social-network, service-dependency, fraud-investigation, or citation visualization already uses deck.gl and needs GPU graph results to become directly drawable attributes. @@ -99,7 +99,7 @@ exact `O(V² + E)` layout, not the optional spatial approximation, and does not throughput. The reusable graph effect, node and edge layers, and graph integration's deck.gl imports live in -the existing private `@deck.gl-community/luspatial` adapter. The website-only example consumes +the existing private `@deck.gl-community/arrow-layers` adapter. The website-only example consumes those exported symbols without importing `@deck.gl/core` or adding an example package; neither `@luma.gl/experimental` nor its optional graph entry point depends on or imports deck.gl. diff --git a/examples/deck/lugraph-explorer/app.ts b/examples/deck/lugraph-explorer/app.ts index 0a9dbace53..e59c283680 100644 --- a/examples/deck/lugraph-explorer/app.ts +++ b/examples/deck/lugraph-explorer/app.ts @@ -8,7 +8,7 @@ import { LuGraphNodeLayer, OrthographicView, type PickingInfo -} from '@deck.gl-community/luspatial'; +} from '@deck.gl-community/arrow-layers'; import {Buffer, type Device} from '@luma.gl/core'; import { ShaderAssembler, diff --git a/modules/arrow-layers/README.md b/modules/arrow-layers/README.md index 1205161f77..80f5006b7b 100644 --- a/modules/arrow-layers/README.md +++ b/modules/arrow-layers/README.md @@ -6,3 +6,33 @@ Private deck.gl layers backed by the Arrow adapters and `GPUVector` objects from The layers intentionally do not use deck.gl `AttributeManager` for Arrow columns. Arrow data is converted once into `GPUVector`/`GPUTable` inputs and bound directly to luma.gl models. + +## Graph effects and layers + +`LuGraphDeckEffect` composes topology, PageRank, weak components, neighborhood search, and +progressive force layout inside deck.gl's existing frame. Deck owns queue submission; the effect +retains original source and target edge partitions, including empty batches, without staging or +reading graph data back to the CPU. + +```ts +import { + LuGraphDeckEffect, + LuGraphEdgeLayer, + LuGraphNodeLayer, + type LuGraphDeckDataset +} from '@deck.gl-community/arrow-layers'; + +const dataset: LuGraphDeckDataset = { + vertexCount, + sourceChunks, + targetChunks, + positions, + velocities +}; +const effect = new LuGraphDeckEffect(device, dataset); +``` + +`LuGraphNodeLayer` consumes the exact progressive position allocation alongside resident PageRank, +component, distance, and selection outputs. Create one `LuGraphEdgeLayer` per nonempty original edge +partition to render caller-owned source and target buffers directly. The graph algorithms remain in +`@luma.gl/experimental/lugraph`; only this private adapter package depends on deck.gl. diff --git a/modules/arrow-layers/package.json b/modules/arrow-layers/package.json index c4106f11a1..54f45e5eb9 100644 --- a/modules/arrow-layers/package.json +++ b/modules/arrow-layers/package.json @@ -26,6 +26,7 @@ "@luma.gl/arrow": "9.4.0-alpha.4", "@luma.gl/core": "9.4.0-alpha.4", "@luma.gl/engine": "9.4.0-alpha.4", + "@luma.gl/experimental": "9.4.0-alpha.4", "@luma.gl/shadertools": "9.4.0-alpha.4", "@luma.gl/tables": "9.4.0-alpha.4", "apache-arrow": "^17.0.0" diff --git a/modules/arrow-layers/src/index.ts b/modules/arrow-layers/src/index.ts index 1c0a61fe2c..8751ec3956 100644 --- a/modules/arrow-layers/src/index.ts +++ b/modules/arrow-layers/src/index.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: Copyright (c) vis.gl contributors +export {OrthographicView, type PickingInfo} from '@deck.gl/core'; export { ArrowPolygonLayer, type ArrowPolygonColorInput, @@ -19,3 +20,14 @@ export { type ArrowTextLayerProps } from './layers/arrow-text-layer'; export type {ArrowLayerPickingInfo} from './layers/arrow-layer-types'; +export {LuGraphDeckEffect, type LuGraphDeckDataset} from './lugraph/lugraph-effect'; +export { + LUGRAPH_DECK_EDGE_SHADER, + LuGraphEdgeLayer, + type LuGraphEdgeLayerProps +} from './lugraph/lugraph-edge-layer'; +export { + LUGRAPH_DECK_NODE_SHADER, + LuGraphNodeLayer, + type LuGraphNodeLayerProps +} from './lugraph/lugraph-node-layer'; diff --git a/modules/deck-luspatial/src/lugraph/lugraph-edge-layer.ts b/modules/arrow-layers/src/lugraph/lugraph-edge-layer.ts similarity index 100% rename from modules/deck-luspatial/src/lugraph/lugraph-edge-layer.ts rename to modules/arrow-layers/src/lugraph/lugraph-edge-layer.ts diff --git a/modules/deck-luspatial/src/lugraph/lugraph-effect.ts b/modules/arrow-layers/src/lugraph/lugraph-effect.ts similarity index 100% rename from modules/deck-luspatial/src/lugraph/lugraph-effect.ts rename to modules/arrow-layers/src/lugraph/lugraph-effect.ts diff --git a/modules/deck-luspatial/src/lugraph/lugraph-node-layer.ts b/modules/arrow-layers/src/lugraph/lugraph-node-layer.ts similarity index 100% rename from modules/deck-luspatial/src/lugraph/lugraph-node-layer.ts rename to modules/arrow-layers/src/lugraph/lugraph-node-layer.ts diff --git a/modules/deck-luspatial/test/lu-graph-deck.node.spec.ts b/modules/arrow-layers/test/lu-graph-deck.node.spec.ts similarity index 96% rename from modules/deck-luspatial/test/lu-graph-deck.node.spec.ts rename to modules/arrow-layers/test/lu-graph-deck.node.spec.ts index 8b6827e3da..1ac52b8575 100644 --- a/modules/deck-luspatial/test/lu-graph-deck.node.spec.ts +++ b/modules/arrow-layers/test/lu-graph-deck.node.spec.ts @@ -10,7 +10,7 @@ import { LuGraphDeckEffect, LuGraphEdgeLayer, LuGraphNodeLayer -} from '@deck.gl-community/luspatial'; +} from '@deck.gl-community/arrow-layers'; import * as experimentalModule from '@luma.gl/experimental'; import * as luGraphModule from '@luma.gl/experimental/lugraph'; import {describe, expect, test} from 'vitest'; @@ -51,13 +51,14 @@ describe('optional luGraph deck.gl integration package isolation', () => { expect('LuGraphEdgeLayer' in luGraphModule).toBe(false); }); - test('keeps deck.gl and GPU table dependencies inside the private integration package', () => { + test('keeps deck.gl and GPU graph dependencies inside the existing private layers package', () => { const packageJson = JSON.parse( readFileSync(new URL('../package.json', import.meta.url), 'utf8') ) as {private?: boolean; dependencies?: Record}; expect(packageJson.private).toBe(true); expect(packageJson.dependencies?.['@deck.gl/core']).toBe('9.3.4'); + expect(packageJson.dependencies?.['@luma.gl/experimental']).toBe('9.4.0-alpha.4'); expect(packageJson.dependencies?.['@luma.gl/tables']).toBe('9.4.0-alpha.4'); }); @@ -67,7 +68,8 @@ describe('optional luGraph deck.gl integration package isolation', () => { 'utf8' ); - expect(exampleSource).toContain("from '@deck.gl-community/luspatial'"); + expect(exampleSource).toContain("from '@deck.gl-community/arrow-layers'"); + expect(exampleSource).not.toContain('@deck.gl-community/luspatial'); expect(exampleSource).not.toContain('@deck.gl/core'); }); diff --git a/modules/deck-luspatial/test/lu-graph-deck.spec.ts b/modules/arrow-layers/test/lu-graph-deck.spec.ts similarity index 99% rename from modules/deck-luspatial/test/lu-graph-deck.spec.ts rename to modules/arrow-layers/test/lu-graph-deck.spec.ts index 06401c2983..8dd73af60f 100644 --- a/modules/deck-luspatial/test/lu-graph-deck.spec.ts +++ b/modules/arrow-layers/test/lu-graph-deck.spec.ts @@ -3,7 +3,11 @@ // SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import type {EffectContext} from '@deck.gl/core'; -import {LuGraphDeckEffect, LuGraphEdgeLayer, LuGraphNodeLayer} from '@deck.gl-community/luspatial'; +import { + LuGraphDeckEffect, + LuGraphEdgeLayer, + LuGraphNodeLayer +} from '@deck.gl-community/arrow-layers'; import {Buffer} from '@luma.gl/core'; import {ShaderAssembler} from '@luma.gl/shadertools'; import type {GPUVector} from '@luma.gl/tables'; diff --git a/modules/arrow-layers/tsconfig.json b/modules/arrow-layers/tsconfig.json index 7539218f38..8be3a2f8d7 100644 --- a/modules/arrow-layers/tsconfig.json +++ b/modules/arrow-layers/tsconfig.json @@ -11,6 +11,7 @@ {"path": "../arrow"}, {"path": "../core"}, {"path": "../engine"}, + {"path": "../experimental"}, {"path": "../tables"} ] } diff --git a/modules/deck-luspatial/README.md b/modules/deck-luspatial/README.md index bbad0efa46..bad3479d43 100644 --- a/modules/deck-luspatial/README.md +++ b/modules/deck-luspatial/README.md @@ -1,8 +1,6 @@ # @deck.gl-community/luspatial -Reusable deck.gl adapters for GPU-native spatial and graph data. - -## Spatial layers +Reusable deck.gl layers for GPU-selected spatial data. `LuSpatialPointLayer` binds caller-owned position and point-ID buffers directly and replays a caller-owned `DrawCommandBuffer`. The layer owns only its render model and style-uniform buffer; @@ -11,10 +9,8 @@ query results, indirect commands, and source positions remain owned by the appli The command buffer must use the non-indexed `draw` layout. Its selected record keeps `vertexCount: 6` and `firstVertex: 0`; GPU queries normally update only `instanceCount`. -The spatial layer does not depend on Arrow, GeoArrow, or the luSpatial query algorithms. -Applications can produce the fixed-width buffers with any ingestion and query pipeline. The separate -graph adapter uses `@luma.gl/experimental/lugraph` without coupling the spatial layer to a particular -query algorithm. +The package deliberately does not depend on Arrow, GeoArrow, or the luSpatial query algorithms. +Applications can produce the fixed-width buffers with any ingestion and query pipeline. ```ts import {LuSpatialPointLayer} from '@deck.gl-community/luspatial'; @@ -41,33 +37,3 @@ metadata without a readback. Deck's RGB24 picking reserves zero for “no object,” so indices through `16_777_214` are pickable. Larger indices continue to render but are intentionally omitted from the picking pass; use compact resident row indices and keep any global corpus-ID mapping in the application. - -## Graph effects and layers - -`LuGraphDeckEffect` composes topology, PageRank, weak components, neighborhood search, and -progressive force layout inside deck.gl's existing frame. Deck owns queue submission; the effect -retains original source and target edge partitions, including empty batches, without staging or -reading graph data back to the CPU. - -```ts -import { - LuGraphDeckEffect, - LuGraphEdgeLayer, - LuGraphNodeLayer, - type LuGraphDeckDataset -} from '@deck.gl-community/luspatial'; - -const dataset: LuGraphDeckDataset = { - vertexCount, - sourceChunks, - targetChunks, - positions, - velocities -}; -const effect = new LuGraphDeckEffect(device, dataset); -``` - -`LuGraphNodeLayer` consumes the exact progressive position allocation alongside resident PageRank, -component, distance, and selection outputs. Create one `LuGraphEdgeLayer` per nonempty original edge -partition to render caller-owned source and target buffers directly. The graph algorithms remain in -`@luma.gl/experimental/lugraph`; only this private adapter package depends on deck.gl. diff --git a/modules/deck-luspatial/package.json b/modules/deck-luspatial/package.json index c94de8a6ad..2d43fbf99b 100644 --- a/modules/deck-luspatial/package.json +++ b/modules/deck-luspatial/package.json @@ -2,7 +2,7 @@ "name": "@deck.gl-community/luspatial", "version": "9.4.0-alpha.4", "private": true, - "description": "deck.gl adapters for GPU-native spatial and graph data", + "description": "deck.gl layers for GPU-selected luSpatial data", "license": "MIT", "type": "module", "types": "dist/index.d.ts", @@ -26,7 +26,6 @@ "@luma.gl/core": "9.4.0-alpha.4", "@luma.gl/engine": "9.4.0-alpha.4", "@luma.gl/experimental": "9.4.0-alpha.4", - "@luma.gl/shadertools": "9.4.0-alpha.4", - "@luma.gl/tables": "9.4.0-alpha.4" + "@luma.gl/shadertools": "9.4.0-alpha.4" } } diff --git a/modules/deck-luspatial/src/index.ts b/modules/deck-luspatial/src/index.ts index b16805ee81..24ff483665 100644 --- a/modules/deck-luspatial/src/index.ts +++ b/modules/deck-luspatial/src/index.ts @@ -2,19 +2,7 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: Copyright (c) vis.gl contributors -export {OrthographicView, type PickingInfo} from '@deck.gl/core'; export { LuSpatialPointLayer, type LuSpatialPointLayerProps } from './layers/luspatial-point-layer'; -export {LuGraphDeckEffect, type LuGraphDeckDataset} from './lugraph/lugraph-effect'; -export { - LUGRAPH_DECK_EDGE_SHADER, - LuGraphEdgeLayer, - type LuGraphEdgeLayerProps -} from './lugraph/lugraph-edge-layer'; -export { - LUGRAPH_DECK_NODE_SHADER, - LuGraphNodeLayer, - type LuGraphNodeLayerProps -} from './lugraph/lugraph-node-layer'; diff --git a/modules/deck-luspatial/test/luspatial-point-layer.node.spec.ts b/modules/deck-luspatial/test/luspatial-point-layer.node.spec.ts index 8c68d140e1..07065ccfe2 100644 --- a/modules/deck-luspatial/test/luspatial-point-layer.node.spec.ts +++ b/modules/deck-luspatial/test/luspatial-point-layer.node.spec.ts @@ -185,8 +185,7 @@ describe('@deck.gl-community/luspatial package boundary', () => { '@luma.gl/core', '@luma.gl/engine', '@luma.gl/experimental', - '@luma.gl/shadertools', - '@luma.gl/tables' + '@luma.gl/shadertools' ]); }); }); diff --git a/modules/deck-luspatial/tsconfig.json b/modules/deck-luspatial/tsconfig.json index e6088ee359..cf5b5bea2a 100644 --- a/modules/deck-luspatial/tsconfig.json +++ b/modules/deck-luspatial/tsconfig.json @@ -10,7 +10,6 @@ "references": [ {"path": "../core"}, {"path": "../engine"}, - {"path": "../experimental"}, - {"path": "../tables"} + {"path": "../experimental"} ] } diff --git a/website/content/examples/deck/lugraph-explorer.mdx b/website/content/examples/deck/lugraph-explorer.mdx index 7ebec2e68b..f4ed7f0e94 100644 --- a/website/content/examples/deck/lugraph-explorer.mdx +++ b/website/content/examples/deck/lugraph-explorer.mdx @@ -31,7 +31,7 @@ existing responsibility while sharing the same actual GPU allocations. A reusable `LuGraphDeckEffect` schedules luGraph work inside the existing deck.gl frame. `LuGraphNodeLayer` and `LuGraphEdgeLayer` consume its results directly, and an `OrthographicView` provides familiar pan and zoom. Those reusable adapters and all graph integration-specific -deck.gl imports live in the existing private `@deck.gl-community/luspatial` module. Graph +deck.gl imports live in the existing private `@deck.gl-community/arrow-layers` module. Graph analytics remain in `@luma.gl/experimental/lugraph`, while this unpackaged example imports the adapter's public symbols without importing `@deck.gl/core` directly. diff --git a/yarn.lock b/yarn.lock index cbf74f17eb..957aab8946 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2688,6 +2688,7 @@ __metadata: "@luma.gl/arrow": "npm:9.4.0-alpha.4" "@luma.gl/core": "npm:9.4.0-alpha.4" "@luma.gl/engine": "npm:9.4.0-alpha.4" + "@luma.gl/experimental": "npm:9.4.0-alpha.4" "@luma.gl/shadertools": "npm:9.4.0-alpha.4" "@luma.gl/tables": "npm:9.4.0-alpha.4" apache-arrow: "npm:^17.0.0" @@ -2703,7 +2704,6 @@ __metadata: "@luma.gl/engine": "npm:9.4.0-alpha.4" "@luma.gl/experimental": "npm:9.4.0-alpha.4" "@luma.gl/shadertools": "npm:9.4.0-alpha.4" - "@luma.gl/tables": "npm:9.4.0-alpha.4" languageName: unknown linkType: soft