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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/api-reference/experimental/lugraph.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,39 @@ network, dependency map, fraud investigation, or other relationship visualizatio
WebGPU-only educational example, not a large-graph performance benchmark: its exact layout costs
`O(V² + E)` per force iteration and intentionally uses only 128 vertices.

### Use luGraph from deck.gl without copying graph buffers

**Question: How can an existing deck.gl application explore a graph without converting GPU
relationships, analytics, or moving node positions into JavaScript objects?**

The optional [luGraph + deck.gl network explorer](/examples/deck/lugraph-explorer) answers that
question with the reusable `LuGraphDeckEffect`, `LuGraphNodeLayer`, and `LuGraphEdgeLayer`
implementations from the existing private `@deck.gl-community/arrow-layers` adapter package, an
`OrthographicView`, and deck.gl's existing interaction and asynchronous WebGPU picking systems.
Use it when a social-network, service-dependency, fraud-investigation, or citation visualization
already uses deck.gl and needs GPU graph results to become directly drawable attributes.

The effect first encodes forward and reverse adjacency, normalized PageRank, and weak components.
Later frames encode bounded neighborhood selection and exact force-directed layout into
deck.gl's own command encoder; deck.gl remains responsible for queue submission. The writable layout
allocation is also the node layer's `float32x2` instance vertex attribute. PageRank scores,
component labels, hop distances, and the selection mask remain GPU storage inputs; each nonempty
original edge partition gets its own edge layer,
without concatenation, buffer copies, or per-frame graph readback.

The deterministic example fixture is uploaded once. Selecting, pinning, dragging, and changing
neighborhood depth write only the necessary interaction controls or coordinates; they do not
download graph columns. An explicitly requested native deck.gl pick returns the selected original
vertex identifier to JavaScript. Its implementation and transfer size belong to deck.gl; it is
separate from the native explorer's custom **8-byte** integer-picking path above. The example uses
exact `O(V² + E)` layout, not the optional spatial approximation, and does not promise large-graph
throughput.

The reusable graph effect, node and edge layers, and graph integration's deck.gl imports live in
the existing private `@deck.gl-community/arrow-layers` adapter. The website-only example consumes
those exported symbols without importing `@deck.gl/core` or adding an example package; neither
`@luma.gl/experimental` nor its optional graph entry point depends on or imports deck.gl.

## Measure real CPU and WebGPU graph workloads

**Question: Does this graph workflow benefit from GPU execution on my actual browser, and what do
Expand Down
298 changes: 298 additions & 0 deletions examples/deck/lugraph-explorer/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
// luma.gl
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors

import {
LuGraphDeckEffect,
LuGraphEdgeLayer,
LuGraphNodeLayer,
OrthographicView,
type PickingInfo
} from '@deck.gl-community/arrow-layers';
import {Buffer, type Device} from '@luma.gl/core';
import {
ShaderAssembler,
type GLSLShaderAssembler,
type WGSLShaderAssembler
} from '@luma.gl/shadertools';
import {ArrowDeck} from '../arrow-deck';
import {getDeckExampleProps, type DeckExampleDeviceOptions} from '../deck-example-device';
import {
makeGraphExplorerDataset,
type GraphExplorerDataset
} from '../../experimental/lugraph-explorer/graph-data';

const DEFAULT_NEIGHBORHOOD_DEPTH = 2;

type GraphExplorerControls = {
update: () => void;
destroy: () => void;
};

type LuGraphExplorerDeckOptions = DeckExampleDeviceOptions & {
dataset?: GraphExplorerDataset;
};

/**
* Creates an optional deck.gl explorer using resident luGraph analytics and original edge chunks.
*
* Deck owns the WebGPU frame encoder, rendering, controller, and asynchronous node picking. The
* graph module never depends on deck.gl, and no graph column is downloaded for animation, color,
* sizing, selection, or dragging.
*/
export function createLuGraphExplorerDeck(
parent?: HTMLDivElement,
options: LuGraphExplorerDeckOptions = {}
): ArrowDeck<OrthographicView> {
const {dataset, ...deviceOptions} = options;
const ownsContainer = !parent;
const container = parent ?? createStandaloneContainer();
if (getComputedStyle(container).position === 'static') container.style.position = 'relative';

let effect: LuGraphDeckEffect | null = null;
let draggedVertex: number | null = null;
let restoreShaderAssembler: (() => void) | null = null;
let deck: ArrowDeck<OrthographicView>;
const controls = createExplorerControls(container, {
getEffect: () => effect,
redraw: reason => deck?.redraw(reason)
});

deck = new ArrowDeck<OrthographicView>({
parent: container,
...getDeckExampleProps({...deviceOptions, deviceType: 'webgpu'}),
views: new OrthographicView({id: 'lugraph-orthographic'}),
initialViewState: {target: [0, 0, 0], zoom: 7.7, minZoom: 5, maxZoom: 11},
controller: {
dragPan: true,
scrollZoom: {smooth: true, speed: 0.02},
doubleClickZoom: true,
touchZoom: true
},
_animate: true,
pickAsync: 'auto',
layers: [],
effects: [],
onDeviceInitialized: initializedDevice => {
restoreShaderAssembler?.();
restoreShaderAssembler = installLegacyDeckShaderAssemblerCompatibility(initializedDevice);
},
onError: error => {
restoreShaderAssembler?.();
restoreShaderAssembler = null;
throw error;
},
getTooltip: info => getVertexTooltip(info, effect),
onClick: info => {
effect?.setSelectedVertex(info.picked && info.index >= 0 ? info.index : null);
controls.update();
deck.redraw('luGraph deck selection changed');
},
onDragStart: (info, event) => {
if (!effect || !info.picked || info.index < 0) return;
draggedVertex = info.index;
effect.setSelectedVertex(draggedVertex);
effect.setPinnedVertex(draggedVertex, true);
updateDraggedVertex(effect, draggedVertex, info);
controls.update();
event.stopPropagation();
deck.redraw('luGraph vertex drag started');
},
onDrag: (info, event) => {
if (!effect || draggedVertex === null) return;
updateDraggedVertex(effect, draggedVertex, info);
event.stopPropagation();
deck.redraw('luGraph vertex dragged');
},
onDragEnd: (_info, event) => {
if (draggedVertex === null) return;
draggedVertex = null;
controls.update();
event.stopPropagation();
deck.redraw('luGraph vertex pinned');
},
onLoad: ({deck: loadedDeck, device}) => {
if (device.type !== 'webgpu') throw new Error('luGraph deck explorer requires WebGPU');
effect = new LuGraphDeckEffect(device, dataset ?? makeGraphExplorerDataset());
const edgeLayers = effect.graph.sourceVertices.data.flatMap((source, chunkIndex) => {
if (source.length === 0) return [];
const target = effect!.graph.targetVertices.data[chunkIndex];
return [
new LuGraphEdgeLayer({
id: `lugraph-edges-${chunkIndex}`,
data: [],
pickable: false,
positions: effect!.positions,
sourceVertices: source.buffer instanceof Buffer ? source.buffer : source.buffer.buffer,
targetVertices: target.buffer instanceof Buffer ? target.buffer : target.buffer.buffer,
distances: effect!.distances,
edgeCount: source.length,
opacity: 0.85
})
];
});
const nodeLayer = new LuGraphNodeLayer({
id: 'lugraph-nodes',
data: [],
pickable: true,
autoHighlight: true,
positions: effect.positions,
importance: effect.importance,
components: effect.componentLabels,
distances: effect.distances,
selectionMask: effect.selectionMask,
vertexCount: effect.graph.vertexCount,
opacity: 1
});
loadedDeck.setProps({effects: [effect], layers: [...edgeLayers, nodeLayer]});
controls.update();
loadedDeck.redraw('luGraph deck analytics initialized');
},
onFinalize: () => {
restoreShaderAssembler?.();
restoreShaderAssembler = null;
draggedVertex = null;
controls.destroy();
if (ownsContainer) container.remove();
}
});

return deck;
}

/** Bridges exactly one legacy Deck assembler call while preserving strict language separation. */
function installLegacyDeckShaderAssemblerCompatibility(device: Device): () => void {
const original = ShaderAssembler.getDefaultShaderAssembler;
let restored = false;

function restore(): void {
if (restored) return;
if (ShaderAssembler.getDefaultShaderAssembler === getLegacyDeckShaderAssembler) {
ShaderAssembler.getDefaultShaderAssembler = original;
}
restored = true;
}

function getLegacyDeckShaderAssembler(shaderLanguage: 'glsl'): GLSLShaderAssembler;
function getLegacyDeckShaderAssembler(shaderLanguage: 'wgsl'): WGSLShaderAssembler;
function getLegacyDeckShaderAssembler(
shaderLanguage: 'glsl' | 'wgsl'
): GLSLShaderAssembler | WGSLShaderAssembler;
function getLegacyDeckShaderAssembler(
shaderLanguage?: 'glsl' | 'wgsl'
): GLSLShaderAssembler | WGSLShaderAssembler {
if (shaderLanguage === undefined) {
// TODO: Remove after deck.gl forwards its known shading language to luma.gl.
// Restore before forwarding so later user calls retain strict explicit-language behavior.
restore();
return device.info.shadingLanguage === 'wgsl'
? original.call(ShaderAssembler, 'wgsl')
: original.call(ShaderAssembler, 'glsl');
}
return shaderLanguage === 'wgsl'
? original.call(ShaderAssembler, 'wgsl')
: original.call(ShaderAssembler, 'glsl');
}

ShaderAssembler.getDefaultShaderAssembler = getLegacyDeckShaderAssembler;
return restore;
}

/** Updates the same float32x2 allocation bound directly by the node layer's instance attribute. */
function updateDraggedVertex(effect: LuGraphDeckEffect, vertex: number, info: PickingInfo): void {
const coordinate = info.coordinate;
if (!coordinate || coordinate.length < 2) return;
effect.setVertexPosition(vertex, [coordinate[0], coordinate[1]]);
}

function getVertexTooltip(info: PickingInfo, effect: LuGraphDeckEffect | null): string | null {
if (!info.picked || info.index < 0 || !effect) return null;
const state = effect.isVertexPinned(info.index) ? 'pinned' : 'movable';
return `Vertex ${info.index} · ${state}\nGPU PageRank sizing · component color`;
}

/** Provides explicit selection/reset controls without polling or transferring graph metrics. */
function createExplorerControls(
container: HTMLDivElement,
props: {getEffect: () => LuGraphDeckEffect | null; redraw: (reason: string) => void}
): GraphExplorerControls {
const panel = document.createElement('section');
Object.assign(panel.style, {
position: 'absolute',
left: '14px',
top: '14px',
zIndex: '2',
width: '250px',
padding: '12px 14px',
borderRadius: '10px',
background: 'rgba(9, 15, 28, 0.86)',
border: '1px solid rgba(127, 173, 230, 0.2)',
color: '#eaf3ff',
font: '12px/1.5 system-ui, sans-serif'
});
panel.innerHTML = `
<strong style="display:block;font-size:14px">luGraph + deck.gl</strong>
<p style="margin:6px 0 9px;opacity:.8">Resident graph analytics, source-chunk edge layers,
direct instance vertices, and real asynchronous deck.gl picking.</p>
<label style="display:block;margin-bottom:8px">Neighborhood depth
<input data-lugraph-depth type="range" min="0" max="8"
value="${DEFAULT_NEIGHBORHOOD_DEPTH}" style="display:block;width:100%" />
</label>
<div style="display:flex;gap:8px">
<button data-lugraph-reset type="button">Reset layout</button>
<button data-lugraph-release type="button">Release pins</button>
</div>
<p data-lugraph-status style="margin:9px 0 0;opacity:.85">Initializing WebGPU graph…</p>
<p style="margin:6px 0 0;opacity:.64">Click to inspect · drag to pin · scroll to zoom</p>`;
container.appendChild(panel);

const depth = panel.querySelector<HTMLInputElement>('[data-lugraph-depth]');
const reset = panel.querySelector<HTMLButtonElement>('[data-lugraph-reset]');
const release = panel.querySelector<HTMLButtonElement>('[data-lugraph-release]');
const status = panel.querySelector<HTMLElement>('[data-lugraph-status]');
const update = (): void => {
const effect = props.getEffect();
if (!effect || !status) return;
const selected = effect.currentSelection === null ? 'none' : `${effect.currentSelection}`;
status.textContent = `${effect.graph.vertexCount} vertices · ${effect.graph.edgeCount} chunked edges · selected ${selected}`;
};
const updateDepth = (): void => {
props.getEffect()?.setNeighborhoodDepth(Number(depth?.value ?? DEFAULT_NEIGHBORHOOD_DEPTH));
props.redraw('luGraph deck neighborhood depth changed');
};
const resetLayout = (): void => {
props.getEffect()?.requestReset();
props.redraw('luGraph deck deterministic layout reset');
};
const clearPins = (): void => {
props.getEffect()?.clearPins();
update();
props.redraw('luGraph deck pins released');
};
depth?.addEventListener('input', updateDepth);
reset?.addEventListener('click', resetLayout);
release?.addEventListener('click', clearPins);

return {
update,
destroy: () => {
depth?.removeEventListener('input', updateDepth);
reset?.removeEventListener('click', resetLayout);
release?.removeEventListener('click', clearPins);
panel.remove();
}
};
}

function createStandaloneContainer(): HTMLDivElement {
document.body.style.margin = '0';
const container = document.createElement('div');
Object.assign(container.style, {
position: 'fixed',
inset: '0',
overflow: 'hidden',
background: '#070d18'
});
document.body.appendChild(container);
return container;
}
30 changes: 30 additions & 0 deletions modules/arrow-layers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,33 @@ Private deck.gl layers backed by the Arrow adapters and `GPUVector` objects from
The layers intentionally do not use deck.gl `AttributeManager` for Arrow columns.
Arrow data is converted once into `GPUVector`/`GPUTable` inputs and bound directly
to luma.gl models.

## Graph effects and layers

`LuGraphDeckEffect` composes topology, PageRank, weak components, neighborhood search, and
progressive force layout inside deck.gl's existing frame. Deck owns queue submission; the effect
retains original source and target edge partitions, including empty batches, without staging or
reading graph data back to the CPU.

```ts
import {
LuGraphDeckEffect,
LuGraphEdgeLayer,
LuGraphNodeLayer,
type LuGraphDeckDataset
} from '@deck.gl-community/arrow-layers';

const dataset: LuGraphDeckDataset = {
vertexCount,
sourceChunks,
targetChunks,
positions,
velocities
};
const effect = new LuGraphDeckEffect(device, dataset);
```

`LuGraphNodeLayer` consumes the exact progressive position allocation alongside resident PageRank,
component, distance, and selection outputs. Create one `LuGraphEdgeLayer` per nonempty original edge
partition to render caller-owned source and target buffers directly. The graph algorithms remain in
`@luma.gl/experimental/lugraph`; only this private adapter package depends on deck.gl.
1 change: 1 addition & 0 deletions modules/arrow-layers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@luma.gl/arrow": "9.4.0-alpha.4",
"@luma.gl/core": "9.4.0-alpha.4",
"@luma.gl/engine": "9.4.0-alpha.4",
"@luma.gl/experimental": "9.4.0-alpha.4",
"@luma.gl/shadertools": "9.4.0-alpha.4",
"@luma.gl/tables": "9.4.0-alpha.4",
"apache-arrow": "^17.0.0"
Expand Down
Loading
Loading