diff --git a/docs/api-reference/experimental/README.md b/docs/api-reference/experimental/README.md index b231a073f1..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 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 new file mode 100644 index 0000000000..195dd38c0f --- /dev/null +++ b/examples/experimental/lugraph-explorer/app.ts @@ -0,0 +1,1030 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// 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'; +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 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; + 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 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(); + 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(); + 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); + } + + 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'}); + const frameEncoding = 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 + } + } + }); + 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, 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); + 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' + }); + 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]; + 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: degrees, 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}) => { + if (this.edgesVisible) { + 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 degrees = graph.importGPUVector('picking-degrees', this.degree.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: degrees, 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), + 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: '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: 'degrees', type: 'read-only-storage', group: 0, location: 4}, + {name: 'view', type: 'uniform', group: 0, location: 5} + ] + }, + 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), + 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: 'degrees', type: 'read-only-storage', group: 0, location: 1}, + {name: 'view', type: 'uniform', group: 0, location: 2} + ] + }, + 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) | + (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, + 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 { + 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 || 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'; + 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)) + ]; + this.pendingPickSession = this.pointerSession; + }; + + private readonly handlePointerMove = (event: PointerEvent): void => { + if ( + !this.dragging || + !this.canvas || + !this.lastPointer || + event.pointerId !== this.activePointerId + ) { + return; + } + const previous = this.lastPointer; + this.lastPointer = [event.clientX, event.clientY]; + 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); + 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.dragVertex * UINT32_BYTE_LENGTH + ); + this.getVectorBuffer(this.layout.positions).write( + Float32Array.from(position), + this.dragVertex * 2 * Float32Array.BYTES_PER_ELEMENT + ); + this.getVectorBuffer(this.layout.velocities).write( + Float32Array.of(0, 0), + 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'; + }; + + 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 { + const selectStyle = + 'width:100%;padding:7px 9px;border:1px solid rgba(148,163,184,.26);' + + 'border-radius:8px;background:rgba(15,23,42,.8);color:inherit'; + const buttonStyle = + 'padding:7px 10px;border:1px solid rgba(148,163,184,.28);border-radius:8px;' + + 'background:rgba(30,41,59,.8);color:inherit;cursor:pointer'; + return `
+
+ Graph analytics + WEBGPU · LIVE +
+

Real GPU topology, influence, + connected components, and neighborhood traversal.

+
+ + +
+ +
+ + + + +
+
+

+

+

+

+

Click to select · drag to pin · + Shift-drag to pan · scroll to zoom

+
`; + } + + private attachControls(root: HTMLElement): () => void { + const depth = root.querySelector('[data-depth]'); + const color = root.querySelector('[data-color-mode]'); + const size = root.querySelector('[data-node-size]'); + const pause = root.querySelector('[data-pause]'); + const edges = root.querySelector('[data-edge-toggle]'); + const reset = root.querySelector('[data-reset]'); + const unpin = root.querySelector('[data-unpin]'); + this.statusElement = root.querySelector('[data-status]'); + this.legendElement = root.querySelector('[data-graph-legend]'); + this.adapterElement = root.querySelector('[data-graph-adapter]'); + this.memoryElement = root.querySelector('[data-graph-memory]'); + this.frameRateElement = root.querySelector('[data-graph-fps]'); + const updateColor = () => { + const selectedMode = GRAPH_EXPLORER_COLOR_MODES.find(mode => mode === color?.value); + if (selectedMode) this.colorMode = selectedMode; + this.updateStatus(); + }; + const updateSize = () => { + const selectedMode = GRAPH_EXPLORER_NODE_SIZE_MODES.find(mode => mode === size?.value); + if (selectedMode) this.nodeSizeMode = selectedMode; + this.updateStatus(); + }; + const updateDepth = () => { + this.neighborhoodDepth = Number(depth?.value ?? INITIAL_NEIGHBORHOOD_DEPTH); + this.getVectorBuffer(this.activeDepth).write(Uint32Array.of(this.neighborhoodDepth)); + this.updateStatus(); + }; + const togglePause = () => { + this.paused = !this.paused; + if (pause) { + pause.textContent = this.paused ? 'Resume layout' : 'Pause layout'; + pause.setAttribute('aria-pressed', String(this.paused)); + } + this.frameGraph.destroy(); + this.frameGraph = this.createFrameGraph(this.frameWidth, this.frameHeight); + this.updateStatus(); + }; + const toggleEdges = () => { + this.edgesVisible = !this.edgesVisible; + if (edges) { + edges.textContent = this.edgesVisible ? 'Hide edges' : 'Show edges'; + edges.setAttribute('aria-pressed', String(this.edgesVisible)); + } + this.updateStatus(); + }; + const resetLayout = () => { + this.getVectorBuffer(this.reset).write(Uint32Array.of(1)); + this.updateStatus(); + }; + const clearPins = () => { + this.getVectorBuffer(this.pinned).write(new Uint32Array(this.graph.vertexCount)); + this.updateStatus(); + }; + depth?.addEventListener('input', updateDepth); + color?.addEventListener('change', updateColor); + size?.addEventListener('change', updateSize); + pause?.addEventListener('click', togglePause); + edges?.addEventListener('click', toggleEdges); + reset?.addEventListener('click', resetLayout); + unpin?.addEventListener('click', clearPins); + this.updateStatus(); + return () => { + depth?.removeEventListener('input', updateDepth); + color?.removeEventListener('change', updateColor); + size?.removeEventListener('change', updateSize); + pause?.removeEventListener('click', togglePause); + edges?.removeEventListener('click', toggleEdges); + reset?.removeEventListener('click', resetLayout); + unpin?.removeEventListener('click', clearPins); + this.statusElement = null; + this.legendElement = null; + this.adapterElement = null; + this.memoryElement = null; + this.frameRateElement = 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}`; + if (this.legendElement) { + const legends: Record = { + component: '● Colors identify GPU weakly connected components', + degree: '● Blue → amber shows GPU-computed vertex degree', + pagerank: '● Teal → violet shows GPU PageRank influence', + distance: '● Bright → dim shows bounded GPU traversal distance' + }; + this.legendElement.textContent = legends[this.colorMode]; + } + if (this.adapterElement) { + const adapter = this.device.info.renderer || this.device.info.vendor || this.device.info.gpu; + this.adapterElement.textContent = `GPU adapter: ${adapter}`; + } + if (this.memoryElement) { + const residentBytes = this.buffers.reduce((total, buffer) => total + buffer.byteLength, 0); + const transientBytes = + this.analysisGraph.stats.physicalTransientBytes + + this.frameGraph.stats.physicalTransientBytes + + this.pickingGraph.stats.physicalTransientBytes; + this.memoryElement.textContent = + `GPU buffers: ${(residentBytes / 1024).toFixed(1)} KiB resident · ` + + `${(transientBytes / 1024).toFixed(1)} KiB transient`; + } + if (this.frameRateElement) { + this.frameRateElement.textContent = + `${this.framesPerSecond.toFixed(0)} FPS · ` + + `${this.cpuEncodeTimeMilliseconds.toFixed(2)} ms CPU command encoding`; + } + } + + private updateFrameRate(): void { + const currentTime = performance.now(); + if (this.sampledFrameTime === 0) this.sampledFrameTime = currentTime; + this.sampledFrameCount++; + const elapsedMilliseconds = currentTime - this.sampledFrameTime; + if (elapsedMilliseconds < 500) return; + this.framesPerSecond = (this.sampledFrameCount * 1000) / elapsedMilliseconds; + this.sampledFrameTime = currentTime; + this.sampledFrameCount = 0; + this.updateStatus(); + } +} diff --git a/examples/experimental/lugraph-explorer/graph-data.ts b/examples/experimental/lugraph-explorer/graph-data.ts new file mode 100644 index 0000000000..d6d0b37f30 --- /dev/null +++ b/examples/experimental/lugraph-explorer/graph-data.ts @@ -0,0 +1,103 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 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..a30788204a --- /dev/null +++ b/examples/experimental/lugraph-explorer/graph-shaders.ts @@ -0,0 +1,236 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 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, packed display modes. +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, degrees=4, view=5. + */ +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 degrees: array; +@group(0) @binding(5) 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 pageRankRadius = clamp( + 0.012 + sqrt(max(importance[sourceIndex], 0.0)) * 0.085, + 0.012, + 0.050 + ); + let degreeRadius = clamp(0.011 + sqrt(f32(degrees[sourceIndex])) * 0.008, 0.012, 0.050); + let sizeMode = (view.interaction.w >> 8u) & 3u; + let metricRadius = select(pageRankRadius, degreeRadius, sizeMode == 1u); + let radius = select(metricRadius, 0.018, sizeMode == 2u); + 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; + let colorMode = (view.interaction.w >> 4u) & 3u; + let degreeIntensity = clamp(log2(f32(degrees[sourceIndex]) + 1.0) / 4.0, 0.0, 1.0); + let degreeColor = mix(vec3(0.22, 0.48, 1.0), vec3(1.0, 0.71, 0.31), degreeIntensity); + let importanceIntensity = clamp( + sqrt(max(importance[sourceIndex], 0.0) * f32(view.interaction.z)) * 0.55, + 0.0, + 1.0 + ); + let importanceColor = mix( + vec3(0.19, 0.86, 0.76), + vec3(0.79, 0.45, 1.0), + importanceIntensity + ); + let distance = distances[sourceIndex]; + let distanceIntensity = clamp( + f32(distance) / f32(max(view.interaction.y, 1u)), + 0.0, + 1.0 + ); + let reachableDistanceColor = mix( + vec3(0.39, 0.95, 1.0), + vec3(0.71, 0.43, 0.89), + distanceIntensity + ); + let distanceColor = select( + vec3(0.24, 0.29, 0.40), + reachableDistanceColor, + distance != ${GRAPH_EXPLORER_INVALID_VERTEX}u + ); + var color = palette[components[sourceIndex] % 6u]; + color = select(color, degreeColor, colorMode == 1u); + color = select(color, importanceColor, colorMode == 2u); + color = select(color, distanceColor, colorMode == 3u); + 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, degrees=1, view=2. 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 degrees: array; +@group(0) @binding(2) 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 pageRankRadius = clamp( + 0.012 + sqrt(max(importance[sourceIndex], 0.0)) * 0.085, + 0.012, + 0.050 + ); + let degreeRadius = clamp(0.011 + sqrt(f32(degrees[sourceIndex])) * 0.008, 0.012, 0.050); + let sizeMode = (view.interaction.w >> 8u) & 3u; + let metricRadius = select(pageRankRadius, degreeRadius, sizeMode == 1u); + let radius = select(metricRadius, 0.018, sizeMode == 2u); + 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/gpu-primitives/gpu-visibility-workflow.node.spec.ts b/modules/experimental/test/gpu-primitives/gpu-visibility-workflow.node.spec.ts index 269bada0ed..edc937bed7 100644 --- a/modules/experimental/test/gpu-primitives/gpu-visibility-workflow.node.spec.ts +++ b/modules/experimental/test/gpu-primitives/gpu-visibility-workflow.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 {Buffer} from '@luma.gl/core'; import {GPUCommandGraph, GPUCompaction, GPUVisibilityWorkflow} from '@luma.gl/experimental'; 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..7a0af069aa --- /dev/null +++ b/modules/experimental/test/lugraph/lu-graph-explorer.node.spec.ts @@ -0,0 +1,194 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 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('exposes genuine existing GPU analytics without inventing community or spatial contributors', () => { + const explorerSource = readFileSync( + new URL('../../../../examples/experimental/lugraph-explorer/app.ts', import.meta.url), + 'utf8' + ); + + expect(GRAPH_EXPLORER_NODE_SHADER).toMatch(/@binding\(4\).*degrees/u); + expect(GRAPH_EXPLORER_PICKING_SHADER).toMatch(/@binding\(1\).*degrees/u); + expect(GRAPH_EXPLORER_NODE_SHADER).toContain('degrees[sourceIndex]'); + expect(GRAPH_EXPLORER_PICKING_SHADER).toContain('degrees[sourceIndex]'); + + for (const selector of [ + 'data-color-mode', + 'data-node-size', + 'data-pause', + 'data-edge-toggle', + 'data-depth', + 'data-reset', + 'data-unpin', + 'data-graph-legend', + 'data-graph-adapter', + 'data-graph-memory', + 'data-graph-fps' + ]) { + expect(explorerSource, selector).toContain(selector); + } + + expect(explorerSource).toContain('aria-live="polite"'); + expect(explorerSource).toContain('Expand info box'); + expect(explorerSource).toContain('[data-info-box-appearance]'); + expect(explorerSource).not.toContain('LuGraphLabelPropagation'); + expect(explorerSource).not.toContain('LuGraphSpatialForceLayout'); + }); + + 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..bc879e52ba --- /dev/null +++ b/modules/experimental/test/lugraph/lu-graph-explorer.spec.ts @@ -0,0 +1,544 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 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; +}; + +type ExplorerPointerBindings = { + readPickedVertex(ticket: {read: () => Promise}): Promise; +}; + +type ExplorerDashboardBindings = { + colorMode: string; + nodeSizeMode: string; + paused: boolean; + edgesVisible: boolean; + viewUniforms: Buffer; + writeViewUniforms(width: number, height: number): void; +}; + +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 unknown 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.nodeModel.bindings['degrees'], + explorer.degree.output.data[0].buffer, + 'node color and sizing consume the actual GPU-computed degree buffer' + ); + tapeTest.equal( + explorer.pickingModel.bindings['degrees'], + explorer.degree.output.data[0].buffer, + 'integer picking uses the same degree-dependent radius as visible nodes' + ); + 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; + const devicePixelSizeSpy = vi + .spyOn(device.getDefaultCanvasContext(), 'getDevicePixelSize') + .mockReturnValue([320, 240]); + try { + explorer = new LuGraphExplorerAnimationLoopTemplate({device} as unknown 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, + 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 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])); + + 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: [Math.floor(bindings.frameWidth / 2), Math.floor(bindings.frameHeight / 2)] + }, + 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 { + devicePixelSizeSpy.mockRestore(); + pickingReadback?.destroy(); + depth?.destroy(); + color?.destroy(); + explorer?.onFinalize(); + } + + tapeTest.end(); +}); + +test('luGraph explorer exposes genuine GPU analytics and only expands its own graph inspector', async tapeTest => { + const device = await getWebGPUTestDevice(); + if (!device) { + tapeTest.comment('WebGPU is not available'); + tapeTest.end(); + return; + } + + const unrelatedInfoBox = document.createElement('section'); + unrelatedInfoBox.setAttribute('data-info-box-appearance', 'cinematic'); + const unrelatedToggle = document.createElement('button'); + unrelatedToggle.setAttribute('aria-expanded', 'false'); + unrelatedToggle.setAttribute('aria-label', 'Expand info box'); + unrelatedInfoBox.append(unrelatedToggle); + + const graphInfoBox = document.createElement('section'); + graphInfoBox.setAttribute('data-info-box-appearance', 'cinematic'); + const graphToggle = document.createElement('button'); + graphToggle.setAttribute('aria-expanded', 'false'); + graphToggle.setAttribute('aria-label', 'Expand info box'); + const host = document.createElement('div'); + host.id = 'example-panel-host'; + graphInfoBox.append(graphToggle, host); + document.body.append(unrelatedInfoBox, graphInfoBox); + + const unrelatedExpansion = vi.fn(); + const graphExpansion = vi.fn(() => graphToggle.setAttribute('aria-expanded', 'true')); + unrelatedToggle.addEventListener('click', unrelatedExpansion); + graphToggle.addEventListener('click', graphExpansion); + + let explorer: LuGraphExplorerAnimationLoopTemplate | undefined; + try { + explorer = new LuGraphExplorerAnimationLoopTemplate({device} as unknown as AnimationProps); + const dashboard = explorer as unknown as ExplorerDashboardBindings; + tapeTest.equal(graphExpansion.mock.calls.length, 1, 'the graph inspector opens exactly once'); + tapeTest.equal( + unrelatedExpansion.mock.calls.length, + 0, + 'unrelated collapsed example inspectors are never opened' + ); + + const color = host.querySelector('[data-color-mode]'); + const size = host.querySelector('[data-node-size]'); + const pause = host.querySelector('[data-pause]'); + const edges = host.querySelector('[data-edge-toggle]'); + const depth = host.querySelector('[data-depth]'); + tapeTest.deepEqual( + Array.from(color!.options, option => option.value), + ['component', 'degree', 'pagerank', 'distance'], + 'all four available GPU analytics are selectable as node colors' + ); + tapeTest.deepEqual( + Array.from(size!.options, option => option.value), + ['pagerank', 'degree', 'uniform'], + 'importance, degree, and uniform node sizing are independently selectable' + ); + + color!.value = 'degree'; + color!.dispatchEvent(new Event('change', {bubbles: true})); + size!.value = 'degree'; + size!.dispatchEvent(new Event('change', {bubbles: true})); + tapeTest.equal(dashboard.colorMode, 'degree', 'degree coloring updates the real render state'); + tapeTest.equal(dashboard.nodeSizeMode, 'degree', 'degree sizing updates the real render state'); + tapeTest.ok( + host.querySelector('[data-graph-legend]')?.textContent?.includes('vertex degree'), + 'the visible legend describes the active GPU-computed color metric' + ); + + const uniformWrite = vi.spyOn(dashboard.viewUniforms, 'write'); + dashboard.writeViewUniforms(320, 240); + const uniformBytes = uniformWrite.mock.calls[0][0] as Uint8Array; + tapeTest.equal( + new DataView(uniformBytes.buffer, uniformBytes.byteOffset, uniformBytes.byteLength).getUint32( + 28, + true + ), + (1 << 4) | (1 << 8), + 'the shared node and picking uniform packs the selected genuine GPU metric modes' + ); + uniformWrite.mockRestore(); + + depth!.value = '4'; + depth!.dispatchEvent(new Event('input', {bubbles: true})); + tapeTest.equal( + (await readUint32Vector(explorer.search.activeDepth!))[0], + 4, + 'the depth slider writes the existing GPU traversal control' + ); + + const layoutNode = 'lugraph-explorer-layout-initialize'; + tapeTest.ok(explorer.frameGraph.stats.nodeOrder.includes(layoutNode), 'layout starts active'); + pause!.click(); + tapeTest.equal(dashboard.paused, true, 'pausing changes actual graph execution state'); + tapeTest.equal(pause!.getAttribute('aria-pressed'), 'true', 'pause state remains accessible'); + tapeTest.equal( + explorer.frameGraph.stats.nodeOrder.includes(layoutNode), + false, + 'pausing removes actual force-layout compute from the compiled frame graph' + ); + tapeTest.ok( + explorer.frameGraph.stats.nodeOrder.includes('lugraph-explorer-neighborhood-initialize'), + 'selection and neighborhood traversal remain active while layout is paused' + ); + pause!.click(); + tapeTest.ok( + explorer.frameGraph.stats.nodeOrder.includes(layoutNode), + 'resuming restores real force-layout compute' + ); + + edges!.click(); + tapeTest.equal(dashboard.edgesVisible, false, 'edge visibility changes actual rendering state'); + tapeTest.equal(edges!.getAttribute('aria-pressed'), 'false', 'edge state remains accessible'); + tapeTest.ok(host.querySelector('[data-status]')?.textContent?.includes('depth 4')); + tapeTest.ok(host.querySelector('[data-graph-adapter]')?.textContent?.includes('GPU adapter:')); + tapeTest.ok(host.querySelector('[data-graph-memory]')?.textContent?.includes('KiB resident')); + tapeTest.ok( + host.querySelector('[data-graph-fps]')?.textContent?.includes('CPU command encoding'), + 'telemetry identifies CPU encoding honestly instead of inventing GPU execution timings' + ); + tapeTest.equal(graphExpansion.mock.calls.length, 1, 'interaction never reopens the inspector'); + } finally { + explorer?.onFinalize(); + graphInfoBox.remove(); + unrelatedInfoBox.remove(); + } + + tapeTest.end(); +}); + +test('luGraph explorer waits for the current asynchronous GPU pick before dragging a different node', async tapeTest => { + const device = await getWebGPUTestDevice(); + if (!device) { + tapeTest.comment('WebGPU is not available'); + tapeTest.end(); + return; + } + + let explorer: LuGraphExplorerAnimationLoopTemplate | undefined; + const cleanup: Array<() => void> = []; + const canvas = document.createElement('canvas'); + canvas.width = 320; + canvas.height = 240; + let capturedPointerId: number | null = null; + + const devicePixelSizeSpy = vi + .spyOn(device.getDefaultCanvasContext(), 'getDevicePixelSize') + .mockReturnValue([320, 240]); + cleanup.push(() => devicePixelSizeSpy.mockRestore()); + const pixelConversionSpy = vi + .spyOn(device.getDefaultCanvasContext(), 'cssToDevicePixels') + .mockReturnValue({x: 160, y: 120, width: 1, height: 1}); + cleanup.push(() => pixelConversionSpy.mockRestore()); + const boundsSpy = vi + .spyOn(canvas, 'getBoundingClientRect') + .mockReturnValue(new DOMRect(0, 0, 320, 240)); + cleanup.push(() => boundsSpy.mockRestore()); + const captureSpy = vi.spyOn(canvas, 'setPointerCapture').mockImplementation(pointerId => { + capturedPointerId = pointerId; + }); + cleanup.push(() => captureSpy.mockRestore()); + const hasCaptureSpy = vi + .spyOn(canvas, 'hasPointerCapture') + .mockImplementation(pointerId => capturedPointerId === pointerId); + cleanup.push(() => hasCaptureSpy.mockRestore()); + const releaseCaptureSpy = vi.spyOn(canvas, 'releasePointerCapture').mockImplementation(() => { + capturedPointerId = null; + }); + cleanup.push(() => releaseCaptureSpy.mockRestore()); + + try { + explorer = new LuGraphExplorerAnimationLoopTemplate({device} as unknown as AnimationProps); + await explorer.onInitialize({device, canvas} as unknown as AnimationProps); + const pointerBindings = explorer as unknown as ExplorerPointerBindings; + const pinnedBuffer = explorer.layout.pinned!.data[0].buffer as Buffer; + const positionsBuffer = explorer.layout.positions.data[0].buffer as Buffer; + const velocitiesBuffer = explorer.layout.velocities.data[0].buffer as Buffer; + const pinWriteSpy = vi.spyOn(pinnedBuffer, 'write'); + const positionWriteSpy = vi.spyOn(positionsBuffer, 'write'); + const velocityWriteSpy = vi.spyOn(velocitiesBuffer, 'write'); + cleanup.push( + () => pinWriteSpy.mockRestore(), + () => positionWriteSpy.mockRestore(), + () => velocityWriteSpy.mockRestore() + ); + + canvas.dispatchEvent( + new PointerEvent('pointerdown', {pointerId: 11, clientX: 160, clientY: 120}) + ); + + let resolveCurrentPick: ((value: Uint8Array) => void) | undefined; + const currentPick = new Promise(resolve => { + resolveCurrentPick = resolve; + }); + const currentReadback = pointerBindings.readPickedVertex({read: () => currentPick}); + canvas.dispatchEvent( + new PointerEvent('pointermove', {pointerId: 11, clientX: 190, clientY: 135}) + ); + + tapeTest.equal(pinWriteSpy.mock.calls.length, 0, 'the previously selected node is not pinned'); + tapeTest.equal( + positionWriteSpy.mock.calls.length, + 0, + 'the previously selected node coordinates are not changed before GPU picking resolves' + ); + tapeTest.equal( + velocityWriteSpy.mock.calls.length, + 0, + 'the previously selected node velocity is not cleared while picking remains asynchronous' + ); + + resolveCurrentPick!(new Uint8Array(Int32Array.of(7, 0).buffer)); + await currentReadback; + canvas.dispatchEvent( + new PointerEvent('pointermove', {pointerId: 11, clientX: 205, clientY: 145}) + ); + + tapeTest.equal(pinWriteSpy.mock.calls.length, 1, 'the resolved current node is pinned once'); + tapeTest.equal( + pinWriteSpy.mock.calls[0][1], + 7 * Uint32Array.BYTES_PER_ELEMENT, + 'pinning targets the newly picked stable vertex, never the stale selected node' + ); + tapeTest.equal( + positionWriteSpy.mock.calls[0][1], + 7 * 2 * Float32Array.BYTES_PER_ELEMENT, + 'position writes target only the newly picked vertex row' + ); + tapeTest.equal( + velocityWriteSpy.mock.calls[0][1], + 7 * 2 * Float32Array.BYTES_PER_ELEMENT, + 'velocity writes target only the newly picked vertex row' + ); + const pinValues = await readUint32Vector(explorer.layout.pinned!); + tapeTest.equal(pinValues[0], 0, 'the old selected vertex remains unpinned on the actual GPU'); + tapeTest.equal(pinValues[7], 1, 'the resolved drag target is pinned on the actual GPU'); + + canvas.dispatchEvent(new PointerEvent('pointerup', {pointerId: 11})); + canvas.dispatchEvent( + new PointerEvent('pointerdown', {pointerId: 12, clientX: 215, clientY: 155}) + ); + let resolveReleasedPick: ((value: Uint8Array) => void) | undefined; + const releasedPick = new Promise(resolve => { + resolveReleasedPick = resolve; + }); + const releasedReadback = pointerBindings.readPickedVertex({read: () => releasedPick}); + canvas.dispatchEvent(new PointerEvent('pointerup', {pointerId: 12})); + resolveReleasedPick!(new Uint8Array(Int32Array.of(9, 0).buffer)); + await releasedReadback; + canvas.dispatchEvent( + new PointerEvent('pointermove', {pointerId: 12, clientX: 235, clientY: 170}) + ); + + tapeTest.equal( + pinWriteSpy.mock.calls.length, + 1, + 'a GPU pick resolving after pointer release never resurrects a stale drag' + ); + tapeTest.equal(positionWriteSpy.mock.calls.length, 1, 'released pointers never move a node'); + tapeTest.equal( + velocityWriteSpy.mock.calls.length, + 1, + 'released pointers never clear velocities' + ); + } finally { + for (const restore of cleanup.reverse()) restore(); + explorer?.onFinalize(); + canvas.remove(); + } + + 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/test/examples/gpgpu-catalog-navigation.node.spec.ts b/test/examples/gpgpu-catalog-navigation.node.spec.ts index bcd49e3a7c..9358b775ef 100644 --- a/test/examples/gpgpu-catalog-navigation.node.spec.ts +++ b/test/examples/gpgpu-catalog-navigation.node.spec.ts @@ -189,6 +189,7 @@ describe('GPGPU example catalog navigation', () => { '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/test/examples/lugraph-docs.node.spec.ts b/test/examples/lugraph-docs.node.spec.ts index 8256b2b684..578052e515 100644 --- a/test/examples/lugraph-docs.node.spec.ts +++ b/test/examples/lugraph-docs.node.spec.ts @@ -17,6 +17,10 @@ const experimentalOverview = readFileSync( new URL('../../docs/api-reference/experimental/README.md', import.meta.url), 'utf8' ); +const graphExplorerExample = readFileSync( + new URL('../../website/content/examples/experimental/lugraph-explorer.mdx', import.meta.url), + 'utf8' +); const sidebar = readFileSync(new URL('../../docs/table-of-contents.json', import.meta.url), 'utf8'); const experimentalTabs = readFileSync( new URL('../../website/src/components/docs/experimental-docs-tabs.tsx', import.meta.url), @@ -31,10 +35,56 @@ describe('luGraph GPU-resident graph analytics documentation', () => { expect(experimentalTabs).toContain("| 'lugraph'"); expect(experimentalTabs).toContain("href: '/docs/api-reference/experimental/lugraph'"); expect(experimentalOverview).toContain('## GPU-resident Graph Analytics'); + expect(experimentalOverview.match(/^## GPU-resident Graph Analytics$/gmu)).toHaveLength(1); expect(experimentalOverview).toContain('/docs/api-reference/experimental/lugraph'); + expect(experimentalOverview).toContain('/examples/experimental/lugraph-explorer'); expect(packageDocumentation).toContain('/docs/api-reference/experimental/lugraph'); }); + test('embeds an honest 128-vertex GPU explorer and explains its practical interactions', () => { + expect(graphDocumentation).toContain( + "import {LuGraphExplorerExample} from '@site/src/examples';" + ); + expect(graphDocumentation).toContain('## Explore a live GPU graph'); + expect(graphDocumentation).toContain( + '' + ); + + for (const documentation of [graphDocumentation, graphExplorerExample]) { + expect(documentation).toContain('128-vertex'); + expect(documentation).toContain('weakly connected'); + expect(documentation).toContain('not community-detection'); + expect(documentation).toContain('PageRank'); + expect(documentation).toContain('8-byte'); + expect(documentation).toContain('`O(V² + E)`'); + } + + expect(graphExplorerExample).toContain('## Overview'); + expect(graphExplorerExample).toContain('## How to read the network'); + expect(graphExplorerExample).toContain('## Try the controls'); + expect(graphExplorerExample).toContain('## What actually stays on the GPU'); + expect(graphExplorerExample).toContain(''); + expect(graphExplorerExample).toContain('**Choose a node color mode**'); + expect(graphExplorerExample).toContain('**Choose a node size mode**'); + expect(graphExplorerExample).toContain('**Adjust neighborhood depth**'); + expect(graphExplorerExample).toContain('**Toggle original edges**'); + expect(graphExplorerExample).toContain('**Pause or resume the layout**'); + expect(graphExplorerExample).toContain('**Release pins**'); + expect(graphExplorerExample).toContain('**Reset layout**'); + expect(graphExplorerExample).toContain('**Hold Shift and drag**'); + expect(graphExplorerExample).toContain('nonempty, empty, and nonempty'); + expect(graphExplorerExample).toContain('/docs/api-reference/experimental/lugraph'); + + for (const documentation of [graphDocumentation, graphExplorerExample]) { + expect(documentation).toContain('**Weak components**'); + expect(documentation).toContain('**Vertex degree**'); + expect(documentation).toContain('**PageRank importance**'); + expect(documentation).toContain('**Neighborhood distance**'); + expect(documentation).toContain('legend'); + expect(documentation).toContain('execution times'); + } + }); + test('explains graph motivation, appropriate workloads, and concrete application use cases', () => { expect(graphDocumentation).toContain('## Overview'); expect(graphDocumentation).toContain('## Why keep a graph on the GPU?'); diff --git a/website/content/examples/experimental/lugraph-explorer.mdx b/website/content/examples/experimental/lugraph-explorer.mdx new file mode 100644 index 0000000000..614e80df43 --- /dev/null +++ b/website/content/examples/experimental/lugraph-explorer.mdx @@ -0,0 +1,83 @@ +--- +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'; + +## Overview + +A list of relationships can tell you which two entities are connected, but it cannot immediately +show which account influences a network, how far a problem can spread, or which systems form an +isolated group. This interactive example answers those questions with a deterministic 128-vertex +graph whose relationship analysis, progressive layout, and rendering all run on WebGPU. + + + +## How to read the network + +The graph contains four intentionally generated source groups, unequal-importance hubs, a single +bridge connecting the first two groups, and one isolated final vertex. Its graph inspector compares +four actual GPU-computed perspectives: + +- **Weak components** show which entities remain connected when relationship direction is ignored. + A bridge merges its two source groups into one component, while disconnected groups and the + isolated vertex remain separate. These are not community-detection labels. +- **Vertex degree** counts immediate relationships, revealing directly connected local hubs. +- **PageRank importance** reflects influence arriving from other important vertices, which is not + equivalent to simply counting direct edges. +- **Neighborhood distance** shows bounded shortest-path distance from the selected vertex. + +Node size can independently represent **PageRank**, **vertex degree**, or a **uniform radius**. +All color and sizing modes consume existing GPU result buffers; they do not download graph columns +to JavaScript. + +## Try the controls + +- **Click a node** to select its stable original vertex identifier and highlight its neighborhood. +- **Choose a node color mode** to compare weak components, direct degree, PageRank influence, or + shortest-path distance. +- **Choose a node size mode** to compare PageRank, degree, or uniform-size vertices. +- **Adjust neighborhood depth** to include more or fewer unweighted relationship hops. +- **Toggle original edges** to inspect vertices without hiding or repacking source edge batches. +- **Pause or resume the layout** while keeping the graph, selection, and rendering interactive. +- **Drag a selected node** to move it and pin its position while the rest of the graph evolves. +- **Release pins** to allow pinned vertices to move again. +- **Reset layout** to restore deterministic initial positions and restart the progressive layout. +- **Hold Shift and drag** to pan; use the scroll wheel to zoom. + +These interactions are useful when exploring social relationships, tracing service dependencies, +investigating transactions, or explaining why a structurally important account differs from one +that simply has many direct connections. + +The graph inspector opens automatically for this example. Its accessible color legend, live graph +status, actual WebGPU adapter, frame cadence, and owned or transient GPU memory make the active +pipeline understandable without claiming unmeasured GPU execution times. + +## What actually stays on the GPU + +The original source and target edge columns remain in their aligned nonempty, empty, and nonempty +batches throughout adjacency construction and rendering. GPU-built forward and reverse compressed +adjacency feeds exact vertex degree, bounded breadth-first selection, weakly connected components, +normalized PageRank, and progressive exact force simulation. Node models bind the same writable +position buffer directly as a vertex attribute, and edge models consume their original source +batches; no implicit source concatenation, copied render attribute, or per-frame CPU position +readback is required. + +Picking renders stable vertex identifiers into an integer attachment. Only an explicitly requested +single-pixel selection copies one **8-byte** result back asynchronously through a caller-owned +readback ring; ordinary analytics, simulation, and drawing remain GPU-resident. + +This WebGPU-only example deliberately uses 128 vertices because exact force-directed layout +evaluates all vertex pairs and costs `O(V² + E)` per iteration. It demonstrates real GPU ownership, +composition, and interaction; it does not claim approximate layout, automatic community detection, +or large-graph benchmark performance. + +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 => (