From 369b70b1e47e3bd1cd354c7e1c7f47be4461a03b Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 4 Aug 2026 22:05:00 -0400 Subject: [PATCH 1/3] feat(examples): add interactive GPU-native luGraph explorer --- docs/api-reference/experimental/README.md | 13 + examples/experimental/lugraph-explorer/app.ts | 809 ++++++++++++++++++ .../lugraph-explorer/graph-data.ts | 103 +++ .../lugraph-explorer/graph-shaders.ts | 186 ++++ .../lugraph/lu-graph-explorer.node.spec.ts | 160 ++++ .../test/lugraph/lu-graph-explorer.spec.ts | 238 ++++++ .../experimental/lugraph-explorer.mdx | 32 + .../content/examples/table-of-contents.json | 1 + website/src/example-thumbnails.ts | 1 + website/src/examples.tsx | 14 + 10 files changed, 1557 insertions(+) create mode 100644 examples/experimental/lugraph-explorer/app.ts create mode 100644 examples/experimental/lugraph-explorer/graph-data.ts create mode 100644 examples/experimental/lugraph-explorer/graph-shaders.ts create mode 100644 modules/experimental/test/lugraph/lu-graph-explorer.node.spec.ts create mode 100644 modules/experimental/test/lugraph/lu-graph-explorer.spec.ts create mode 100644 website/content/examples/experimental/lugraph-explorer.mdx diff --git a/docs/api-reference/experimental/README.md b/docs/api-reference/experimental/README.md index b231a073f1..74f267fd89 100644 --- a/docs/api-reference/experimental/README.md +++ b/docs/api-reference/experimental/README.md @@ -149,6 +149,19 @@ brushes to linked histograms, grouped aggregates, stable visible-row identifiers masks through one reusable WebGPU command graph. Source rows stay on the GPU; applications control chart rendering, command submission, and any compact summary readback. +## GPU-resident Graph Analytics + +

+ WebGPU required +

+ +[`@luma.gl/experimental/lugraph`](/docs/api-reference/experimental/lugraph) builds bounded graph +adjacency, searches multi-seed neighborhoods, publishes exact vertex degrees and weak components, +computes normalized PageRank, and progressively updates render-ready force-layout coordinates. +Caller-owned source chunks, selection masks, scores, component identifiers, and position buffers +remain GPU-resident. The [interactive graph explorer](/examples/experimental/lugraph-explorer) +combines those outputs with stable GPU picking, neighborhood highlighting, dragging, and pinning. + ## WebGPU Geospatial Kernels

diff --git a/examples/experimental/lugraph-explorer/app.ts b/examples/experimental/lugraph-explorer/app.ts new file mode 100644 index 0000000000..1881c44b26 --- /dev/null +++ b/examples/experimental/lugraph-explorer/app.ts @@ -0,0 +1,809 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {Buffer, Texture, type Device} from '@luma.gl/core'; +import {AnimationLoopTemplate, Model, type AnimationProps} from '@luma.gl/engine'; +import { + decodeGPUIndexPickInfo, + GPUCommandGraph, + GPUIndexPickingTarget, + GPUReadbackRing, + INDEX_PICKING_READBACK_BYTE_LENGTH, + type CompiledGPUCommandGraph, + type GPUReadbackTicket, + type GraphBufferUse +} from '@luma.gl/experimental'; +import { + LuGraph, + LuGraphBreadthFirstSearch, + LuGraphConnectedComponents, + LuGraphDegree, + LuGraphForceLayout, + LuGraphPageRank, + LuGraphTopology, + type LuGraphAdjacency +} from '@luma.gl/experimental/lugraph'; +import {GPUData, GPUVector} from '@luma.gl/tables'; +import { + ExamplePanelManager, + makeExamplePanelHostHtml, + makeHtmlCustomPanel +} from '../../example-panels'; +import {makeGraphExplorerDataset} from './graph-data'; +import { + GRAPH_EXPLORER_EDGE_SHADER, + GRAPH_EXPLORER_NODE_SHADER, + GRAPH_EXPLORER_PICKING_SHADER, + GRAPH_EXPLORER_VIEW_BYTE_LENGTH +} from './graph-shaders'; + +export const title = 'luGraph GPU Graph Explorer'; +export const description = + 'GPU-resident graph analytics, progressive force layout, neighborhood selection, and direct node/edge rendering.'; + +const MAXIMUM_NEIGHBORHOOD_DEPTH = 6; +const INITIAL_NEIGHBORHOOD_DEPTH = 2; +const INVALID_VERTEX = 0xffffffff; +const UINT32_BYTE_LENGTH = Uint32Array.BYTES_PER_ELEMENT; + +type ScalarVectorFormat = 'uint32' | 'float32'; + +type FrameParameters = { + width: number; + height: number; +}; + +type PickingParameters = { + pixel: readonly [number, number]; +}; + +type EdgeModel = { + model: Model; + chunkIndex: number; +}; + +/** Interactive browser-native graph exploration with no per-frame graph readback. */ +export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopTemplate { + static info = makeExamplePanelHostHtml(); + static props = {createFramebuffer: true, debug: true}; + + readonly device: Device; + readonly graph: LuGraph; + readonly topology: LuGraphTopology; + readonly degree: LuGraphDegree; + readonly pageRank: LuGraphPageRank; + readonly components: LuGraphConnectedComponents; + readonly search: LuGraphBreadthFirstSearch; + readonly layout: LuGraphForceLayout; + readonly nodeModel: Model; + readonly pickingModel: Model; + readonly edgeModels: EdgeModel[]; + readonly analysisGraph: CompiledGPUCommandGraph; + frameGraph: CompiledGPUCommandGraph; + pickingGraph: CompiledGPUCommandGraph; + + private readonly buffers: Buffer[] = []; + private readonly vectors: GPUVector[] = []; + private readonly viewUniforms: Buffer; + private readonly readbackRing: GPUReadbackRing; + private readonly panels: ExamplePanelManager; + 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 neighborhoodMask: GPUVector<'uint32'>; + + private frameColorId = ''; + private frameDepthId = ''; + private pickingReadbackId = ''; + private frameWidth = 1; + private frameHeight = 1; + private frameIndex = 0; + private analyticsPending = true; + private selectedVertex: number | null = 0; + private pendingPick: readonly [number, number] | null = null; + private neighborhoodDepth = INITIAL_NEIGHBORHOOD_DEPTH; + private centerX = 0; + private centerY = 0; + private zoom = 0.55; + private dragging = false; + private lastPointer: [number, number] | null = null; + private canvas: HTMLCanvasElement | null = null; + private statusElement: HTMLElement | null = null; + + constructor({device}: AnimationProps) { + super(); + if (device.type !== 'webgpu') { + throw new Error('luGraph GPU Graph Explorer requires WebGPU'); + } + this.device = device; + this.viewUniforms = device.createBuffer({ + id: 'lugraph-explorer-view', + byteLength: GRAPH_EXPLORER_VIEW_BYTE_LENGTH, + usage: Buffer.UNIFORM | Buffer.COPY_DST + }); + this.readbackRing = new GPUReadbackRing(device, { + id: 'lugraph-explorer-picking-readback', + byteLength: INDEX_PICKING_READBACK_BYTE_LENGTH, + slotCount: 2 + }); + + const dataset = makeGraphExplorerDataset(); + const sourceVertices = this.createChunkedVector('source-vertices', dataset.sourceChunks); + const targetVertices = this.createChunkedVector('target-vertices', dataset.targetChunks); + this.graph = new LuGraph({ + vertexCount: dataset.vertexCount, + sourceVertices, + targetVertices, + directed: true + }); + + const forward = this.createAdjacency('forward', dataset.vertexCount, this.graph.edgeCount); + const reverse = this.createAdjacency('reverse', dataset.vertexCount, this.graph.edgeCount); + this.topology = new LuGraphTopology({ + id: 'lugraph-explorer-topology', + graph: this.graph, + forward, + reverse, + invalidEdgeCount: this.createScalarVector('invalid-edges', 'uint32', 1) + }); + + this.degree = new LuGraphDegree({ + id: 'lugraph-explorer-degree', + topology: this.topology, + output: this.createScalarVector('vertex-degrees', 'uint32', dataset.vertexCount) + }); + this.pageRank = new LuGraphPageRank({ + id: 'lugraph-explorer-page-rank', + topology: this.topology, + output: this.createScalarVector('vertex-importance', 'float32', dataset.vertexCount), + residual: this.createScalarVector('rank-residual', 'float32', 1), + iterations: 16 + }); + this.components = new LuGraphConnectedComponents({ + id: 'lugraph-explorer-components', + topology: this.topology, + output: this.createScalarVector('vertex-components', 'uint32', dataset.vertexCount), + converged: this.createScalarVector('components-converged', 'uint32', 1), + iterations: 12 + }); + + this.seeds = this.createScalarVector('selected-vertices', 'uint32', 1, [0]); + this.seedCount = this.createScalarVector('selected-vertex-count', 'uint32', 1, [1]); + this.activeDepth = this.createScalarVector('active-neighborhood-depth', 'uint32', 1, [ + INITIAL_NEIGHBORHOOD_DEPTH + ]); + this.neighborhoodMask = this.createScalarVector( + 'selected-neighborhood', + 'uint32', + dataset.vertexCount + ); + this.search = new LuGraphBreadthFirstSearch({ + id: 'lugraph-explorer-neighborhood', + topology: this.topology, + seeds: this.seeds, + seedCount: this.seedCount, + distances: this.createScalarVector('vertex-distances', 'uint32', dataset.vertexCount), + predecessors: this.createScalarVector('vertex-predecessors', 'uint32', dataset.vertexCount), + mask: this.neighborhoodMask, + maxDepth: MAXIMUM_NEIGHBORHOOD_DEPTH, + activeDepth: this.activeDepth, + direction: 'both' + }); + + const positions = this.createCoordinateVector( + 'vertex-positions', + dataset.positions, + Buffer.VERTEX + ); + const velocities = this.createCoordinateVector('vertex-velocities', dataset.velocities); + this.pinned = this.createScalarVector('pinned-vertices', 'uint32', dataset.vertexCount); + this.reset = this.createScalarVector('reset-layout', 'uint32', 1, [0]); + this.layout = new LuGraphForceLayout({ + id: 'lugraph-explorer-layout', + topology: this.topology, + positions, + 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 + }); + + this.nodeModel = this.createNodeModel(); + this.pickingModel = this.createPickingModel(); + this.edgeModels = this.createEdgeModels(); + this.analysisGraph = this.createAnalysisGraph(); + + const [width, height] = this.getDeviceSize(); + this.frameGraph = this.createFrameGraph(width, height); + this.pickingGraph = this.createPickingGraph(width, height); + this.panels = new ExamplePanelManager({ + panel: makeHtmlCustomPanel({ + id: 'lugraph-explorer-controls', + title: 'GPU Graph Explorer', + html: this.getControlsHtml(), + onRender: root => this.attachControls(root) + }) + }); + this.panels.mount(); + this.writeViewUniforms(width, height); + } + + override async onInitialize({canvas}: AnimationProps): Promise { + if (canvas instanceof HTMLCanvasElement) { + this.canvas = canvas; + canvas.style.cursor = 'grab'; + canvas.addEventListener('pointerdown', this.handlePointerDown); + canvas.addEventListener('pointermove', this.handlePointerMove); + canvas.addEventListener('pointerup', this.handlePointerUp); + canvas.addEventListener('pointercancel', this.handlePointerUp); + canvas.addEventListener('wheel', this.handleWheel, {passive: false}); + } + } + + override onRender({device}: AnimationProps): void { + const [width, height] = this.getDeviceSize(); + if (width !== this.frameWidth || height !== this.frameHeight) { + this.frameGraph.destroy(); + this.pickingGraph.destroy(); + this.frameGraph = this.createFrameGraph(width, height); + this.pickingGraph = this.createPickingGraph(width, height); + } + this.writeViewUniforms(width, height); + + if (this.analyticsPending) { + this.analysisGraph.encode(device.commandEncoder, {parameters: undefined}); + this.analyticsPending = false; + } + + const framebuffer = device + .getDefaultCanvasContext() + .getCurrentFramebuffer({depthStencilFormat: 'depth24plus'}); + this.frameGraph.encode(device.commandEncoder, { + parameters: {width, height}, + frameTextures: { + [this.frameColorId]: { + texture: framebuffer.colorAttachments[0].texture, + frameId: this.frameIndex + }, + [this.frameDepthId]: { + texture: framebuffer.depthStencilAttachment!.texture, + frameId: this.frameIndex + } + } + }); + + if (this.pendingPick) { + const ticket = this.readbackRing.tryAcquire(); + if (ticket) { + const pixel = this.pendingPick; + this.pendingPick = null; + this.pickingGraph.encode(device.commandEncoder, { + parameters: {pixel}, + buffers: {[this.pickingReadbackId]: ticket.buffer} + }); + ticket.markEncoded({byteLength: 8}); + queueMicrotask(() => void this.readPickedVertex(ticket)); + } + } + this.frameIndex++; + } + + override onFinalize(): void { + if (this.canvas) { + this.canvas.removeEventListener('pointerdown', this.handlePointerDown); + this.canvas.removeEventListener('pointermove', this.handlePointerMove); + this.canvas.removeEventListener('pointerup', this.handlePointerUp); + this.canvas.removeEventListener('pointercancel', this.handlePointerUp); + this.canvas.removeEventListener('wheel', this.handleWheel); + } + this.panels.finalize(); + this.analysisGraph.destroy(); + this.frameGraph.destroy(); + this.pickingGraph.destroy(); + for (const {model} of this.edgeModels) model.destroy(); + this.nodeModel.destroy(); + this.pickingModel.destroy(); + for (const vector of this.vectors) vector.destroy(); + for (const buffer of this.buffers) buffer.destroy(); + this.readbackRing.destroy(); + this.viewUniforms.destroy(); + } + + /** Builds persistent analytics once; animation never reruns PageRank or components. */ + private createAnalysisGraph(): CompiledGPUCommandGraph { + const graph = new GPUCommandGraph(this.device, {id: 'lugraph-explorer-analysis'}); + this.topology.addToGraph(graph); + this.degree.addToGraph(graph); + this.components.addToGraph(graph); + this.pageRank.addToGraph(graph); + return graph.compile(); + } + + /** Composes GPU-resident layout, dynamic neighborhood search, and direct chunked rendering. */ + private createFrameGraph( + width: number, + height: number + ): CompiledGPUCommandGraph { + this.frameWidth = width; + this.frameHeight = height; + const graph = new GPUCommandGraph(this.device, { + id: 'lugraph-explorer-frame' + }); + this.layout.addToGraph(graph); + this.search.addToGraph(graph); + + const positions = graph.importGPUVector('render-positions', this.layout.positions).data[0]; + const importance = graph.importGPUVector('render-importance', this.pageRank.output).data[0]; + const componentLabels = graph.importGPUVector('render-components', this.components.output) + .data[0]; + const distances = graph.importGPUVector('render-distances', this.search.distances).data[0]; + const mask = graph.importGPUVector('render-neighborhood', this.neighborhoodMask).data[0]; + const sourceVertices = graph.importGPUVector('render-sources', this.graph.sourceVertices); + const targetVertices = graph.importGPUVector('render-targets', this.graph.targetVertices); + const view = graph.importBuffer( + { + id: 'render-view', + byteLength: this.viewUniforms.byteLength, + usage: this.viewUniforms.usage + }, + this.viewUniforms + ); + const color = graph.importFrameTexture({ + id: 'lugraph-frame-color', + format: this.device.preferredColorFormat, + width, + height, + usage: Texture.RENDER + }); + const depth = graph.importFrameTexture({ + id: 'lugraph-frame-depth', + format: 'depth24plus', + width, + height, + usage: Texture.RENDER + }); + this.frameColorId = color.id; + this.frameDepthId = depth.id; + + const resources: GraphBufferUse[] = [ + {buffer: positions, usage: 'storage-read'}, + {buffer: importance, usage: 'storage-read'}, + {buffer: componentLabels, usage: 'storage-read'}, + {buffer: distances, usage: 'storage-read'}, + {buffer: mask, usage: 'storage-read'}, + {buffer: view, usage: 'uniform'} + ]; + for (const {chunkIndex} of this.edgeModels) { + resources.push( + {buffer: sourceVertices.data[chunkIndex], usage: 'storage-read'}, + {buffer: targetVertices.data[chunkIndex], usage: 'storage-read'} + ); + } + graph.addRenderPass({ + id: 'lugraph-render-edges-and-vertices', + attachments: { + colorAttachments: [graph.createTextureView(color)], + depthStencilAttachment: graph.createTextureView(depth) + }, + resources, + compile: () => ({ + getRenderPassProps: () => ({ + id: 'lugraph-explorer-render', + clearColor: [0.016, 0.022, 0.045, 1], + clearDepth: 1, + clearStencil: false + }), + encode: ({renderPass}) => { + for (const {model} of this.edgeModels) model.draw(renderPass); + this.nodeModel.draw(renderPass); + } + }) + }); + return graph.compile(); + } + + /** Compiles event-driven integer picking; no staging buffer is read during ordinary frames. */ + private createPickingGraph( + width: number, + height: number + ): CompiledGPUCommandGraph { + const graph = new GPUCommandGraph(this.device, { + id: 'lugraph-explorer-picking' + }); + const positions = graph.importGPUVector('picking-positions', this.layout.positions).data[0]; + const importance = graph.importGPUVector('picking-importance', this.pageRank.output).data[0]; + const view = graph.importBuffer( + { + id: 'picking-view', + byteLength: this.viewUniforms.byteLength, + usage: this.viewUniforms.usage + }, + this.viewUniforms + ); + const target = new GPUIndexPickingTarget(graph, { + id: 'lugraph-vertex-picking', + width, + height + }); + const renderId = 'lugraph-render-picking'; + graph.addRenderPass({ + id: renderId, + attachments: target.attachments, + resources: [ + {buffer: positions, usage: 'vertex'}, + {buffer: importance, usage: 'storage-read'}, + {buffer: view, usage: 'uniform'} + ], + compile: () => ({ + getRenderPassProps: () => target.renderPassProps, + encode: ({renderPass}) => { + this.pickingModel.draw(renderPass); + } + }) + }); + target.addReadbackPass({after: renderId, getPixel: parameters => parameters.pixel}); + this.pickingReadbackId = target.readback.id; + return graph.compile(); + } + + /** Binds progressive positions as real instance vertex attributes without copying. */ + private createNodeModel(): Model { + return new Model(this.device, { + id: 'lugraph-explorer-nodes', + source: GRAPH_EXPLORER_NODE_SHADER, + topology: 'triangle-list', + vertexCount: 6, + isInstanced: true, + instanceCount: this.graph.vertexCount, + colorAttachmentFormats: [this.device.preferredColorFormat], + depthStencilAttachmentFormat: 'depth24plus', + attributes: {nodePosition: this.getVectorBuffer(this.layout.positions)}, + bufferLayout: [{name: 'nodePosition', format: 'float32x2', stepMode: 'instance'}], + bindings: { + importance: this.getVectorBuffer(this.pageRank.output), + components: this.getVectorBuffer(this.components.output), + distances: this.getVectorBuffer(this.search.distances), + selectionMask: this.getVectorBuffer(this.neighborhoodMask), + view: this.viewUniforms + }, + shaderLayout: { + attributes: [{name: 'nodePosition', location: 0, type: 'vec2'}], + bindings: [ + {name: 'importance', type: 'read-only-storage', group: 0, location: 0}, + {name: 'components', type: 'read-only-storage', group: 0, location: 1}, + {name: 'distances', type: 'read-only-storage', group: 0, location: 2}, + {name: 'selectionMask', type: 'read-only-storage', group: 0, location: 3}, + {name: 'view', type: 'uniform', group: 0, location: 4} + ] + }, + parameters: {depthCompare: 'less-equal', depthWriteEnabled: true} + }); + } + + /** Uses original GPUData chunks directly; no edge source buffers are packed or copied. */ + private createEdgeModels(): EdgeModel[] { + const edgeModels: EdgeModel[] = []; + for (const [chunkIndex, source] of this.graph.sourceVertices.data.entries()) { + if (source.length === 0) continue; + const target = this.graph.targetVertices.data[chunkIndex]; + edgeModels.push({ + chunkIndex, + model: new Model(this.device, { + id: `lugraph-explorer-edges-${chunkIndex}`, + source: GRAPH_EXPLORER_EDGE_SHADER, + topology: 'line-list', + vertexCount: 2, + isInstanced: true, + instanceCount: source.length, + colorAttachmentFormats: [this.device.preferredColorFormat], + depthStencilAttachmentFormat: 'depth24plus', + bindings: { + positions: this.getVectorBuffer(this.layout.positions), + sourceVertices: source.buffer, + targetVertices: target.buffer, + distances: this.getVectorBuffer(this.search.distances), + view: this.viewUniforms + }, + shaderLayout: { + attributes: [], + bindings: [ + {name: 'positions', type: 'read-only-storage', group: 0, location: 0}, + {name: 'sourceVertices', type: 'read-only-storage', group: 0, location: 1}, + {name: 'targetVertices', type: 'read-only-storage', group: 0, location: 2}, + {name: 'distances', type: 'read-only-storage', group: 0, location: 3}, + {name: 'view', type: 'uniform', group: 0, location: 4} + ] + }, + parameters: {depthCompare: 'less-equal', depthWriteEnabled: false} + }) + }); + } + return edgeModels; + } + + /** Writes stable node identifiers into the existing integer picking target. */ + private createPickingModel(): Model { + return new Model(this.device, { + id: 'lugraph-explorer-picking-nodes', + source: GRAPH_EXPLORER_PICKING_SHADER, + topology: 'triangle-list', + vertexCount: 6, + isInstanced: true, + instanceCount: this.graph.vertexCount, + colorAttachmentFormats: ['rgba8unorm', 'rg32sint'], + depthStencilAttachmentFormat: 'depth24plus', + attributes: {nodePosition: this.getVectorBuffer(this.layout.positions)}, + bufferLayout: [{name: 'nodePosition', format: 'float32x2', stepMode: 'instance'}], + bindings: { + importance: this.getVectorBuffer(this.pageRank.output), + view: this.viewUniforms + }, + shaderLayout: { + attributes: [{name: 'nodePosition', location: 0, type: 'vec2'}], + bindings: [ + {name: 'importance', type: 'read-only-storage', group: 0, location: 0}, + {name: 'view', type: 'uniform', group: 0, location: 1} + ] + }, + parameters: {depthCompare: 'less-equal', depthWriteEnabled: true} + }); + } + + /** Preserves every edge batch, including empty partitions and borrowed buffer ownership. */ + private createChunkedVector(name: string, chunks: Uint32Array[]): GPUVector<'uint32'> { + const data = chunks.map((values, chunkIndex) => { + const buffer = this.device.createBuffer({ + id: `lugraph-explorer-${name}-${chunkIndex}`, + data: values.length > 0 ? values : new Uint32Array(1), + 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; + } + + /** Creates one caller-owned packed scalar column or status without implicit transfers. */ + private createScalarVector( + name: string, + format: Format, + length: number, + values?: number[] + ): GPUVector { + const buffer = this.device.createBuffer({ + id: `lugraph-explorer-${name}`, + byteLength: Math.max(length, 1) * UINT32_BYTE_LENGTH, + usage: Buffer.STORAGE | Buffer.COPY_SRC | Buffer.COPY_DST + }); + if (values?.length) { + buffer.write(format === 'float32' ? Float32Array.from(values) : Uint32Array.from(values)); + } + this.buffers.push(buffer); + const vector = new GPUVector({ + type: 'buffer', + name, + format, + buffer, + length, + ownsBuffer: false + }); + this.vectors.push(vector); + return vector; + } + + /** Creates caller-owned progressive simulation state, optionally directly vertex-bindable. */ + private createCoordinateVector( + name: string, + values: Float32Array, + additionalUsage = 0 + ): GPUVector<'float32x2'> { + const buffer = this.device.createBuffer({ + id: `lugraph-explorer-${name}`, + data: values, + usage: Buffer.STORAGE | Buffer.COPY_DST | 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; + } + + /** Allocates every caller-owned topology/status vector without aliasing graph sources. */ + 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; + } + + private getDeviceSize(): [number, number] { + const [width, height] = this.device.getDefaultCanvasContext().getDevicePixelSize(); + return [Math.max(width, 1), Math.max(height, 1)]; + } + + private writeViewUniforms(width: number, height: number): void { + const values = new ArrayBuffer(GRAPH_EXPLORER_VIEW_BYTE_LENGTH); + new Float32Array(values, 0, 4).set([ + this.centerX, + this.centerY, + this.zoom, + width / Math.max(height, 1) + ]); + new Uint32Array(values, 16, 4).set([ + this.selectedVertex ?? INVALID_VERTEX, + this.neighborhoodDepth, + this.graph.vertexCount, + this.dragging ? 1 : 0 + ]); + this.viewUniforms.write(new Uint8Array(values)); + } + + private async readPickedVertex(ticket: GPUReadbackTicket): Promise { + try { + const pickedVertex = decodeGPUIndexPickInfo(await ticket.read()).objectIndex; + this.selectedVertex = pickedVertex; + if (pickedVertex === null) { + this.getVectorBuffer(this.seedCount).write(Uint32Array.of(0)); + } else { + this.getVectorBuffer(this.seeds).write(Uint32Array.of(pickedVertex)); + this.getVectorBuffer(this.seedCount).write(Uint32Array.of(1)); + } + this.updateStatus(); + } catch { + // Device loss or cancellation releases the staging slot without changing selection. + } + } + + private readonly handlePointerDown = (event: PointerEvent): void => { + if (!this.canvas) return; + this.dragging = true; + this.lastPointer = [event.clientX, event.clientY]; + this.canvas.style.cursor = 'grabbing'; + this.canvas.setPointerCapture(event.pointerId); + const devicePixels = this.device + .getDefaultCanvasContext() + .cssToDevicePixels([event.offsetX, event.offsetY], false); + this.pendingPick = [ + Math.max(0, Math.min(this.frameWidth - 1, devicePixels.x)), + Math.max(0, Math.min(this.frameHeight - 1, devicePixels.y)) + ]; + }; + + private readonly handlePointerMove = (event: PointerEvent): void => { + if (!this.dragging || !this.canvas || !this.lastPointer) return; + const previous = this.lastPointer; + this.lastPointer = [event.clientX, event.clientY]; + if (this.selectedVertex === null || event.shiftKey) { + const rectangle = this.canvas.getBoundingClientRect(); + this.centerX -= (2 * (event.clientX - previous[0])) / (rectangle.height * this.zoom); + this.centerY += (2 * (event.clientY - previous[1])) / (rectangle.height * this.zoom); + return; + } + + const rectangle = this.canvas.getBoundingClientRect(); + const aspect = rectangle.width / Math.max(rectangle.height, 1); + const normalizedX = ((event.clientX - rectangle.left) / rectangle.width) * 2 - 1; + const normalizedY = 1 - ((event.clientY - rectangle.top) / rectangle.height) * 2; + const position = [ + this.centerX + (normalizedX * aspect) / this.zoom, + this.centerY + normalizedY / this.zoom + ]; + this.getVectorBuffer(this.pinned).write( + Uint32Array.of(1), + this.selectedVertex * UINT32_BYTE_LENGTH + ); + this.getVectorBuffer(this.layout.positions).write( + Float32Array.from(position), + this.selectedVertex * 2 * Float32Array.BYTES_PER_ELEMENT + ); + this.getVectorBuffer(this.layout.velocities).write( + Float32Array.of(0, 0), + this.selectedVertex * 2 * Float32Array.BYTES_PER_ELEMENT + ); + this.updateStatus(); + }; + + private readonly handlePointerUp = (event: PointerEvent): void => { + if (this.canvas?.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + this.dragging = false; + this.lastPointer = null; + if (this.canvas) this.canvas.style.cursor = 'grab'; + }; + + private readonly handleWheel = (event: WheelEvent): void => { + event.preventDefault(); + this.zoom = Math.max(0.08, Math.min(4, this.zoom * Math.exp(-event.deltaY * 0.001))); + this.updateStatus(); + }; + + private getControlsHtml(): string { + return `

+

GPU topology, PageRank, components, bounded breadth-first selection, + and progressive force integration feed these node and edge draws without per-frame readback.

+ +
+ + +
+

+

Click nodes to inspect neighborhoods. Drag to pin; + shift-drag to pan; scroll to zoom.

+
`; + } + + private attachControls(root: HTMLElement): () => void { + const depth = root.querySelector('[data-depth]'); + const reset = root.querySelector('[data-reset]'); + const unpin = root.querySelector('[data-unpin]'); + this.statusElement = root.querySelector('[data-status]'); + const updateDepth = () => { + this.neighborhoodDepth = Number(depth?.value ?? INITIAL_NEIGHBORHOOD_DEPTH); + this.getVectorBuffer(this.activeDepth).write(Uint32Array.of(this.neighborhoodDepth)); + this.updateStatus(); + }; + const resetLayout = () => { + this.getVectorBuffer(this.reset).write(Uint32Array.of(1)); + }; + const clearPins = () => { + this.getVectorBuffer(this.pinned).write(new Uint32Array(this.graph.vertexCount)); + this.updateStatus(); + }; + depth?.addEventListener('input', updateDepth); + reset?.addEventListener('click', resetLayout); + unpin?.addEventListener('click', clearPins); + this.updateStatus(); + return () => { + depth?.removeEventListener('input', updateDepth); + reset?.removeEventListener('click', resetLayout); + unpin?.removeEventListener('click', clearPins); + this.statusElement = null; + }; + } + + private updateStatus(): void { + if (!this.statusElement) return; + const selection = this.selectedVertex === null ? 'none' : String(this.selectedVertex); + this.statusElement.textContent = `${this.graph.vertexCount} vertices · ${this.graph.edgeCount} chunked edges · selected ${selection} · depth ${this.neighborhoodDepth}`; + } +} diff --git a/examples/experimental/lugraph-explorer/graph-data.ts b/examples/experimental/lugraph-explorer/graph-data.ts new file mode 100644 index 0000000000..980caa4e48 --- /dev/null +++ b/examples/experimental/lugraph-explorer/graph-data.ts @@ -0,0 +1,103 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/** Four source communities keep component coloring and hub importance visually distinguishable. */ +export const GRAPH_EXPLORER_COMMUNITY_COUNT = 4; + +/** Exact all-pairs layout remains interactive at this bounded demonstration population. */ +export const GRAPH_EXPLORER_DEFAULT_VERTEX_COUNT = 128; + +/** Deterministic graph input preserving an explicit empty aligned source batch. */ +export type GraphExplorerDataset = { + /** Number of stable zero-based graph vertices. */ + vertexCount: number; + /** Original directed source-edge batches, including one intentionally empty batch. */ + sourceChunks: Uint32Array[]; + /** Original target-edge batches with source-aligned row counts and chunk boundaries. */ + targetChunks: Uint32Array[]; + /** Initial directly renderable two-component positions in source-vertex order. */ + positions: Float32Array; + /** Caller-owned progressive two-component velocities in source-vertex order. */ + velocities: Float32Array; +}; + +/** + * Creates deterministic directed community rings, unequal-importance hubs, and one isolated node. + * + * One bridge connects the first two communities; the remaining communities and final isolated + * vertex stay disconnected. Original edge rows are deliberately partitioned around an empty + * middle chunk so the explorer can draw the actual caller-owned edge batches without packing. + */ +export function makeGraphExplorerDataset( + vertexCount: number = GRAPH_EXPLORER_DEFAULT_VERTEX_COUNT +): GraphExplorerDataset { + if (!Number.isSafeInteger(vertexCount) || vertexCount < GRAPH_EXPLORER_COMMUNITY_COUNT * 2) { + throw new Error('Graph explorer requires at least eight graph vertices'); + } + + const positions = new Float32Array(vertexCount * 2); + const velocities = new Float32Array(vertexCount * 2); + const sources: number[] = []; + const targets: number[] = []; + + for (let community = 0; community < GRAPH_EXPLORER_COMMUNITY_COUNT; community++) { + const firstVertex = Math.floor((community * vertexCount) / GRAPH_EXPLORER_COMMUNITY_COUNT); + const nextCommunityVertex = Math.floor( + ((community + 1) * vertexCount) / GRAPH_EXPLORER_COMMUNITY_COUNT + ); + const lastConnectedVertex = + community === GRAPH_EXPLORER_COMMUNITY_COUNT - 1 + ? nextCommunityVertex - 1 + : nextCommunityVertex; + const connectedCount = lastConnectedVertex - firstVertex; + const centerX = community % 2 === 0 ? -0.52 : 0.52; + const centerY = community < 2 ? -0.43 : 0.43; + + for (let vertex = firstVertex; vertex < nextCommunityVertex; vertex++) { + const communityIndex = vertex - firstVertex; + const angle = communityIndex * 2.399963229728653; + const radius = 0.11 + (communityIndex % 7) * 0.016; + positions[vertex * 2] = centerX + Math.cos(angle) * radius; + positions[vertex * 2 + 1] = centerY + Math.sin(angle) * radius; + + if (vertex >= lastConnectedVertex || connectedCount < 2) { + continue; + } + + sources.push(vertex); + targets.push(firstVertex + ((communityIndex + 1) % connectedCount)); + + if (connectedCount > 3) { + sources.push(vertex); + targets.push(firstVertex + ((communityIndex + 3) % connectedCount)); + } + + if (communityIndex > 0 && communityIndex % 4 === 0) { + sources.push(vertex); + targets.push(firstVertex); + } + } + } + + // Join only the first pair so weak components, an isolated vertex, and hubs remain visible. + sources.push(0); + targets.push(Math.floor(vertexCount / GRAPH_EXPLORER_COMMUNITY_COUNT)); + + const midpoint = Math.ceil(sources.length / 2); + return { + vertexCount, + sourceChunks: [ + Uint32Array.from(sources.slice(0, midpoint)), + new Uint32Array(0), + Uint32Array.from(sources.slice(midpoint)) + ], + targetChunks: [ + Uint32Array.from(targets.slice(0, midpoint)), + new Uint32Array(0), + Uint32Array.from(targets.slice(midpoint)) + ], + positions, + velocities + }; +} diff --git a/examples/experimental/lugraph-explorer/graph-shaders.ts b/examples/experimental/lugraph-explorer/graph-shaders.ts new file mode 100644 index 0000000000..a3f1c8b390 --- /dev/null +++ b/examples/experimental/lugraph-explorer/graph-shaders.ts @@ -0,0 +1,186 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/** Four floating-point framing words followed by four unsigned interaction words. */ +export const GRAPH_EXPLORER_VIEW_BYTE_LENGTH = 32; + +/** Unsigned no-selection and unreachable-distance sentinel shared with GPU graph traversal. */ +export const GRAPH_EXPLORER_INVALID_VERTEX = 0xffffffff; + +// Framing: center x, center y, zoom, viewport aspect ratio. +// Interaction: selected vertex, maximum traversal depth, vertex count, application flags. +const GRAPH_EXPLORER_VIEW_SOURCE = /* wgsl */ ` +struct GraphExplorerView { + framing: vec4, + interaction: vec4, +};`; + +/** + * Draws one actual caller-owned source/target edge batch without packing graph source chunks. + * + * Bindings: positions=0, sourceVertices=1, targetVertices=2, distances=3, view=4. + * Use a line-list model with two vertices and one instance for every row in this source batch. + */ +export const GRAPH_EXPLORER_EDGE_SHADER = /* wgsl */ ` +${GRAPH_EXPLORER_VIEW_SOURCE} + +@group(0) @binding(0) var positions: array; +@group(0) @binding(1) var sourceVertices: array; +@group(0) @binding(2) var targetVertices: array; +@group(0) @binding(3) var distances: array; +@group(0) @binding(4) var view: GraphExplorerView; + +struct EdgeVertexOutput { + @builtin(position) position: vec4, + @location(0) color: vec4, +}; + +@vertex fn vertexMain( + @builtin(vertex_index) endpointIndex: u32, + @builtin(instance_index) edgeIndex: u32 +) -> EdgeVertexOutput { + let sourceVertex = sourceVertices[edgeIndex]; + let targetVertex = targetVertices[edgeIndex]; + let vertex = select(sourceVertex, targetVertex, endpointIndex == 1u); + let position = vec2(positions[vertex * 2u], positions[vertex * 2u + 1u]); + let centered = (position - view.framing.xy) * view.framing.z; + let projected = vec2(centered.x / max(view.framing.w, 0.001), centered.y); + let hasSelection = view.interaction.x != ${GRAPH_EXPLORER_INVALID_VERTEX}u; + let connected = + distances[sourceVertex] != ${GRAPH_EXPLORER_INVALID_VERTEX}u && + distances[targetVertex] != ${GRAPH_EXPLORER_INVALID_VERTEX}u; + + var output: EdgeVertexOutput; + output.position = vec4(projected, 0.35, 1.0); + output.color = select( + vec4(0.30, 0.47, 0.70, select(0.23, 0.07, hasSelection)), + vec4(0.39, 0.87, 1.0, 0.76), + hasSelection && connected + ); + return output; +} + +@fragment fn fragmentMain(input: EdgeVertexOutput) -> @location(0) vec4 { + return input.color; +}`; + +/** + * Draws source-aligned circular nodes from the original float32x2 instance vertex buffer. + * + * Attribute: nodePosition at location zero, stepMode instance. + * Bindings: importance=0, components=1, distances=2, selectionMask=3, view=4. + */ +export const GRAPH_EXPLORER_NODE_SHADER = /* wgsl */ ` +${GRAPH_EXPLORER_VIEW_SOURCE} + +@group(0) @binding(0) var importance: array; +@group(0) @binding(1) var components: array; +@group(0) @binding(2) var distances: array; +@group(0) @binding(3) var selectionMask: array; +@group(0) @binding(4) var view: GraphExplorerView; + +struct NodeVertexOutput { + @builtin(position) position: vec4, + @location(0) color: vec4, + @location(1) coordinates: vec2, +}; + +@vertex fn vertexMain( + @location(0) nodePosition: vec2, + @builtin(vertex_index) vertexIndex: u32, + @builtin(instance_index) sourceIndex: u32 +) -> NodeVertexOutput { + 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) + ); + let palette = array, 6>( + vec3(0.34, 0.84, 1.0), + vec3(0.76, 0.50, 1.0), + vec3(1.0, 0.71, 0.37), + vec3(0.36, 0.90, 0.66), + vec3(1.0, 0.45, 0.65), + vec3(0.91, 0.92, 0.52) + ); + let radius = clamp(0.012 + sqrt(max(importance[sourceIndex], 0.0)) * 0.085, 0.012, 0.050); + let corner = corners[vertexIndex]; + let centered = (nodePosition - view.framing.xy) * view.framing.z; + let aspect = max(view.framing.w, 0.001); + let projected = vec2(centered.x / aspect, centered.y) + + vec2(corner.x / aspect, corner.y) * radius; + let hasSelection = view.interaction.x != ${GRAPH_EXPLORER_INVALID_VERTEX}u; + let reachable = selectionMask[sourceIndex] != 0u && + distances[sourceIndex] != ${GRAPH_EXPLORER_INVALID_VERTEX}u; + let selected = sourceIndex == view.interaction.x; + var color = palette[components[sourceIndex] % 6u]; + color = select(color, color * 0.24, hasSelection && !reachable); + color = select(color, vec3(1.0, 0.96, 0.66), selected); + + var output: NodeVertexOutput; + output.position = vec4(projected, 0.1, 1.0); + output.color = vec4(color, select(0.92, 1.0, selected)); + output.coordinates = corner; + return output; +} + +@fragment fn fragmentMain(input: NodeVertexOutput) -> @location(0) vec4 { + let distanceFromCenter = length(input.coordinates); + if (distanceFromCenter > 1.0) { discard; } + let edge = 1.0 - smoothstep(0.70, 1.0, distanceFromCenter); + return vec4(input.color.rgb * (0.72 + edge * 0.28), input.color.a * edge); +}`; + +/** + * Emits GPUIndexPickingTarget-compatible object IDs from the same true instance vertex buffer. + * + * Attribute: nodePosition at location zero, stepMode instance. + * Bindings: importance=0, view=1. Fragment targets: rgba8unorm and rg32sint. + */ +export const GRAPH_EXPLORER_PICKING_SHADER = /* wgsl */ ` +${GRAPH_EXPLORER_VIEW_SOURCE} + +@group(0) @binding(0) var importance: array; +@group(0) @binding(1) var view: GraphExplorerView; + +struct PickingVertexOutput { + @builtin(position) position: vec4, + @location(0) coordinates: vec2, + @location(1) @interpolate(flat) sourceIndex: u32, +}; + +struct PickingFragmentOutput { + @location(0) color: vec4, + @location(1) indices: vec2, +}; + +@vertex fn vertexMain( + @location(0) nodePosition: vec2, + @builtin(vertex_index) vertexIndex: u32, + @builtin(instance_index) sourceIndex: u32 +) -> PickingVertexOutput { + 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) + ); + let corner = corners[vertexIndex]; + let radius = clamp(0.012 + sqrt(max(importance[sourceIndex], 0.0)) * 0.085, 0.012, 0.050); + let centered = (nodePosition - view.framing.xy) * view.framing.z; + let aspect = max(view.framing.w, 0.001); + let projected = vec2(centered.x / aspect, centered.y) + + vec2(corner.x / aspect, corner.y) * radius; + + var output: PickingVertexOutput; + output.position = vec4(projected, 0.1, 1.0); + output.coordinates = corner; + output.sourceIndex = sourceIndex; + return output; +} + +@fragment fn fragmentMain(input: PickingVertexOutput) -> PickingFragmentOutput { + if (length(input.coordinates) > 1.0) { discard; } + var output: PickingFragmentOutput; + output.color = vec4(0.0, 0.0, 0.0, 1.0); + output.indices = vec2(i32(input.sourceIndex), 0); + return output; +}`; diff --git a/modules/experimental/test/lugraph/lu-graph-explorer.node.spec.ts b/modules/experimental/test/lugraph/lu-graph-explorer.node.spec.ts new file mode 100644 index 0000000000..38fc2941a9 --- /dev/null +++ b/modules/experimental/test/lugraph/lu-graph-explorer.node.spec.ts @@ -0,0 +1,160 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {existsSync, readFileSync} from 'node:fs'; +import {describe, expect, test} from 'vitest'; +import {makeGraphExplorerDataset} from '../../../../examples/experimental/lugraph-explorer/graph-data'; +import { + GRAPH_EXPLORER_EDGE_SHADER, + GRAPH_EXPLORER_NODE_SHADER, + GRAPH_EXPLORER_PICKING_SHADER, + GRAPH_EXPLORER_VIEW_BYTE_LENGTH +} from '../../../../examples/experimental/lugraph-explorer/graph-shaders'; + +describe('interactive luGraph explorer deterministic source graph', () => { + test('publishes deterministic typed graph data without allocating browser or GPU resources', () => { + const first = makeGraphExplorerDataset(); + const second = makeGraphExplorerDataset(); + + expect(first.vertexCount).toBe(128); + expect(first.positions).toBeInstanceOf(Float32Array); + expect(first.velocities).toBeInstanceOf(Float32Array); + expect(first.positions.length).toBe(first.vertexCount * 2); + expect(first.velocities.length).toBe(first.vertexCount * 2); + expect(Array.from(first.positions)).toEqual(Array.from(second.positions)); + expect(Array.from(first.velocities)).toEqual(Array.from(second.velocities)); + expect(first.sourceChunks.map(chunk => Array.from(chunk))).toEqual( + second.sourceChunks.map(chunk => Array.from(chunk)) + ); + expect(first.targetChunks.map(chunk => Array.from(chunk))).toEqual( + second.targetChunks.map(chunk => Array.from(chunk)) + ); + expect(Array.from(first.positions).every(Number.isFinite)).toBe(true); + expect(Array.from(first.velocities).every(velocity => velocity === 0)).toBe(true); + }); + + test.each([ + 64, 128, 256 + ])('preserves aligned original uint32 edge batches: %i vertices', vertexCount => { + const dataset = makeGraphExplorerDataset(vertexCount); + + expect(dataset.vertexCount).toBe(vertexCount); + expect(dataset.sourceChunks).toHaveLength(3); + expect(dataset.targetChunks).toHaveLength(3); + expect(dataset.sourceChunks[1]).toBeInstanceOf(Uint32Array); + expect(dataset.targetChunks[1]).toBeInstanceOf(Uint32Array); + expect(dataset.sourceChunks[1]).toHaveLength(0); + expect(dataset.targetChunks[1]).toHaveLength(0); + + for (const [chunkIndex, sources] of dataset.sourceChunks.entries()) { + const targets = dataset.targetChunks[chunkIndex]; + expect(sources).toBeInstanceOf(Uint32Array); + expect(targets).toBeInstanceOf(Uint32Array); + expect(targets.length).toBe(sources.length); + expect(Array.from(sources).every(source => source < vertexCount)).toBe(true); + expect(Array.from(targets).every(target => target < vertexCount)).toBe(true); + } + }); + + test('contains multiple weak components and high-degree vertices for visible graph analytics', () => { + const dataset = makeGraphExplorerDataset(); + const neighbors = Array.from({length: dataset.vertexCount}, () => new Set()); + + for (const [chunkIndex, sources] of dataset.sourceChunks.entries()) { + for (const [rowIndex, source] of sources.entries()) { + const target = dataset.targetChunks[chunkIndex][rowIndex]; + neighbors[source].add(target); + neighbors[target].add(source); + } + } + + const visited = new Set(); + let componentCount = 0; + for (let vertexIndex = 0; vertexIndex < dataset.vertexCount; vertexIndex++) { + if (visited.has(vertexIndex)) continue; + componentCount++; + const frontier = [vertexIndex]; + while (frontier.length > 0) { + const current = frontier.pop()!; + if (visited.has(current)) continue; + visited.add(current); + for (const neighbor of neighbors[current]) frontier.push(neighbor); + } + } + + expect(componentCount).toBeGreaterThan(1); + expect(Math.max(...neighbors.map(vertices => vertices.size))).toBeGreaterThanOrEqual(4); + }); +}); + +describe('interactive luGraph explorer dependency-free rendering integration', () => { + test('keeps its website-only example outside Yarn workspace manifests', () => { + const packagePath = new URL( + '../../../../examples/experimental/lugraph-explorer/package.json', + import.meta.url + ); + expect(existsSync(packagePath)).toBe(false); + }); + + test('declares compatible GPU node, original-batch edge, and signed-integer picking shaders', () => { + expect(GRAPH_EXPLORER_VIEW_BYTE_LENGTH).toBe(32); + for (const shader of [ + GRAPH_EXPLORER_EDGE_SHADER, + GRAPH_EXPLORER_NODE_SHADER, + GRAPH_EXPLORER_PICKING_SHADER + ]) { + expect(shader).toMatch(/@vertex/); + expect(shader).toMatch(/@fragment/); + expect(shader).not.toMatch(/atomic\s*<\s*f32\s*>/); + } + expect(GRAPH_EXPLORER_PICKING_SHADER).toMatch(/@location\(1\)/); + expect(GRAPH_EXPLORER_PICKING_SHADER).toMatch(/vec2/); + }); + + test('registers the API guide in both documentation navigation trees', () => { + const documentationContents = readFileSync( + new URL('../../../../docs/table-of-contents.json', import.meta.url), + 'utf8' + ); + expect(documentationContents.match(/api-reference\/experimental\/lugraph/g)).toHaveLength(2); + const experimentalTabs = readFileSync( + new URL( + '../../../../website/src/components/docs/experimental-docs-tabs.tsx', + import.meta.url + ), + 'utf8' + ); + expect(experimentalTabs).toContain("id: 'lugraph'"); + }); + + test('registers the WebGPU explorer route, component, and discoverable sidebar entry', () => { + const exampleContents = readFileSync( + new URL('../../../../website/content/examples/table-of-contents.json', import.meta.url), + 'utf8' + ); + const examplePage = readFileSync( + new URL( + '../../../../website/content/examples/experimental/lugraph-explorer.mdx', + import.meta.url + ), + 'utf8' + ); + const examplesRegistry = readFileSync( + new URL('../../../../website/src/examples.tsx', import.meta.url), + 'utf8' + ); + const exampleThumbnails = readFileSync( + new URL('../../../../website/src/example-thumbnails.ts', import.meta.url), + 'utf8' + ); + + expect(exampleContents).toContain('experimental/lugraph-explorer'); + expect(examplePage).toContain(''); + expect(examplesRegistry).toContain('template={LuGraphExplorerApp}'); + expect(examplesRegistry).toContain("devices={['webgpu']}"); + expect(exampleThumbnails).toContain( + "'experimental/lugraph-explorer': 'showcase/packet-spraying'" + ); + }); +}); diff --git a/modules/experimental/test/lugraph/lu-graph-explorer.spec.ts b/modules/experimental/test/lugraph/lu-graph-explorer.spec.ts new file mode 100644 index 0000000000..83204f4a8d --- /dev/null +++ b/modules/experimental/test/lugraph/lu-graph-explorer.spec.ts @@ -0,0 +1,238 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {Buffer, Texture, type Device} from '@luma.gl/core'; +import type {AnimationProps} from '@luma.gl/engine'; +import {decodeGPUIndexPickInfo, INDEX_PICKING_READBACK_BYTE_LENGTH} from '@luma.gl/experimental'; +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 LuGraphExplorerAnimationLoopTemplate from '../../../../examples/experimental/lugraph-explorer/app'; +import {makeGraphExplorerDataset} from '../../../../examples/experimental/lugraph-explorer/graph-data'; + +type ExplorerGraphBindings = { + frameColorId: string; + frameDepthId: string; + pickingReadbackId: string; + frameWidth: number; + frameHeight: number; +}; + +test('luGraph explorer constructs actual GPU models and computes source-aligned graph analytics', async tapeTest => { + const device = await getWebGPUTestDevice(); + if (!device) { + tapeTest.comment('WebGPU is not available'); + tapeTest.end(); + return; + } + + const submitSpy = vi.spyOn(device, 'submit'); + let explorer: LuGraphExplorerAnimationLoopTemplate | undefined; + try { + explorer = new LuGraphExplorerAnimationLoopTemplate({device} as AnimationProps); + tapeTest.equal(submitSpy.mock.calls.length, 0, 'construction never submits hidden GPU work'); + submitSpy.mockRestore(); + + const dataset = makeGraphExplorerDataset(); + tapeTest.equal( + explorer.graph.vertexCount, + dataset.vertexCount, + 'source vertex identities stay stable' + ); + tapeTest.deepEqual( + explorer.graph.sourceVertices.data.map(chunk => chunk.length), + dataset.sourceChunks.map(chunk => chunk.length), + 'source edge vectors retain original nonempty, empty, and nonempty batches' + ); + tapeTest.deepEqual( + explorer.edgeModels.map(model => model.chunkIndex), + [0, 2], + 'each nonempty original edge batch has its own directly bound edge model' + ); + tapeTest.ok( + explorer.nodeModel.pipeline, + 'actual WebGPU node shader and vertex pipeline compile' + ); + tapeTest.ok( + explorer.pickingModel.pipeline, + 'actual integer picking shader and pipeline compile' + ); + tapeTest.equal( + explorer.layout.positions.data[0].buffer.usage & (Buffer.STORAGE | Buffer.VERTEX), + Buffer.STORAGE | Buffer.VERTEX, + 'progressive layout coordinates are the same physical render vertex allocation' + ); + + executeAnalysis(device, explorer); + const [degrees, componentLabels, importance, forwardCount, reverseCount, invalid, overflow] = + await Promise.all([ + readUint32Vector(explorer.degree.output), + readUint32Vector(explorer.components.output), + readFloat32Vector(explorer.pageRank.output), + readUint32Vector(explorer.topology.forward.count), + readUint32Vector(explorer.topology.reverse!.count), + readUint32Vector(explorer.topology.invalidEdgeCount), + readUint32Vector(explorer.topology.forward.overflow) + ]); + + tapeTest.equal( + forwardCount[0], + explorer.graph.edgeCount, + 'GPU builds every original directed edge' + ); + tapeTest.equal(reverseCount[0], explorer.graph.edgeCount, 'GPU builds full reverse adjacency'); + tapeTest.equal(invalid[0], 0, 'deterministic dataset contains no invalid source identifiers'); + tapeTest.equal(overflow[0], 0, 'caller-owned graph adjacency has adequate explicit capacity'); + tapeTest.equal( + degrees.reduce((sum, degree) => sum + degree, 0), + explorer.graph.edgeCount, + 'GPU degree outputs exactly account for all original source edges' + ); + tapeTest.equal(degrees[dataset.vertexCount - 1], 0, 'final isolated vertex has degree zero'); + tapeTest.equal( + componentLabels[0], + 0, + 'first community retains minimum stable source identifier' + ); + tapeTest.equal( + componentLabels[32], + 0, + 'one actual bridge joins the first two weak communities' + ); + tapeTest.equal(componentLabels[64], 64, 'third disconnected community keeps its own component'); + tapeTest.equal( + componentLabels[96], + 96, + 'fourth disconnected community keeps its own component' + ); + tapeTest.equal( + componentLabels[dataset.vertexCount - 1], + dataset.vertexCount - 1, + 'isolated node retains its own stable component identifier' + ); + tapeTest.ok( + importance.every(score => Number.isFinite(score) && score > 0), + 'real dangling-aware PageRank supplies positive node sizing values' + ); + tapeTest.ok( + Math.abs(importance.reduce((sum, score) => sum + score, 0) - 1) < 5e-5, + 'GPU node importance remains correctly normalized' + ); + } finally { + submitSpy.mockRestore(); + explorer?.onFinalize(); + } + + tapeTest.end(); +}); + +test('luGraph explorer renders original GPU chunks, highlights neighborhoods, pins, and picks stable nodes', async tapeTest => { + const device = await getWebGPUTestDevice(); + if (!device) { + tapeTest.comment('WebGPU is not available'); + tapeTest.end(); + return; + } + + let explorer: LuGraphExplorerAnimationLoopTemplate | undefined; + let color: Texture | undefined; + let depth: Texture | undefined; + let pickingReadback: Buffer | undefined; + try { + explorer = new LuGraphExplorerAnimationLoopTemplate({device} as AnimationProps); + const bindings = explorer as unknown as ExplorerGraphBindings; + color = device.createTexture({ + id: 'lugraph-explorer-test-color', + format: device.preferredColorFormat, + width: bindings.frameWidth, + height: bindings.frameHeight, + usage: Texture.RENDER + }); + depth = device.createTexture({ + id: 'lugraph-explorer-test-depth', + format: 'depth24plus', + width: bindings.frameWidth, + height: bindings.frameHeight, + usage: Texture.RENDER + }); + pickingReadback = device.createBuffer({ + id: 'lugraph-explorer-test-picking-readback', + byteLength: INDEX_PICKING_READBACK_BYTE_LENGTH, + usage: Buffer.COPY_DST | Buffer.MAP_READ + }); + + // Preserve one centered instance so its true rendered circle deterministically covers pixel 0. + (explorer.layout.positions.data[0].buffer as Buffer).write(Float32Array.from([0, 0])); + (explorer.layout.pinned!.data[0].buffer as Buffer).write(Uint32Array.from([1])); + + const encoder = device.createCommandEncoder({id: 'lugraph-explorer-real-frame'}); + explorer.analysisGraph.encode(encoder, {parameters: undefined}); + explorer.frameGraph.encode(encoder, { + parameters: {width: bindings.frameWidth, height: bindings.frameHeight}, + frameTextures: { + [bindings.frameColorId]: {texture: color, frameId: 0}, + [bindings.frameDepthId]: {texture: depth, frameId: 0} + } + }); + explorer.pickingGraph.encode(encoder, { + parameters: {pixel: [0, 0]}, + buffers: {[bindings.pickingReadbackId]: pickingReadback} + }); + device.submit(encoder.finish()); + + const [distances, mask, pin, bytes] = await Promise.all([ + readUint32Vector(explorer.search.distances), + readUint32Vector(explorer.search.mask!), + readUint32Vector(explorer.layout.pinned!), + pickingReadback.readAsync(0, 8) + ]); + const pick = decodeGPUIndexPickInfo(bytes); + + tapeTest.equal(distances[0], 0, 'selected root is highlighted at GPU hop distance zero'); + tapeTest.ok( + distances.some(distance => distance === 1 || distance === 2), + 'GPU traversal publishes a bounded multi-hop neighborhood' + ); + tapeTest.equal(mask[0], 1, 'node shader receives the source-aligned GPU selection mask'); + tapeTest.equal( + mask[explorer.graph.vertexCount - 1], + 0, + 'disconnected isolated nodes remain outside the highlighted component' + ); + tapeTest.equal(pin[0], 1, 'dragged node remains pinned through force integration'); + tapeTest.equal( + pick.objectIndex, + 0, + 'integer GPU picking recovers the original stable vertex ID' + ); + } finally { + pickingReadback?.destroy(); + depth?.destroy(); + color?.destroy(); + explorer?.onFinalize(); + } + + tapeTest.end(); +}); + +function executeAnalysis(device: Device, explorer: LuGraphExplorerAnimationLoopTemplate): void { + const encoder = device.createCommandEncoder({id: 'lugraph-explorer-analysis-test'}); + explorer.analysisGraph.encode(encoder, {parameters: undefined}); + device.submit(encoder.finish()); +} + +async function readUint32Vector(vector: GPUVector<'uint32'>): Promise { + if (vector.length === 0) return []; + 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 { + if (vector.length === 0) return []; + 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)); +} diff --git a/website/content/examples/experimental/lugraph-explorer.mdx b/website/content/examples/experimental/lugraph-explorer.mdx new file mode 100644 index 0000000000..6a7ba0a48b --- /dev/null +++ b/website/content/examples/experimental/lugraph-explorer.mdx @@ -0,0 +1,32 @@ +--- +title: luGraph Interactive Graph Explorer +sidebar_label: luGraph explorer +description: Explore GPU-native graph topology, PageRank, weak components, neighborhood selection, picking, and progressive force layout. +sidebar_custom_props: + backends: [webgpu] + difficulty: advanced + maturity: experimental + topics: [compute, visualization, command-graphs, picking, graph-analytics] +--- + +import {LuGraphExplorerExample} from '@site/src/examples'; + + + +Click a node to select its stable source vertex identifier and highlight its bounded neighborhood. +Node size reflects GPU-computed PageRank; color reflects its weakly connected component. Drag the +selected node, pin or unpin it, adjust traversal depth, and reset the progressive layout to its +deterministic initial coordinates. + +Source edge batches remain separate throughout adjacency construction and rendering. GPU-built +forward and reverse CSR feeds shortest-path selection, degree, connected components, normalized +PageRank, and exact force simulation. Node and edge models consume those original GPU buffers +directly: there is no per-frame CPU position readback, implicit source concatenation, or copied +render attribute. + +Picking renders stable vertex identifiers into an integer attachment. Only an explicitly requested +single-pixel selection is asynchronously copied back through a small caller-owned readback ring; +ordinary analytics, simulation, and drawing remain GPU-resident. + +See the [luGraph API guide](/docs/api-reference/experimental/lugraph) for ownership, overflow, +selection, convergence, and force-layout contracts. diff --git a/website/content/examples/table-of-contents.json b/website/content/examples/table-of-contents.json index 81bc30ce98..3c3fb783d8 100644 --- a/website/content/examples/table-of-contents.json +++ b/website/content/examples/table-of-contents.json @@ -85,6 +85,7 @@ "v10/gpgpu", "experimental/gpu-frustum-culling", "experimental/gpu-trace-viewer", + "experimental/lugraph-explorer", "experimental/gpu-trace-scene", "experimental/gpu-scene-graph", "experimental/gpu-sort", diff --git a/website/src/example-thumbnails.ts b/website/src/example-thumbnails.ts index 57d823a1f1..fd341e5978 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', + 'experimental/lugraph-explorer': 'showcase/packet-spraying', 'experimental/gpu-trace-scene': 'experimental/gpu-trace-viewer', 'experimental/gpu-scene-graph': 'experimental/gpu-frustum-culling', 'showcase/gaussian-splat-viewer': 'showcase/gaussian-splats' diff --git a/website/src/examples.tsx b/website/src/examples.tsx index d2e73f114a..ca4828c1a7 100644 --- a/website/src/examples.tsx +++ b/website/src/examples.tsx @@ -35,6 +35,7 @@ import GPUFrustumCullingApp from '../../examples/experimental/gpu-frustum-cullin import GPUSceneGraphApp from '../../examples/experimental/gpu-scene-graph/app'; import GPUTraceSceneApp from '../../examples/experimental/gpu-trace-scene/app'; import GPUTraceViewerApp from '../../examples/experimental/gpu-trace-viewer/app'; +import LuGraphExplorerApp from '../../examples/experimental/lugraph-explorer/app'; import { initializeGPUSortExample, type GPUSortExampleHandle @@ -1658,6 +1659,19 @@ export const GPUTraceViewerExample: React.FC = props => ( /> ); +export const LuGraphExplorerExample: React.FC = props => ( + +); + export const GPUTraceSceneExample: React.FC = props => ( Date: Tue, 4 Aug 2026 22:12:58 -0400 Subject: [PATCH 2/3] test(examples): pick centered graph node across shared canvas sizes --- .../test/lugraph/lu-graph-explorer.spec.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/modules/experimental/test/lugraph/lu-graph-explorer.spec.ts b/modules/experimental/test/lugraph/lu-graph-explorer.spec.ts index 83204f4a8d..e16ab26e6a 100644 --- a/modules/experimental/test/lugraph/lu-graph-explorer.spec.ts +++ b/modules/experimental/test/lugraph/lu-graph-explorer.spec.ts @@ -140,9 +140,17 @@ test('luGraph explorer renders original GPU chunks, highlights neighborhoods, pi let color: Texture | undefined; let depth: Texture | undefined; let pickingReadback: Buffer | undefined; + const devicePixelSizeSpy = vi + .spyOn(device.getDefaultCanvasContext(), 'getDevicePixelSize') + .mockReturnValue([320, 240]); try { explorer = new LuGraphExplorerAnimationLoopTemplate({device} as AnimationProps); const bindings = explorer as unknown as ExplorerGraphBindings; + tapeTest.deepEqual( + [bindings.frameWidth, bindings.frameHeight], + [320, 240], + 'GPU picking uses real centered device pixels rather than assuming a one-pixel test canvas' + ); color = device.createTexture({ id: 'lugraph-explorer-test-color', format: device.preferredColorFormat, @@ -163,7 +171,7 @@ test('luGraph explorer renders original GPU chunks, highlights neighborhoods, pi usage: Buffer.COPY_DST | Buffer.MAP_READ }); - // Preserve one centered instance so its true rendered circle deterministically covers pixel 0. + // Preserve one centered instance so its true rendered circle covers the current canvas center. (explorer.layout.positions.data[0].buffer as Buffer).write(Float32Array.from([0, 0])); (explorer.layout.pinned!.data[0].buffer as Buffer).write(Uint32Array.from([1])); @@ -177,7 +185,9 @@ test('luGraph explorer renders original GPU chunks, highlights neighborhoods, pi } }); explorer.pickingGraph.encode(encoder, { - parameters: {pixel: [0, 0]}, + parameters: { + pixel: [Math.floor(bindings.frameWidth / 2), Math.floor(bindings.frameHeight / 2)] + }, buffers: {[bindings.pickingReadbackId]: pickingReadback} }); device.submit(encoder.finish()); @@ -208,6 +218,7 @@ test('luGraph explorer renders original GPU chunks, highlights neighborhoods, pi 'integer GPU picking recovers the original stable vertex ID' ); } finally { + devicePixelSizeSpy.mockRestore(); pickingReadback?.destroy(); depth?.destroy(); color?.destroy(); From c18b0d4bc5b5d460a7768901edea472f1aeecae6 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 4 Aug 2026 22:30:22 -0400 Subject: [PATCH 3/3] fix(examples): bind graph dragging to resolved pointer-session picks --- docs/api-reference/experimental/README.md | 15 +- docs/api-reference/experimental/lugraph.md | 47 +++ examples/experimental/lugraph-explorer/app.ts | 277 ++++++++++++++-- .../lugraph-explorer/graph-data.ts | 2 +- .../lugraph-explorer/graph-shaders.ts | 66 +++- .../gpu-visibility-workflow.node.spec.ts | 2 +- .../lugraph/lu-graph-explorer.node.spec.ts | 36 ++- .../test/lugraph/lu-graph-explorer.spec.ts | 301 +++++++++++++++++- .../gpgpu-catalog-navigation.node.spec.ts | 1 + test/examples/lugraph-docs.node.spec.ts | 50 +++ .../experimental/lugraph-explorer.mdx | 73 ++++- 11 files changed, 804 insertions(+), 66 deletions(-) diff --git a/docs/api-reference/experimental/README.md b/docs/api-reference/experimental/README.md index 74f267fd89..985b6dec31 100644 --- a/docs/api-reference/experimental/README.md +++ b/docs/api-reference/experimental/README.md @@ -137,6 +137,8 @@ edge columns into reusable compressed adjacency, vertex degrees, bounded shortes weakly connected components, and dangling-aware PageRank scores. Social networks, dependency graphs, transaction investigations, and infrastructure maps can compose those operations into one WebGPU command graph without copying source batches or reading complete results back to JavaScript. +The [interactive graph explorer](/examples/experimental/lugraph-explorer) adds directly renderable +exact force-layout coordinates, neighborhood highlighting, stable GPU picking, dragging, and pinning. ## GPU-resident Linked Crossfiltering @@ -149,19 +151,6 @@ brushes to linked histograms, grouped aggregates, stable visible-row identifiers masks through one reusable WebGPU command graph. Source rows stay on the GPU; applications control chart rendering, command submission, and any compact summary readback. -## GPU-resident Graph Analytics - -

- WebGPU required -

- -[`@luma.gl/experimental/lugraph`](/docs/api-reference/experimental/lugraph) builds bounded graph -adjacency, searches multi-seed neighborhoods, publishes exact vertex degrees and weak components, -computes normalized PageRank, and progressively updates render-ready force-layout coordinates. -Caller-owned source chunks, selection masks, scores, component identifiers, and position buffers -remain GPU-resident. The [interactive graph explorer](/examples/experimental/lugraph-explorer) -combines those outputs with stable GPU picking, neighborhood highlighting, dragging, and pinning. - ## WebGPU Geospatial Kernels

diff --git a/docs/api-reference/experimental/lugraph.md b/docs/api-reference/experimental/lugraph.md index 3a3e5c00f3..81ae996203 100644 --- a/docs/api-reference/experimental/lugraph.md +++ b/docs/api-reference/experimental/lugraph.md @@ -1,4 +1,5 @@ import {ExperimentalDocsTabs} from '@site/src/components/docs/experimental-docs-tabs'; +import {LuGraphExplorerExample} from '@site/src/examples'; # luGraph: GPU-Resident Graph Analytics @@ -21,6 +22,52 @@ This is an experimental, headless graph analytics API, not a graph database, vis framework, file importer, or general-purpose dataframe. Applications decide how data reaches the GPU, which results they render, when commands are submitted, and whether anything is read back. +## Explore a live GPU graph + +**What do graph relationships, vertex influence, connected groups, and neighborhood searches look +like when they feed a real interactive application?** + +The [interactive luGraph explorer](/examples/experimental/lugraph-explorer) answers that question +with a deterministic 128-vertex network. Four intentionally generated source groups contain +important hubs, a bridge between the first two groups, and one completely isolated vertex. This +small, deliberately interpretable network makes it possible to see how adjacency, degree, +PageRank, weak components, bounded shortest paths, and exact force-directed layout work together. + + + +The graph inspector opens automatically and lets you compare four real GPU-backed color modes: + +- **Weak components** identify entities that can reach each other when edge direction is ignored. + The two source groups joined by a bridge have the same color; disconnected groups and the + isolated vertex remain separate. These are weak components, not community-detection output. +- **Vertex degree** exposes direct relationship counts and identifies immediately connected hubs. +- **PageRank importance** identifies influence received from other important vertices, which can + differ substantially from raw relationship count. +- **Neighborhood distance** shows how many bounded, unweighted hops separate each reachable vertex + from the current selection. + +Node size can independently reflect normalized PageRank, vertex degree, or a uniform radius. Click +a node to inspect its stable source identifier and highlighted neighborhood; adjust neighborhood +depth to follow more unweighted hops. Toggle the original edge batches, pause or resume the exact +layout, drag a node to pin it, release pins, or reset deterministic initial positions. Hold Shift +while dragging to pan and scroll to zoom. An accessible legend and live status explain the current +graph; adapter, frame-rate, and GPU-allocation details report actual available runtime information, +not invented GPU execution times. + +The example builds forward and reverse compressed adjacency, vertex degree, weak components, and +normalized PageRank on the GPU. Each frame updates bounded breadth-first selection and progresses +the exact force layout. The same caller-owned position buffer is simultaneously writable storage +and a render vertex attribute. Node and picking shaders consume the actual PageRank and degree +buffers, while edge models draw their original aligned source batches without concatenating the +intentionally empty middle batch. Analytics, simulation, and ordinary rendering do not read graph +data back to JavaScript; explicitly requested integer picking reads only one compact **8-byte** +selected-vertex result. + +Use this demonstration to understand how GPU-resident graph outputs can directly support a social +network, dependency map, fraud investigation, or other relationship visualization. It is a +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. + ## Why keep a graph on the GPU? A CPU application can certainly traverse a graph. The problem appears when its relationship data diff --git a/examples/experimental/lugraph-explorer/app.ts b/examples/experimental/lugraph-explorer/app.ts index 1881c44b26..195dd38c0f 100644 --- a/examples/experimental/lugraph-explorer/app.ts +++ b/examples/experimental/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 {Buffer, Texture, type Device} from '@luma.gl/core'; import {AnimationLoopTemplate, Model, type AnimationProps} from '@luma.gl/engine'; @@ -48,6 +48,20 @@ const INVALID_VERTEX = 0xffffffff; const UINT32_BYTE_LENGTH = Uint32Array.BYTES_PER_ELEMENT; type ScalarVectorFormat = 'uint32' | 'float32'; +type GraphExplorerColorMode = 'component' | 'degree' | 'pagerank' | 'distance'; +type GraphExplorerNodeSizeMode = 'pagerank' | 'degree' | 'uniform'; + +const GRAPH_EXPLORER_COLOR_MODES: GraphExplorerColorMode[] = [ + 'component', + 'degree', + 'pagerank', + 'distance' +]; +const GRAPH_EXPLORER_NODE_SIZE_MODES: GraphExplorerNodeSizeMode[] = [ + 'pagerank', + 'degree', + 'uniform' +]; type FrameParameters = { width: number; @@ -104,14 +118,32 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT private analyticsPending = true; private selectedVertex: number | null = 0; private pendingPick: readonly [number, number] | null = null; + private pendingPickSession: number | null = null; + private pointerSession = 0; + private activePointerId: number | null = null; + private dragPickResolved = false; + private dragVertex: number | null = null; + private finalized = false; private neighborhoodDepth = INITIAL_NEIGHBORHOOD_DEPTH; private centerX = 0; private centerY = 0; private zoom = 0.55; private dragging = false; + private paused = false; + private edgesVisible = true; + private colorMode: GraphExplorerColorMode = 'component'; + private nodeSizeMode: GraphExplorerNodeSizeMode = 'pagerank'; + private sampledFrameCount = 0; + private sampledFrameTime = 0; + private framesPerSecond = 0; + private cpuEncodeTimeMilliseconds = 0; private lastPointer: [number, number] | null = null; private canvas: HTMLCanvasElement | null = null; private statusElement: HTMLElement | null = null; + private legendElement: HTMLElement | null = null; + private adapterElement: HTMLElement | null = null; + private memoryElement: HTMLElement | null = null; + private frameRateElement: HTMLElement | null = null; constructor({device}: AnimationProps) { super(); @@ -234,6 +266,15 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT }) }); this.panels.mount(); + if (typeof document !== 'undefined') { + document + .getElementById('example-panel-host') + ?.closest('[data-info-box-appearance]') + ?.querySelector( + 'button[aria-expanded="false"][aria-label="Expand info box"]' + ) + ?.click(); + } this.writeViewUniforms(width, height); } @@ -267,7 +308,7 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT const framebuffer = device .getDefaultCanvasContext() .getCurrentFramebuffer({depthStencilFormat: 'depth24plus'}); - this.frameGraph.encode(device.commandEncoder, { + const frameEncoding = this.frameGraph.encode(device.commandEncoder, { parameters: {width, height}, frameTextures: { [this.frameColorId]: { @@ -280,24 +321,35 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT } } }); + this.cpuEncodeTimeMilliseconds = frameEncoding.stats.cpuEncodeTimeMilliseconds; + this.updateFrameRate(); if (this.pendingPick) { const ticket = this.readbackRing.tryAcquire(); if (ticket) { const pixel = this.pendingPick; + const pointerSession = this.pendingPickSession; this.pendingPick = null; + this.pendingPickSession = null; this.pickingGraph.encode(device.commandEncoder, { parameters: {pixel}, buffers: {[this.pickingReadbackId]: ticket.buffer} }); ticket.markEncoded({byteLength: 8}); - queueMicrotask(() => void this.readPickedVertex(ticket)); + queueMicrotask(() => void this.readPickedVertex(ticket, pointerSession ?? undefined)); } } this.frameIndex++; } override onFinalize(): void { + this.finalized = true; + this.pointerSession++; + this.activePointerId = null; + this.dragPickResolved = false; + this.dragVertex = null; + this.pendingPick = null; + this.pendingPickSession = null; if (this.canvas) { this.canvas.removeEventListener('pointerdown', this.handlePointerDown); this.canvas.removeEventListener('pointermove', this.handlePointerMove); @@ -338,11 +390,12 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT const graph = new GPUCommandGraph(this.device, { id: 'lugraph-explorer-frame' }); - this.layout.addToGraph(graph); + if (!this.paused) this.layout.addToGraph(graph); this.search.addToGraph(graph); const positions = graph.importGPUVector('render-positions', this.layout.positions).data[0]; const importance = graph.importGPUVector('render-importance', this.pageRank.output).data[0]; + const degrees = graph.importGPUVector('render-degrees', this.degree.output).data[0]; const componentLabels = graph.importGPUVector('render-components', this.components.output) .data[0]; const distances = graph.importGPUVector('render-distances', this.search.distances).data[0]; @@ -377,6 +430,7 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT const resources: GraphBufferUse[] = [ {buffer: positions, usage: 'storage-read'}, {buffer: importance, usage: 'storage-read'}, + {buffer: degrees, usage: 'storage-read'}, {buffer: componentLabels, usage: 'storage-read'}, {buffer: distances, usage: 'storage-read'}, {buffer: mask, usage: 'storage-read'}, @@ -403,7 +457,9 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT clearStencil: false }), encode: ({renderPass}) => { - for (const {model} of this.edgeModels) model.draw(renderPass); + if (this.edgesVisible) { + for (const {model} of this.edgeModels) model.draw(renderPass); + } this.nodeModel.draw(renderPass); } }) @@ -421,6 +477,7 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT }); const positions = graph.importGPUVector('picking-positions', this.layout.positions).data[0]; const importance = graph.importGPUVector('picking-importance', this.pageRank.output).data[0]; + const degrees = graph.importGPUVector('picking-degrees', this.degree.output).data[0]; const view = graph.importBuffer( { id: 'picking-view', @@ -441,6 +498,7 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT resources: [ {buffer: positions, usage: 'vertex'}, {buffer: importance, usage: 'storage-read'}, + {buffer: degrees, usage: 'storage-read'}, {buffer: view, usage: 'uniform'} ], compile: () => ({ @@ -473,6 +531,7 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT components: this.getVectorBuffer(this.components.output), distances: this.getVectorBuffer(this.search.distances), selectionMask: this.getVectorBuffer(this.neighborhoodMask), + degrees: this.getVectorBuffer(this.degree.output), view: this.viewUniforms }, shaderLayout: { @@ -482,7 +541,8 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT {name: 'components', type: 'read-only-storage', group: 0, location: 1}, {name: 'distances', type: 'read-only-storage', group: 0, location: 2}, {name: 'selectionMask', type: 'read-only-storage', group: 0, location: 3}, - {name: 'view', type: 'uniform', group: 0, location: 4} + {name: 'degrees', type: 'read-only-storage', group: 0, location: 4}, + {name: 'view', type: 'uniform', group: 0, location: 5} ] }, parameters: {depthCompare: 'less-equal', depthWriteEnabled: true} @@ -545,13 +605,15 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT bufferLayout: [{name: 'nodePosition', format: 'float32x2', stepMode: 'instance'}], bindings: { importance: this.getVectorBuffer(this.pageRank.output), + degrees: this.getVectorBuffer(this.degree.output), view: this.viewUniforms }, shaderLayout: { attributes: [{name: 'nodePosition', location: 0, type: 'vec2'}], bindings: [ {name: 'importance', type: 'read-only-storage', group: 0, location: 0}, - {name: 'view', type: 'uniform', group: 0, location: 1} + {name: 'degrees', type: 'read-only-storage', group: 0, location: 1}, + {name: 'view', type: 'uniform', group: 0, location: 2} ] }, parameters: {depthCompare: 'less-equal', depthWriteEnabled: true} @@ -669,15 +731,25 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT this.selectedVertex ?? INVALID_VERTEX, this.neighborhoodDepth, this.graph.vertexCount, - this.dragging ? 1 : 0 + (this.dragging ? 1 : 0) | + (GRAPH_EXPLORER_COLOR_MODES.indexOf(this.colorMode) << 4) | + (GRAPH_EXPLORER_NODE_SIZE_MODES.indexOf(this.nodeSizeMode) << 8) ]); this.viewUniforms.write(new Uint8Array(values)); } - private async readPickedVertex(ticket: GPUReadbackTicket): Promise { + private async readPickedVertex( + ticket: GPUReadbackTicket, + pointerSession: number = this.pointerSession + ): Promise { try { const pickedVertex = decodeGPUIndexPickInfo(await ticket.read()).objectIndex; + if (this.finalized || pointerSession !== this.pointerSession) return; this.selectedVertex = pickedVertex; + if (this.dragging && this.activePointerId !== null) { + this.dragPickResolved = true; + this.dragVertex = pickedVertex; + } if (pickedVertex === null) { this.getVectorBuffer(this.seedCount).write(Uint32Array.of(0)); } else { @@ -691,7 +763,11 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT } private readonly handlePointerDown = (event: PointerEvent): void => { - if (!this.canvas) return; + if (!this.canvas || this.finalized) return; + this.pointerSession++; + this.activePointerId = event.pointerId; + this.dragPickResolved = false; + this.dragVertex = null; this.dragging = true; this.lastPointer = [event.clientX, event.clientY]; this.canvas.style.cursor = 'grabbing'; @@ -703,13 +779,22 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT Math.max(0, Math.min(this.frameWidth - 1, devicePixels.x)), Math.max(0, Math.min(this.frameHeight - 1, devicePixels.y)) ]; + this.pendingPickSession = this.pointerSession; }; private readonly handlePointerMove = (event: PointerEvent): void => { - if (!this.dragging || !this.canvas || !this.lastPointer) return; + if ( + !this.dragging || + !this.canvas || + !this.lastPointer || + event.pointerId !== this.activePointerId + ) { + return; + } const previous = this.lastPointer; this.lastPointer = [event.clientX, event.clientY]; - if (this.selectedVertex === null || event.shiftKey) { + if (!this.dragPickResolved && !event.shiftKey) return; + if (this.dragVertex === null || event.shiftKey) { const rectangle = this.canvas.getBoundingClientRect(); this.centerX -= (2 * (event.clientX - previous[0])) / (rectangle.height * this.zoom); this.centerY += (2 * (event.clientY - previous[1])) / (rectangle.height * this.zoom); @@ -726,24 +811,28 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT ]; this.getVectorBuffer(this.pinned).write( Uint32Array.of(1), - this.selectedVertex * UINT32_BYTE_LENGTH + this.dragVertex * UINT32_BYTE_LENGTH ); this.getVectorBuffer(this.layout.positions).write( Float32Array.from(position), - this.selectedVertex * 2 * Float32Array.BYTES_PER_ELEMENT + this.dragVertex * 2 * Float32Array.BYTES_PER_ELEMENT ); this.getVectorBuffer(this.layout.velocities).write( Float32Array.of(0, 0), - this.selectedVertex * 2 * Float32Array.BYTES_PER_ELEMENT + this.dragVertex * 2 * Float32Array.BYTES_PER_ELEMENT ); this.updateStatus(); }; private readonly handlePointerUp = (event: PointerEvent): void => { + if (event.pointerId !== this.activePointerId) return; if (this.canvas?.hasPointerCapture(event.pointerId)) { this.canvas.releasePointerCapture(event.pointerId); } this.dragging = false; + this.activePointerId = null; + this.dragPickResolved = false; + this.dragVertex = null; this.lastPointer = null; if (this.canvas) this.canvas.style.cursor = 'grab'; }; @@ -755,49 +844,141 @@ export default class LuGraphExplorerAnimationLoopTemplate extends AnimationLoopT }; private getControlsHtml(): string { - return `

-

GPU topology, PageRank, components, bounded breadth-first selection, - and progressive force integration feed these node and edge draws without per-frame readback.

-