From 701614d9492ffaffe2229de53a4bce6128996406 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 4 Aug 2026 04:30:22 -0400 Subject: [PATCH 1/6] feat(deck): extract reusable luSpatial geographic query effect --- examples/deck/luspatial-taxi/app.ts | 82 +- .../luspatial-taxi/luspatial-query-effect.ts | 687 ----------- modules/deck-luspatial/README.md | 57 +- modules/deck-luspatial/package.json | 5 + modules/deck-luspatial/src/query/index.ts | 20 + ...luspatial-geographic-point-query-effect.ts | 1013 +++++++++++++++++ modules/deck-luspatial/test/index.ts | 1 + ...geographic-point-query-effect.node.spec.ts | 150 +++ ...tial-geographic-point-query-effect.spec.ts | 134 +++ .../luspatial-taxi-runtime.node.spec.ts | 121 -- website/docusaurus.config.js | 4 + 11 files changed, 1425 insertions(+), 849 deletions(-) delete mode 100644 examples/deck/luspatial-taxi/luspatial-query-effect.ts create mode 100644 modules/deck-luspatial/src/query/index.ts create mode 100644 modules/deck-luspatial/src/query/luspatial-geographic-point-query-effect.ts create mode 100644 modules/deck-luspatial/test/luspatial-geographic-point-query-effect.node.spec.ts create mode 100644 modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts delete mode 100644 test/examples/luspatial-taxi-runtime.node.spec.ts diff --git a/examples/deck/luspatial-taxi/app.ts b/examples/deck/luspatial-taxi/app.ts index b8c256480f..008e200422 100644 --- a/examples/deck/luspatial-taxi/app.ts +++ b/examples/deck/luspatial-taxi/app.ts @@ -6,10 +6,16 @@ import { MapView, type MapViewState, type PickingInfo, - type ViewStateChangeParameters, - type Viewport + type Viewport, + type ViewStateChangeParameters } from '@deck.gl/core'; import {LuSpatialPointLayer} from '@deck.gl-community/luspatial'; +import { + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS, + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_GRAPH_IDS, + LuSpatialGeographicPointQueryEffect, + type LuSpatialGeographicPointQueryStats +} from '@deck.gl-community/luspatial/query'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import {GPUCommandGraphInspectorPanel} from '../../gpu-command-graph-inspector-panel'; @@ -19,21 +25,18 @@ import { type TaxiPointSource } from '../../showcase/billion-point-spatial-atlas/taxi-source'; import {ArrowDeck} from '../arrow-deck'; -import {getDeckExampleProps, type DeckExampleDeviceOptions} from '../deck-example-device'; -import { - LU_SPATIAL_TAXI_QUERY_COUNTER_IDS, - LuSpatialTaxiQueryEffect, - type LuSpatialTaxiQueryStats -} from './luspatial-query-effect'; +import {type DeckExampleDeviceOptions, getDeckExampleProps} from '../deck-example-device'; import { - TAXI_CORPUS_POINT_COUNT, - TAXI_POINT_COUNT, assertLongitudeLatitudeTaxiMetadata, getTaxiPoint, + type LuSpatialTaxiData, makeLuSpatialTaxiDataAsync, makeLuSpatialTaxiDataFromResidentWindow, makeTaxiZonePresets, - type LuSpatialTaxiData, + TAXI_CORPUS_POINT_COUNT, + TAXI_GRID_SIZE, + TAXI_POINT_COUNT, + TAXI_PROJECTION_ORIGIN, type TaxiZonePreset } from './taxi-data'; @@ -99,9 +102,9 @@ export function createLuSpatialTaxiDeck( minZoom: 9, maxZoom: 20 }; - let queryEffect: LuSpatialTaxiQueryEffect | null = null; + let queryEffect: LuSpatialGeographicPointQueryEffect | null = null; let latestSelectionCenter: readonly [number, number] = initialZone.center; - let stagingQueryEffect: LuSpatialTaxiQueryEffect | null = null; + let stagingQueryEffect: LuSpatialGeographicPointQueryEffect | null = null; let activeLayers: LuSpatialPointLayer[] = []; let queryRadiusKilometres = 0.35; let taxiDataRevision = 0; @@ -136,7 +139,6 @@ export function createLuSpatialTaxiDeck( queryRadiusKilometres = radiusKilometres; queryEffect?.setSelectionRadius(radiusKilometres); stagingQueryEffect?.setSelectionRadius(radiusKilometres); - deck?.redraw('luSpatial radius changed'); }, onZoneChange: zone => { latestSelectionCenter = zone.center; @@ -146,12 +148,11 @@ export function createLuSpatialTaxiDeck( latitude: zone.center[1], zoom: zone.zoom }; + deck?.setProps({viewState}); queryEffect?.setSelection(zone.center, queryRadiusKilometres); stagingQueryEffect?.setSelection(zone.center, queryRadiusKilometres); controls.setCoordinate(zone.center, zone.name); - deck?.setProps({viewState}); synchronizeBasemap(map, viewState); - deck?.redraw('luSpatial taxi zone changed'); } }); controls.setCoordinate(initialZone.center, initialZone.name); @@ -191,7 +192,6 @@ export function createLuSpatialTaxiDeck( stagingQueryEffect?.setSelection(center, queryRadiusKilometres); controls.setCoordinate(center, 'Custom map query'); controls.setCustomZone(); - deck.redraw('luSpatial query moved'); }, onViewStateChange: ({viewState: nextViewState}: ViewStateChangeParameters) => { viewState = nextViewState as TaxiViewState; @@ -216,10 +216,20 @@ export function createLuSpatialTaxiDeck( const previousQueryEffect = queryEffect; const previousLayers = activeLayers; const nextTaxiDataRevision = taxiDataRevision + 1; - let nextQueryEffect: LuSpatialTaxiQueryEffect; + let nextQueryEffect: LuSpatialGeographicPointQueryEffect; try { - nextQueryEffect = new LuSpatialTaxiQueryEffect(device, nextTaxiData, { + nextQueryEffect = new LuSpatialGeographicPointQueryEffect(device, { id: `luspatial-taxi-query-effect-${nextTaxiDataRevision}`, + longitudeLatitudes: nextTaxiData.longitudeLatitudes, + sourceBounds: nextTaxiData.sourceBounds, + projectedBounds: nextTaxiData.projectedBounds, + projectionOrigin: TAXI_PROJECTION_ORIGIN, + gridSize: TAXI_GRID_SIZE, + initialSelection: { + center: latestSelectionCenter, + radiusKilometres: queryRadiusKilometres + }, + selectionRadiusRangeKilometres: [0.05, 5], onStats: stats => controls.updateStats(stats) }); } catch (error) { @@ -440,7 +450,7 @@ type TaxiControlPanel = { setCustomZone: () => void; setLoadingProgress: (processedPointCount: number, totalPointCount: number) => void; updateSourceStatus: (status: {corpusPointCount?: number; message: string}) => void; - updateStats: (stats: LuSpatialTaxiQueryStats) => void; + updateStats: (stats: LuSpatialGeographicPointQueryStats) => void; }; type TaxiLoadingIndicator = { @@ -629,16 +639,16 @@ function createControlPanel( const graphInspectorElement = root.querySelector('[data-graph-inspector]')!; const graphInspectorPanel = new GPUCommandGraphInspectorPanel(graphInspectorElement, { graphLabels: { - 'luspatial-taxi-build-graph': 'luProj projection + grid build', - 'luspatial-taxi-query-graph': 'Viewport + radius queries' + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_GRAPH_IDS.build]: 'luProj projection + grid build', + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_GRAPH_IDS.query]: 'Viewport + radius queries' }, counterLabels: { - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.viewportIntersectedCells]: 'Viewport cells', - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.viewportCandidates]: 'Viewport candidates', - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.viewportMatches]: 'Viewport matches', - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.selectionIntersectedCells]: 'Selection cells', - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.selectionCandidates]: 'Selection candidates', - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.selectionMatches]: 'Selection matches' + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.viewportIntersectedCells]: 'Viewport cells', + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.viewportCandidates]: 'Viewport candidates', + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.viewportMatches]: 'Viewport matches', + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.selectionIntersectedCells]: 'Selection cells', + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.selectionCandidates]: 'Selection candidates', + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.selectionMatches]: 'Selection matches' } }); @@ -803,8 +813,8 @@ async function closeTaxiPointSource(source: TaxiPointSource | null): Promise, - previousQueryEffect: LuSpatialTaxiQueryEffect | null, - nextQueryEffect: LuSpatialTaxiQueryEffect, + previousQueryEffect: LuSpatialGeographicPointQueryEffect | null, + nextQueryEffect: LuSpatialGeographicPointQueryEffect, shouldSkip: () => boolean ): void { if (!previousQueryEffect) return; @@ -824,7 +834,7 @@ type TaxiLayerStagingOptions = { }; function makeTaxiLayers( - queryEffect: LuSpatialTaxiQueryEffect, + queryEffect: LuSpatialGeographicPointQueryEffect, taxiDataRevision: number, options: TaxiLayerStagingOptions = {} ): LuSpatialPointLayer[] { @@ -835,10 +845,7 @@ function makeTaxiLayers( pickable: true, autoHighlight: true, highlightColor: [255, 140, 32, 230], - positions: queryEffect.longitudeLatitudes, - pointIds: queryEffect.viewportIds, - drawCommands: queryEffect.drawCommands, - commandIndex: 0, + ...queryEffect.outputs.viewport, color: [94, 172, 198, 105], radiusPixels: 0.9, radiusScale: getTaxiPointRadiusScale, @@ -857,10 +864,7 @@ function makeTaxiLayers( pickable: true, autoHighlight: true, highlightColor: [255, 140, 32, 245], - positions: queryEffect.longitudeLatitudes, - pointIds: queryEffect.selectedIds, - drawCommands: queryEffect.drawCommands, - commandIndex: 1, + ...queryEffect.outputs.selection, color: [52, 220, 244, 205], radiusPixels: 1.25, radiusScale: getTaxiPointRadiusScale, diff --git a/examples/deck/luspatial-taxi/luspatial-query-effect.ts b/examples/deck/luspatial-taxi/luspatial-query-effect.ts deleted file mode 100644 index b8e8149268..0000000000 --- a/examples/deck/luspatial-taxi/luspatial-query-effect.ts +++ /dev/null @@ -1,687 +0,0 @@ -// luma.gl -// SPDX-License-Identifier: MIT -// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors - -import type {Effect, EffectContext} from '@deck.gl/core'; -import {Buffer, type Device} from '@luma.gl/core'; -import { - DrawCommandBuffer, - GPUCommandGraph, - GPUCommandGraphInspector, - type GPUCommandGraphInspectorObservation, - type GPUCommandGraphInspectorSnapshot, - type CompiledGPUCommandGraph -} from '@luma.gl/experimental'; -import {GPUGridIndex, GPUPointSpatialQuery} from '@luma.gl/experimental/geospatial'; -import {compileProjectionPlan, GPUProjection} from '@luma.gl/experimental/luproj'; -import {TAXI_GRID_SIZE, projectTaxiLongitudeLatitude, type LuSpatialTaxiData} from './taxi-data'; - -const UINT32_BYTE_LENGTH = Uint32Array.BYTES_PER_ELEMENT; -const STORAGE_BUFFER_OFFSET_ALIGNMENT = 256; - -export const LU_SPATIAL_TAXI_QUERY_COUNTER_IDS = { - viewportIntersectedCells: 'viewport-intersected-cells', - viewportCandidates: 'viewport-candidates', - viewportMatches: 'viewport-matches', - selectionIntersectedCells: 'selection-intersected-cells', - selectionCandidates: 'selection-candidates', - selectionMatches: 'selection-matches' -} as const; - -export const LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS = { - viewportIntersectedCellCount: 0, - viewportCandidateCount: STORAGE_BUFFER_OFFSET_ALIGNMENT, - selectionIntersectedCellCount: STORAGE_BUFFER_OFFSET_ALIGNMENT * 2, - selectionCandidateCount: STORAGE_BUFFER_OFFSET_ALIGNMENT * 3 -} as const; - -const QUERY_DIAGNOSTIC_BUFFER_BYTE_LENGTH = - LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionCandidateCount + UINT32_BYTE_LENGTH; - -type DestroyableResource = {destroy(): void}; - -/** Exact work and result counts sampled from the two GPU spatial queries. */ -export type LuSpatialTaxiQueryCounters = { - viewportIntersectedCellCount: number; - viewportCandidateCount: number; - visiblePointCount: number; - selectionIntersectedCellCount: number; - selectionCandidateCount: number; - selectedPointCount: number; -}; - -export type LuSpatialTaxiQueryStats = LuSpatialTaxiQueryCounters & { - residentPointCount: number; - graphNodeCount: number; - buildEncodingMilliseconds: number; - queryEncodingMilliseconds: number; - inspectorSnapshot: GPUCommandGraphInspectorSnapshot; -}; - -export type LuSpatialTaxiQueryEffectOptions = { - /** Stable effect identity for this resident data revision. */ - id?: string; - onStats?: (stats: LuSpatialTaxiQueryStats) => void; -}; - -/** Deck effect that projects, indexes, and queries the resident taxi window on WebGPU. */ -export class LuSpatialTaxiQueryEffect implements Effect { - readonly id: string; - readonly props = {}; - readonly useInPicking = true; - readonly longitudeLatitudes: Buffer; - readonly viewportIds: Buffer; - readonly selectedIds: Buffer; - readonly drawCommands: DrawCommandBuffer; - readonly inspector = new GPUCommandGraphInspector({ - maxSamples: 120, - getNodeGroup: node => { - if (node.id.includes('project')) return 'projection'; - if (node.id.includes('grid')) return 'index'; - if (node.id.includes('query')) return 'query'; - return undefined; - } - }); - - private readonly device: Device; - private readonly data: LuSpatialTaxiData; - private readonly projectedPositions: Buffer; - private readonly cellOffsets: Buffer; - private readonly indexRowIndices: Buffer; - private readonly indexCount: Buffer; - private readonly indexOverflow: Buffer; - private readonly viewportQuery: Buffer; - private readonly selectionQuery: Buffer; - private readonly viewportTotalCount: Buffer; - private readonly selectionTotalCount: Buffer; - private readonly viewportOverflow: Buffer; - private readonly selectionOverflow: Buffer; - private readonly queryDiagnostics: Buffer; - private readonly buildGraph: CompiledGPUCommandGraph; - private readonly queryGraph: CompiledGPUCommandGraph; - private readonly buildGraphObservation: GPUCommandGraphInspectorObservation; - private readonly queryGraphObservation: GPUCommandGraphInspectorObservation; - private readonly onStats?: (stats: LuSpatialTaxiQueryStats) => void; - private projection: GPUProjection | null = null; - private selectionCenter: readonly [number, number] = [-73.9855, 40.758]; - private selectionRadiusKilometres = 0.35; - private visiblePointCount = 0; - private selectedPointCount = 0; - private viewportIntersectedCellCount = 0; - private viewportCandidateCount = 0; - private selectionIntersectedCellCount = 0; - private selectionCandidateCount = 0; - private buildEncodingMilliseconds = 0; - private queryEncodingMilliseconds = 0; - private frameIndex = 0; - private countReadPending = false; - private countSampleRequested = true; - private countSampleTimer: ReturnType | null = null; - private queryInputsChanged = true; - private lastViewportBounds: Float32Array | null = null; - private destroyed = false; - private readonly ownedResources: DestroyableResource[] = []; - - constructor( - device: Device, - data: LuSpatialTaxiData, - options: LuSpatialTaxiQueryEffectOptions = {} - ) { - if (device.type !== 'webgpu') { - throw new Error('LuSpatialTaxiQueryEffect requires WebGPU'); - } - this.device = device; - this.data = data; - this.id = options.id ?? 'luspatial-taxi-query-effect'; - this.onStats = options.onStats; - - try { - const cellCount = TAXI_GRID_SIZE[0] * TAXI_GRID_SIZE[1]; - this.longitudeLatitudes = this.ownResource( - device.createBuffer({ - id: 'luspatial-taxi-longitude-latitudes', - data: data.longitudeLatitudes, - usage: Buffer.STORAGE - }) - ); - this.projectedPositions = this.ownResource( - device.createBuffer({ - id: 'luspatial-taxi-projected-positions', - byteLength: data.pointCount * 2 * Float32Array.BYTES_PER_ELEMENT, - usage: Buffer.STORAGE - }) - ); - this.cellOffsets = this.ownResource( - device.createBuffer({ - id: 'luspatial-taxi-cell-offsets', - byteLength: (cellCount + 1) * UINT32_BYTE_LENGTH, - usage: Buffer.STORAGE | Buffer.COPY_SRC - }) - ); - this.indexRowIndices = this.ownResource( - device.createBuffer({ - id: 'luspatial-taxi-index-row-indices', - byteLength: data.pointCount * UINT32_BYTE_LENGTH, - usage: Buffer.STORAGE | Buffer.COPY_SRC - }) - ); - this.indexCount = this.ownResource(createScalarBuffer(device, 'luspatial-taxi-index-count')); - this.indexOverflow = this.ownResource( - createScalarBuffer(device, 'luspatial-taxi-index-overflow') - ); - this.viewportIds = this.ownResource( - device.createBuffer({ - id: 'luspatial-taxi-viewport-ids', - byteLength: data.pointCount * UINT32_BYTE_LENGTH, - usage: Buffer.STORAGE | Buffer.COPY_SRC - }) - ); - this.selectedIds = this.ownResource( - device.createBuffer({ - id: 'luspatial-taxi-selected-ids', - byteLength: data.pointCount * UINT32_BYTE_LENGTH, - usage: Buffer.STORAGE | Buffer.COPY_SRC - }) - ); - this.viewportQuery = this.ownResource( - device.createBuffer({ - id: 'luspatial-taxi-viewport-query', - byteLength: 4 * Float32Array.BYTES_PER_ELEMENT, - usage: Buffer.STORAGE | Buffer.COPY_DST - }) - ); - this.selectionQuery = this.ownResource( - device.createBuffer({ - id: 'luspatial-taxi-selection-query', - byteLength: 3 * Float32Array.BYTES_PER_ELEMENT, - usage: Buffer.STORAGE | Buffer.COPY_DST - }) - ); - this.viewportTotalCount = this.ownResource( - createScalarBuffer(device, 'luspatial-taxi-viewport-total-count') - ); - this.selectionTotalCount = this.ownResource( - createScalarBuffer(device, 'luspatial-taxi-selection-total-count') - ); - this.viewportOverflow = this.ownResource( - createScalarBuffer(device, 'luspatial-taxi-viewport-overflow') - ); - this.selectionOverflow = this.ownResource( - createScalarBuffer(device, 'luspatial-taxi-selection-overflow') - ); - this.queryDiagnostics = this.ownResource( - device.createBuffer({ - id: 'luspatial-taxi-query-diagnostics', - byteLength: QUERY_DIAGNOSTIC_BUFFER_BYTE_LENGTH, - usage: Buffer.STORAGE | Buffer.COPY_SRC - }) - ); - this.drawCommands = this.ownResource( - new DrawCommandBuffer(device, { - id: 'luspatial-taxi-draw-commands', - type: 'draw', - capacity: 2, - commands: [ - {vertexCount: 6, instanceCount: 0}, - {vertexCount: 6, instanceCount: 0} - ] - }) - ); - - this.buildGraph = this.ownResource(this.createBuildGraph()); - this.queryGraph = this.ownResource(this.createQueryGraph()); - this.buildGraphObservation = this.ownObservation( - this.inspector.observeGraph(this.buildGraph) - ); - this.queryGraphObservation = this.ownObservation( - this.inspector.observeGraph(this.queryGraph) - ); - const commandEncoder = device.createCommandEncoder({id: 'luspatial-taxi-index-build'}); - const encoding = this.buildGraphObservation.encode(commandEncoder, {parameters: undefined}); - this.buildEncodingMilliseconds = encoding.stats.cpuEncodeTimeMilliseconds; - device.submit(commandEncoder.finish()); - this.publishStats(); - if (encoding.canReadGPUTimings) { - setTimeout(() => { - if (!this.destroyed) { - void this.buildGraphObservation.recordGPUTimings(encoding); - } - }, 0); - } - } catch (error) { - this.destroyOwnedResources(); - throw error; - } - } - - setup(_context: EffectContext): void {} - - setSelection(center: readonly [number, number], radiusKilometres?: number): void { - this.selectionCenter = center; - if (radiusKilometres !== undefined) { - this.selectionRadiusKilometres = clamp(radiusKilometres, 0.05, 5); - } - this.queryInputsChanged = true; - } - - setSelectionRadius(radiusKilometres: number): void { - this.selectionRadiusKilometres = clamp(radiusKilometres, 0.05, 5); - this.queryInputsChanged = true; - } - - getSelection(): {center: readonly [number, number]; radiusKilometres: number} { - return { - center: this.selectionCenter, - radiusKilometres: this.selectionRadiusKilometres - }; - } - - preRender(options: Parameters[0]): void { - if (this.destroyed) return; - const viewport = options.viewports[0]; - if (!viewport || viewport.width <= 0 || viewport.height <= 0) return; - - const projectedCorners = [ - viewport.unproject([0, 0]), - viewport.unproject([viewport.width, 0]), - viewport.unproject([0, viewport.height]), - viewport.unproject([viewport.width, viewport.height]) - ].map(coordinate => projectTaxiLongitudeLatitude([coordinate[0], coordinate[1]])); - const viewportBounds = new Float32Array([ - Math.min(...projectedCorners.map(coordinate => coordinate[0])), - Math.min(...projectedCorners.map(coordinate => coordinate[1])), - Math.max(...projectedCorners.map(coordinate => coordinate[0])), - Math.max(...projectedCorners.map(coordinate => coordinate[1])) - ]); - const viewportBoundsChanged = - !this.lastViewportBounds || - viewportBounds.some((value, index) => value !== this.lastViewportBounds?.[index]); - if (!viewportBoundsChanged && !this.queryInputsChanged) return; - - this.lastViewportBounds = viewportBounds; - const projectedSelection = projectTaxiLongitudeLatitude(this.selectionCenter); - this.viewportQuery.write(viewportBounds); - this.selectionQuery.write( - new Float32Array([ - projectedSelection[0], - projectedSelection[1], - this.selectionRadiusKilometres - ]) - ); - - const encoding = this.queryGraphObservation.encode(this.device.commandEncoder, { - parameters: undefined - }); - this.queryEncodingMilliseconds = encoding.stats.cpuEncodeTimeMilliseconds; - this.frameIndex++; - this.queryInputsChanged = false; - this.scheduleCountSample(80); - if (this.frameIndex === 1 || this.frameIndex % 30 === 0) { - this.scheduleCountSample(0); - if (encoding.canReadGPUTimings) { - setTimeout(() => { - if (!this.destroyed) { - void this.queryGraphObservation.recordGPUTimings(encoding); - } - }, 0); - } - } - if (this.frameIndex === 1 || this.frameIndex % 15 === 0) this.publishStats(); - } - - cleanup(_context: EffectContext): void { - this.destroy(); - } - - /** Releases GPU resources when a newly constructed effect cannot be adopted by deck.gl. */ - destroy(): void { - if (this.destroyed) return; - this.destroyed = true; - if (this.countSampleTimer !== null) clearTimeout(this.countSampleTimer); - this.destroyOwnedResources(); - this.projection = null; - } - - private ownResource(resource: T): T { - this.ownedResources.push(resource); - return resource; - } - - private ownObservation void}>(observation: T): T { - this.ownedResources.push({destroy: () => observation.detach()}); - return observation; - } - - private destroyOwnedResources(): void { - for (let index = this.ownedResources.length - 1; index >= 0; index--) { - try { - this.ownedResources[index]?.destroy(); - } catch { - // Continue releasing the remaining resources after device loss or partial construction. - } - } - this.ownedResources.length = 0; - } - - private createBuildGraph(): CompiledGPUCommandGraph { - const graph = new GPUCommandGraph(this.device, {id: 'luspatial-taxi-build-graph'}); - const sourceBuffer = importBuffer(graph, 'longitude-latitudes', this.longitudeLatitudes); - const projectedBuffer = importBuffer(graph, 'projected-positions', this.projectedPositions); - const cellOffsetsBuffer = importBuffer(graph, 'cell-offsets', this.cellOffsets); - const rowIndicesBuffer = importBuffer(graph, 'index-row-indices', this.indexRowIndices); - const countBuffer = importBuffer(graph, 'index-count', this.indexCount); - const overflowBuffer = importBuffer(graph, 'index-overflow', this.indexOverflow); - const source = graph.createDataView(sourceBuffer, { - format: 'float32x2', - length: this.data.pointCount - }); - const projected = graph.createDataView(projectedBuffer, { - format: 'float32x2', - length: this.data.pointCount - }); - const cellOffsets = graph.createDataView(cellOffsetsBuffer, { - format: 'uint32', - length: TAXI_GRID_SIZE[0] * TAXI_GRID_SIZE[1] + 1 - }); - const rowIndices = graph.createDataView(rowIndicesBuffer, { - format: 'uint32', - length: this.data.pointCount - }); - const count = graph.createDataView(countBuffer, {format: 'uint32', length: 1}); - const overflow = graph.createDataView(overflowBuffer, {format: 'uint32', length: 1}); - - const projectionPlan = compileProjectionPlan({ - projection: coordinates => { - const projected = projectTaxiLongitudeLatitude([coordinates[0], coordinates[1]]); - return [projected[0], projected[1]]; - }, - bounds: this.data.sourceBounds, - degree: 2, - tolerance: 0.0005, - maxDepth: 4 - }); - const projection = this.ownResource( - new GPUProjection({ - id: 'luspatial-taxi-luproj-project', - positions: source, - output: projected, - plan: projectionPlan - }) - ); - projection.addToGraph(graph); - this.projection = projection; - new GPUGridIndex({ - id: 'luspatial-taxi-grid', - positions: projected, - gridSize: TAXI_GRID_SIZE, - bounds: this.data.projectedBounds, - cellOffsets, - objectIds: rowIndices, - count, - overflow - }).addToGraph(graph); - return graph.compile(); - } - - private createQueryGraph(): CompiledGPUCommandGraph { - const graph = new GPUCommandGraph(this.device, {id: 'luspatial-taxi-query-graph'}); - const projectedBuffer = importBuffer(graph, 'projected-positions', this.projectedPositions); - const cellOffsetsBuffer = importBuffer(graph, 'cell-offsets', this.cellOffsets); - const rowIndicesBuffer = importBuffer(graph, 'index-row-indices', this.indexRowIndices); - const indexCountBuffer = importBuffer(graph, 'index-count', this.indexCount); - const indexOverflowBuffer = importBuffer(graph, 'index-overflow', this.indexOverflow); - const viewportIdsBuffer = importBuffer(graph, 'viewport-ids', this.viewportIds); - const selectedIdsBuffer = importBuffer(graph, 'selected-ids', this.selectedIds); - const viewportQueryBuffer = importBuffer(graph, 'viewport-query', this.viewportQuery); - const selectionQueryBuffer = importBuffer(graph, 'selection-query', this.selectionQuery); - const viewportTotalCountBuffer = importBuffer( - graph, - 'viewport-total-count', - this.viewportTotalCount - ); - const selectionTotalCountBuffer = importBuffer( - graph, - 'selection-total-count', - this.selectionTotalCount - ); - const viewportOverflowBuffer = importBuffer(graph, 'viewport-overflow', this.viewportOverflow); - const selectionOverflowBuffer = importBuffer( - graph, - 'selection-overflow', - this.selectionOverflow - ); - const queryDiagnosticsBuffer = importBuffer(graph, 'query-diagnostics', this.queryDiagnostics); - const drawCommandBuffer = importBuffer(graph, 'draw-commands', this.drawCommands.buffer); - - const positions = graph.createDataView(projectedBuffer, { - format: 'float32x2', - length: this.data.pointCount - }); - const cellOffsets = graph.createDataView(cellOffsetsBuffer, { - format: 'uint32', - length: TAXI_GRID_SIZE[0] * TAXI_GRID_SIZE[1] + 1 - }); - const rowIndices = graph.createDataView(rowIndicesBuffer, { - format: 'uint32', - length: this.data.pointCount - }); - const index = { - gridSize: TAXI_GRID_SIZE, - bounds: this.data.projectedBounds, - cellOffsets, - rowIndices, - count: graph.createDataView(indexCountBuffer, {format: 'uint32', length: 1}), - overflow: graph.createDataView(indexOverflowBuffer, {format: 'uint32', length: 1}) - }; - const viewportIntersectedCellCount = graph.createDataView(queryDiagnosticsBuffer, { - format: 'uint32', - length: 1, - byteOffset: LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS.viewportIntersectedCellCount - }); - const viewportCandidateCount = graph.createDataView(queryDiagnosticsBuffer, { - format: 'uint32', - length: 1, - byteOffset: LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS.viewportCandidateCount - }); - const selectionIntersectedCellCount = graph.createDataView(queryDiagnosticsBuffer, { - format: 'uint32', - length: 1, - byteOffset: LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionIntersectedCellCount - }); - const selectionCandidateCount = graph.createDataView(queryDiagnosticsBuffer, { - format: 'uint32', - length: 1, - byteOffset: LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionCandidateCount - }); - - new GPUPointSpatialQuery({ - id: 'luspatial-taxi-viewport-query', - positions, - index, - kind: 'bounds', - query: graph.createDataView(viewportQueryBuffer, {format: 'float32', length: 4}), - intersectedCellCount: viewportIntersectedCellCount, - candidateCount: viewportCandidateCount, - output: { - ids: graph.createDataView(viewportIdsBuffer, { - format: 'uint32', - length: this.data.pointCount - }), - count: graph.createDataView(drawCommandBuffer, { - format: 'uint32', - length: 1, - byteOffset: this.drawCommands.getInstanceCountByteOffset(0) - }), - overflow: graph.createDataView(viewportOverflowBuffer, {format: 'uint32', length: 1}), - totalCount: graph.createDataView(viewportTotalCountBuffer, { - format: 'uint32', - length: 1 - }) - } - }).addToGraph(graph); - - new GPUPointSpatialQuery({ - id: 'luspatial-taxi-radius-query', - positions, - index, - kind: 'radius', - query: graph.createDataView(selectionQueryBuffer, {format: 'float32', length: 3}), - intersectedCellCount: selectionIntersectedCellCount, - candidateCount: selectionCandidateCount, - output: { - ids: graph.createDataView(selectedIdsBuffer, { - format: 'uint32', - length: this.data.pointCount - }), - count: graph.createDataView(drawCommandBuffer, { - format: 'uint32', - length: 1, - byteOffset: this.drawCommands.getInstanceCountByteOffset(1) - }), - overflow: graph.createDataView(selectionOverflowBuffer, {format: 'uint32', length: 1}), - totalCount: graph.createDataView(selectionTotalCountBuffer, { - format: 'uint32', - length: 1 - }) - } - }).addToGraph(graph); - return graph.compile(); - } - - private async sampleCounts(): Promise { - if (this.destroyed) return; - if (this.countReadPending) { - this.countSampleRequested = true; - return; - } - this.countReadPending = true; - try { - const [drawCommandBytes, queryDiagnosticBytes] = await Promise.all([ - this.drawCommands.buffer.readAsync(), - this.queryDiagnostics.readAsync() - ]); - if (this.destroyed) return; - const counters = decodeLuSpatialTaxiQueryCounters(drawCommandBytes, queryDiagnosticBytes, { - viewportInstanceCountByteOffset: this.drawCommands.getInstanceCountByteOffset(0), - selectionInstanceCountByteOffset: this.drawCommands.getInstanceCountByteOffset(1) - }); - this.viewportIntersectedCellCount = counters.viewportIntersectedCellCount; - this.viewportCandidateCount = counters.viewportCandidateCount; - this.visiblePointCount = counters.visiblePointCount; - this.selectionIntersectedCellCount = counters.selectionIntersectedCellCount; - this.selectionCandidateCount = counters.selectionCandidateCount; - this.selectedPointCount = counters.selectedPointCount; - this.queryGraphObservation.recordCounters(makeLuSpatialTaxiQueryInspectorCounters(counters)); - this.publishStats(); - } catch { - // Device loss or teardown can reject optional diagnostics after the render path has ended. - // Rendering stays entirely GPU-driven, so the next requested sample can retry safely. - } finally { - this.countReadPending = false; - if (this.countSampleRequested && !this.destroyed) { - this.countSampleRequested = false; - this.scheduleCountSample(0); - } - } - } - - private scheduleCountSample(delayMilliseconds: number): void { - if (this.destroyed) return; - this.countSampleRequested = false; - if (this.countSampleTimer !== null) clearTimeout(this.countSampleTimer); - this.countSampleTimer = setTimeout(() => { - this.countSampleTimer = null; - void this.sampleCounts(); - }, delayMilliseconds); - } - - private publishStats(): void { - this.onStats?.({ - residentPointCount: this.data.pointCount, - viewportIntersectedCellCount: this.viewportIntersectedCellCount, - viewportCandidateCount: this.viewportCandidateCount, - visiblePointCount: this.visiblePointCount, - selectionIntersectedCellCount: this.selectionIntersectedCellCount, - selectionCandidateCount: this.selectionCandidateCount, - selectedPointCount: this.selectedPointCount, - graphNodeCount: - this.buildGraph.stats.nodeOrder.length + this.queryGraph.stats.nodeOrder.length, - buildEncodingMilliseconds: this.buildEncodingMilliseconds, - queryEncodingMilliseconds: this.queryEncodingMilliseconds, - inspectorSnapshot: this.inspector.getSnapshot() - }); - } -} - -/** Decodes one pair of sparse GPU query-counter and indirect-draw readbacks. */ -export function decodeLuSpatialTaxiQueryCounters( - drawCommandBytes: Uint8Array, - queryDiagnosticBytes: Uint8Array, - drawCommandLayout: { - viewportInstanceCountByteOffset: number; - selectionInstanceCountByteOffset: number; - } -): LuSpatialTaxiQueryCounters { - return { - viewportIntersectedCellCount: readUint32AtByteOffset( - queryDiagnosticBytes, - LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS.viewportIntersectedCellCount - ), - viewportCandidateCount: readUint32AtByteOffset( - queryDiagnosticBytes, - LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS.viewportCandidateCount - ), - visiblePointCount: readUint32AtByteOffset( - drawCommandBytes, - drawCommandLayout.viewportInstanceCountByteOffset - ), - selectionIntersectedCellCount: readUint32AtByteOffset( - queryDiagnosticBytes, - LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionIntersectedCellCount - ), - selectionCandidateCount: readUint32AtByteOffset( - queryDiagnosticBytes, - LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionCandidateCount - ), - selectedPointCount: readUint32AtByteOffset( - drawCommandBytes, - drawCommandLayout.selectionInstanceCountByteOffset - ) - }; -} - -/** Maps an exact query sample onto stable inspector counter identifiers. */ -export function makeLuSpatialTaxiQueryInspectorCounters( - counters: LuSpatialTaxiQueryCounters -): Readonly> { - return { - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.viewportIntersectedCells]: - counters.viewportIntersectedCellCount, - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.viewportCandidates]: counters.viewportCandidateCount, - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.viewportMatches]: counters.visiblePointCount, - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.selectionIntersectedCells]: - counters.selectionIntersectedCellCount, - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.selectionCandidates]: counters.selectionCandidateCount, - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.selectionMatches]: counters.selectedPointCount - }; -} - -function readUint32AtByteOffset(bytes: Uint8Array, byteOffset: number): number { - return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(byteOffset, true); -} - -function createScalarBuffer(device: Device, id: string): Buffer { - return device.createBuffer({ - id, - byteLength: UINT32_BYTE_LENGTH, - usage: Buffer.STORAGE | Buffer.COPY_SRC - }); -} - -function importBuffer(graph: GPUCommandGraph, id: string, buffer: Buffer) { - return graph.importBuffer({id, byteLength: buffer.byteLength, usage: buffer.usage}, buffer); -} - -function clamp(value: number, minimum: number, maximum: number): number { - return Math.max(minimum, Math.min(maximum, value)); -} diff --git a/modules/deck-luspatial/README.md b/modules/deck-luspatial/README.md index bad3479d43..05fe0f007b 100644 --- a/modules/deck-luspatial/README.md +++ b/modules/deck-luspatial/README.md @@ -9,8 +9,9 @@ query results, indirect commands, and source positions remain owned by the appli The command buffer must use the non-indexed `draw` layout. Its selected record keeps `vertexCount: 6` and `firstVertex: 0`; GPU queries normally update only `instanceCount`. -The package deliberately does not depend on Arrow, GeoArrow, or the luSpatial query algorithms. -Applications can produce the fixed-width buffers with any ingestion and query pipeline. +The root layer entry point deliberately does not import Arrow, GeoArrow, or the luSpatial query +algorithms. Applications can produce the fixed-width buffers with any ingestion and query pipeline; +the optional query entry point described below supplies one reusable geographic workflow. ```ts import {LuSpatialPointLayer} from '@deck.gl-community/luspatial'; @@ -37,3 +38,55 @@ metadata without a readback. Deck's RGB24 picking reserves zero for “no object,” so indices through `16_777_214` are pickable. Larger indices continue to render but are intentionally omitted from the picking pass; use compact resident row indices and keep any global corpus-ID mapping in the application. + +## Geographic point queries + +The optional `@deck.gl-community/luspatial/query` subpath adds a WebGPU Deck effect that projects +WGS84 longitude/latitude rows into local kilometres, builds a flat uniform-grid index once, and +runs viewport-bounds plus local-radius queries before each draw. It keeps result IDs and clamped +counts on the GPU; the two `outputs` objects can be passed directly to `LuSpatialPointLayer`. + +```ts +import {LuSpatialPointLayer} from '@deck.gl-community/luspatial'; +import {LuSpatialGeographicPointQueryEffect} from '@deck.gl-community/luspatial/query'; + +const queryEffect = new LuSpatialGeographicPointQueryEffect(device, { + longitudeLatitudes, + sourceBounds: [-74.1, 40.65, -73.84, 40.85], + projectionOrigin: [-73.97, 40.75], + projectedBounds: [-12, -10, 12, 10], + gridSize: [256, 256], + initialSelection: {center: [-73.9855, 40.758], radiusKilometres: 0.35}, + selectionRadiusRangeKilometres: [0.05, 5], + onStats: stats => updateInspector(stats.inspectorSnapshot) +}); + +const contextLayer = new LuSpatialPointLayer({ + id: 'context-points', + ...queryEffect.outputs.viewport +}); +const selectionLayer = new LuSpatialPointLayer({ + id: 'selected-points', + ...queryEffect.outputs.selection +}); + +deck.setProps({effects: [queryEffect], layers: [contextLayer, selectionLayer]}); +queryEffect.setSelection([-73.99, 40.75], 0.5); +``` + +The caller supplies source bounds in WGS84 degrees and projected bounds in the same +cuSpatial-compatible sinusoidal space selected by `projectionOrigin`. The effect compiles an +adaptive luProj plan once and uses it for both resident rows and mutable selections. This keeps +ingestion and source metadata outside the package. Use +`setSelection`, `setSelectionRadius`, and `getSelection` for the mutable radius query. Set +`viewportId` when a Deck instance has multiple views; otherwise the first viewport is queried. + +Selection centers pass through the same GPU projection kernel as resident points. CPU-derived +viewport corners are conservatively expanded by 20 metres, matching the documented projection +error envelope; set `viewportProjectionPaddingKilometres` to override that expansion. + +`drawCommands` and `inspector` remain public for custom renderers and inspector UIs. Diagnostics +are sampled asynchronously and never gate the GPU-driven render path. Readbacks are enabled when +`onStats` is supplied, or explicitly with `enableDiagnostics`. The effect owns every buffer and +graph it creates; Deck calls `cleanup`, while applications may call `destroy` when an effect is +constructed but never adopted. diff --git a/modules/deck-luspatial/package.json b/modules/deck-luspatial/package.json index 2d43fbf99b..94082097fa 100644 --- a/modules/deck-luspatial/package.json +++ b/modules/deck-luspatial/package.json @@ -13,6 +13,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./query": { + "types": "./dist/query/index.d.ts", + "import": "./dist/query/index.js", + "require": "./dist/query/index.cjs" } }, "files": [ diff --git a/modules/deck-luspatial/src/query/index.ts b/modules/deck-luspatial/src/query/index.ts new file mode 100644 index 0000000000..2083b1bee3 --- /dev/null +++ b/modules/deck-luspatial/src/query/index.ts @@ -0,0 +1,20 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +export type { + LuSpatialGeographicPointQueryCounters, + LuSpatialGeographicPointQueryEffectProps, + LuSpatialGeographicPointQueryOutput, + LuSpatialGeographicPointQueryOutputs, + LuSpatialGeographicPointQueryStats, + LuSpatialGeographicPointSelection +} from './luspatial-geographic-point-query-effect'; +export { + decodeLuSpatialGeographicPointQueryCounters, + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS, + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS, + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_GRAPH_IDS, + LuSpatialGeographicPointQueryEffect, + makeLuSpatialGeographicPointQueryInspectorCounters +} from './luspatial-geographic-point-query-effect'; diff --git a/modules/deck-luspatial/src/query/luspatial-geographic-point-query-effect.ts b/modules/deck-luspatial/src/query/luspatial-geographic-point-query-effect.ts new file mode 100644 index 0000000000..49addd8804 --- /dev/null +++ b/modules/deck-luspatial/src/query/luspatial-geographic-point-query-effect.ts @@ -0,0 +1,1013 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +import type {Effect, EffectContext} from '@deck.gl/core'; +import {Buffer, type Device} from '@luma.gl/core'; +import { + type CompiledGPUCommandGraph, + DrawCommandBuffer, + GPUCommandGraph, + GPUCommandGraphInspector, + type GPUCommandGraphInspectorObservation, + type GPUCommandGraphInspectorSnapshot +} from '@luma.gl/experimental'; +import { + GPUGridIndex, + type GPUGridIndexBounds, + type GPUGridIndexSize, + GPUPointSpatialQuery +} from '@luma.gl/experimental/geospatial'; +import { + compileProjectionPlan, + GPUProjection, + type ProjectionBounds, + type ProjectionPlan +} from '@luma.gl/experimental/luproj'; + +const UINT32_BYTE_LENGTH = Uint32Array.BYTES_PER_ELEMENT; +const STORAGE_BUFFER_OFFSET_ALIGNMENT = 256; +const DEFAULT_GRID_SIZE = [256, 256] as const; +const DEFAULT_SELECTION_RADIUS_RANGE_KILOMETRES = [0.001, Number.POSITIVE_INFINITY] as const; +const DEFAULT_VIEWPORT_PROJECTION_PADDING_KILOMETRES = 0.02; +const KILOMETRES_PER_DEGREE = 40_000 / 360; +const DEGREES_TO_RADIANS = Math.PI / 180; + +/** Stable counter IDs recorded on the query graph inspector. */ +export const LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS = { + viewportIntersectedCells: 'viewport-intersected-cells', + viewportCandidates: 'viewport-candidates', + viewportMatches: 'viewport-matches', + selectionIntersectedCells: 'selection-intersected-cells', + selectionCandidates: 'selection-candidates', + selectionMatches: 'selection-matches' +} as const; + +/** Stable graph IDs used by inspector snapshots. */ +export const LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_GRAPH_IDS = { + build: 'luspatial-geographic-point-query-build-graph', + query: 'luspatial-geographic-point-query-graph' +} as const; + +/** Aligned byte offsets used by the optional sparse query-counter readback. */ +export const LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS = { + viewportIntersectedCellCount: 0, + viewportCandidateCount: STORAGE_BUFFER_OFFSET_ALIGNMENT, + selectionIntersectedCellCount: STORAGE_BUFFER_OFFSET_ALIGNMENT * 2, + selectionCandidateCount: STORAGE_BUFFER_OFFSET_ALIGNMENT * 3 +} as const; + +const QUERY_DIAGNOSTIC_BUFFER_BYTE_LENGTH = + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionCandidateCount + + UINT32_BYTE_LENGTH; + +type DestroyableResource = {destroy(): void}; + +/** Exact work and result counts sampled from the two GPU spatial queries. */ +export type LuSpatialGeographicPointQueryCounters = { + viewportIntersectedCellCount: number; + viewportCandidateCount: number; + visiblePointCount: number; + selectionIntersectedCellCount: number; + selectionCandidateCount: number; + selectedPointCount: number; +}; + +/** One immutable telemetry snapshot from the build and frame query graphs. */ +export type LuSpatialGeographicPointQueryStats = LuSpatialGeographicPointQueryCounters & { + residentPointCount: number; + graphNodeCount: number; + buildEncodingMilliseconds: number; + queryEncodingMilliseconds: number; + inspectorSnapshot: GPUCommandGraphInspectorSnapshot; +}; + +/** Geographic center and local radius used by the selection query. */ +export type LuSpatialGeographicPointSelection = { + center: readonly [number, number]; + radiusKilometres: number; +}; + +/** One query result ready to bind to the package's `LuSpatialPointLayer`. */ +export type LuSpatialGeographicPointQueryOutput = { + readonly positions: Buffer; + readonly pointIds: Buffer; + readonly drawCommands: DrawCommandBuffer; + readonly commandIndex: number; +}; + +/** Viewport and local-radius outputs produced by the effect. */ +export type LuSpatialGeographicPointQueryOutputs = { + readonly viewport: LuSpatialGeographicPointQueryOutput; + readonly selection: LuSpatialGeographicPointQueryOutput; +}; + +/** Immutable construction properties for a geographic point-query effect. */ +export type LuSpatialGeographicPointQueryEffectProps = { + /** Stable effect identity for this resident data revision. */ + id?: string; + /** Packed WGS84 longitude/latitude rows in degrees. */ + longitudeLatitudes: Float32Array; + /** Inclusive WGS84 source bounds used to compile the adaptive luProj plan. */ + sourceBounds: ProjectionBounds; + /** Inclusive bounds of the projected rows in local kilometres. */ + projectedBounds: Extract; + /** Longitude/latitude origin used by the cuSpatial-compatible sinusoidal projection. */ + projectionOrigin: readonly [number, number]; + /** Two-dimensional index resolution. Defaults to `[256, 256]`. */ + gridSize?: Extract; + /** Initial local-radius query. Defaults to the projection origin and one kilometre. */ + initialSelection?: LuSpatialGeographicPointSelection; + /** Inclusive radius range enforced by the selection mutators. */ + selectionRadiusRangeKilometres?: readonly [number, number]; + /** Deck viewport ID to query. The first viewport is used when omitted. */ + viewportId?: string; + /** Conservative projected viewport expansion. Defaults to the documented 20 metre envelope. */ + viewportProjectionPaddingKilometres?: number; + /** Receives bounded asynchronous diagnostics without blocking rendering. */ + onStats?: (stats: LuSpatialGeographicPointQueryStats) => void; + /** Enables asynchronous counter and timing readbacks. Defaults to whether `onStats` is set. */ + enableDiagnostics?: boolean; + /** Maximum inspector history retained per graph. Defaults to 120 samples. */ + maxInspectorSamples?: number; +}; + +/** + * Deck effect that projects, indexes, and queries one resident geographic point window on WebGPU. + * + * Construction builds the index once. Each Deck frame updates mutable viewport and selection + * inputs, writes both result counts into indirect draw records, and leaves rendering synchronized + * entirely on the GPU. + */ +export class LuSpatialGeographicPointQueryEffect implements Effect { + readonly id: string; + readonly props: Readonly; + readonly useInPicking = true; + readonly longitudeLatitudes: Buffer; + readonly outputs: LuSpatialGeographicPointQueryOutputs; + readonly drawCommands: DrawCommandBuffer; + readonly inspector: GPUCommandGraphInspector; + + private readonly device: Device; + private readonly pointCount: number; + private readonly projectionOrigin: readonly [number, number]; + private readonly projectionPlan: ProjectionPlan; + private readonly projectionDestinationOrigin: readonly [number, number]; + private readonly projectedBounds: readonly [number, number, number, number]; + private readonly gridSize: readonly [number, number]; + private readonly selectionRadiusRangeKilometres: readonly [number, number]; + private readonly viewportProjectionPaddingKilometres: number; + private readonly viewportId?: string; + private readonly diagnosticsEnabled: boolean; + private readonly projectedPositions: Buffer; + private readonly cellOffsets: Buffer; + private readonly indexRowIndices: Buffer; + private readonly indexCount: Buffer; + private readonly indexOverflow: Buffer; + private readonly viewportIds: Buffer; + private readonly selectedIds: Buffer; + private readonly viewportQuery: Buffer; + private readonly selectionLongitudeLatitude: Buffer; + private readonly selectionQuery: Buffer; + private readonly viewportTotalCount: Buffer; + private readonly selectionTotalCount: Buffer; + private readonly viewportOverflow: Buffer; + private readonly selectionOverflow: Buffer; + private readonly queryDiagnostics: Buffer; + private readonly buildGraph: CompiledGPUCommandGraph; + private readonly queryGraph: CompiledGPUCommandGraph; + private readonly buildGraphObservation: GPUCommandGraphInspectorObservation; + private readonly queryGraphObservation: GPUCommandGraphInspectorObservation; + private readonly onStats?: (stats: LuSpatialGeographicPointQueryStats) => void; + private deck: EffectContext['deck'] | null = null; + private selectionCenter: readonly [number, number]; + private selectionRadiusKilometres: number; + private visiblePointCount = 0; + private selectedPointCount = 0; + private viewportIntersectedCellCount = 0; + private viewportCandidateCount = 0; + private selectionIntersectedCellCount = 0; + private selectionCandidateCount = 0; + private buildEncodingMilliseconds = 0; + private queryEncodingMilliseconds = 0; + private frameIndex = 0; + private countReadPending = false; + private countSampleRequested = true; + private countSampleTimer: ReturnType | null = null; + private queryGeneration = 0; + private queryInputsChanged = true; + private outputsContainQueryResults = false; + private lastViewportBounds: Float32Array | null = null; + private destroyed = false; + private readonly ownedResources: DestroyableResource[] = []; + + constructor(device: Device, props: LuSpatialGeographicPointQueryEffectProps) { + if (device.type !== 'webgpu') { + throw new Error('LuSpatialGeographicPointQueryEffect requires WebGPU'); + } + validateProps(props); + this.device = device; + this.props = props; + this.id = props.id ?? 'luspatial-geographic-point-query-effect'; + this.pointCount = props.longitudeLatitudes.length / 2; + this.projectionOrigin = props.projectionOrigin; + this.projectionPlan = compileProjectionPlan({ + projection: coordinates => + projectLongitudeLatitude([coordinates[0], coordinates[1]], this.projectionOrigin), + bounds: props.sourceBounds, + degree: 2, + tolerance: 0.0005, + maxDepth: 4 + }); + this.projectionDestinationOrigin = this.projectionPlan.destinationOrigin; + this.projectedBounds = [ + props.projectedBounds[0] - this.projectionDestinationOrigin[0], + props.projectedBounds[1] - this.projectionDestinationOrigin[1], + props.projectedBounds[2] - this.projectionDestinationOrigin[0], + props.projectedBounds[3] - this.projectionDestinationOrigin[1] + ]; + this.gridSize = props.gridSize ?? DEFAULT_GRID_SIZE; + this.selectionRadiusRangeKilometres = + props.selectionRadiusRangeKilometres ?? DEFAULT_SELECTION_RADIUS_RANGE_KILOMETRES; + this.selectionCenter = props.initialSelection?.center ?? props.projectionOrigin; + this.selectionRadiusKilometres = clamp( + props.initialSelection?.radiusKilometres ?? 1, + this.selectionRadiusRangeKilometres[0], + this.selectionRadiusRangeKilometres[1] + ); + this.viewportId = props.viewportId; + this.viewportProjectionPaddingKilometres = + props.viewportProjectionPaddingKilometres ?? DEFAULT_VIEWPORT_PROJECTION_PADDING_KILOMETRES; + this.onStats = props.onStats; + this.diagnosticsEnabled = props.enableDiagnostics ?? Boolean(props.onStats); + this.inspector = new GPUCommandGraphInspector({ + maxSamples: props.maxInspectorSamples ?? 120, + getNodeGroup: node => { + if (node.id.includes('project')) return 'projection'; + if (node.id.includes('grid')) return 'index'; + if (node.id.includes('query')) return 'query'; + return undefined; + } + }); + + try { + const cellCount = this.gridSize[0] * this.gridSize[1]; + this.longitudeLatitudes = this.ownResource( + device.createBuffer({ + id: `${this.id}-longitude-latitudes`, + data: props.longitudeLatitudes, + usage: Buffer.STORAGE + }) + ); + this.projectedPositions = this.ownResource( + device.createBuffer({ + id: `${this.id}-projected-positions`, + byteLength: this.pointCount * 2 * Float32Array.BYTES_PER_ELEMENT, + usage: Buffer.STORAGE + }) + ); + this.cellOffsets = this.ownResource( + device.createBuffer({ + id: `${this.id}-cell-offsets`, + byteLength: (cellCount + 1) * UINT32_BYTE_LENGTH, + usage: Buffer.STORAGE | Buffer.COPY_SRC + }) + ); + this.indexRowIndices = this.ownResource( + device.createBuffer({ + id: `${this.id}-index-row-indices`, + byteLength: this.pointCount * UINT32_BYTE_LENGTH, + usage: Buffer.STORAGE | Buffer.COPY_SRC + }) + ); + this.indexCount = this.ownResource(createScalarBuffer(device, `${this.id}-index-count`)); + this.indexOverflow = this.ownResource( + createScalarBuffer(device, `${this.id}-index-overflow`) + ); + this.viewportIds = this.ownResource( + device.createBuffer({ + id: `${this.id}-viewport-ids`, + byteLength: this.pointCount * UINT32_BYTE_LENGTH, + usage: Buffer.STORAGE | Buffer.COPY_SRC + }) + ); + this.selectedIds = this.ownResource( + device.createBuffer({ + id: `${this.id}-selected-ids`, + byteLength: this.pointCount * UINT32_BYTE_LENGTH, + usage: Buffer.STORAGE | Buffer.COPY_SRC + }) + ); + this.viewportQuery = this.ownResource( + device.createBuffer({ + id: `${this.id}-viewport-query`, + byteLength: 4 * Float32Array.BYTES_PER_ELEMENT, + usage: Buffer.STORAGE | Buffer.COPY_DST + }) + ); + this.selectionLongitudeLatitude = this.ownResource( + device.createBuffer({ + id: `${this.id}-selection-longitude-latitude`, + byteLength: 2 * Float32Array.BYTES_PER_ELEMENT, + usage: Buffer.STORAGE | Buffer.COPY_DST + }) + ); + this.selectionQuery = this.ownResource( + device.createBuffer({ + id: `${this.id}-selection-query`, + byteLength: 3 * Float32Array.BYTES_PER_ELEMENT, + usage: Buffer.STORAGE | Buffer.COPY_DST + }) + ); + this.viewportTotalCount = this.ownResource( + createScalarBuffer(device, `${this.id}-viewport-total-count`) + ); + this.selectionTotalCount = this.ownResource( + createScalarBuffer(device, `${this.id}-selection-total-count`) + ); + this.viewportOverflow = this.ownResource( + createScalarBuffer(device, `${this.id}-viewport-overflow`) + ); + this.selectionOverflow = this.ownResource( + createScalarBuffer(device, `${this.id}-selection-overflow`) + ); + this.queryDiagnostics = this.ownResource( + device.createBuffer({ + id: `${this.id}-query-diagnostics`, + byteLength: QUERY_DIAGNOSTIC_BUFFER_BYTE_LENGTH, + usage: Buffer.STORAGE | Buffer.COPY_SRC + }) + ); + this.drawCommands = this.ownResource( + new DrawCommandBuffer(device, { + id: `${this.id}-draw-commands`, + type: 'draw', + capacity: 2, + commands: [ + {vertexCount: 6, instanceCount: 0}, + {vertexCount: 6, instanceCount: 0} + ] + }) + ); + this.outputs = { + viewport: { + positions: this.longitudeLatitudes, + pointIds: this.viewportIds, + drawCommands: this.drawCommands, + commandIndex: 0 + }, + selection: { + positions: this.longitudeLatitudes, + pointIds: this.selectedIds, + drawCommands: this.drawCommands, + commandIndex: 1 + } + }; + + this.buildGraph = this.ownResource(this.createBuildGraph()); + this.queryGraph = this.ownResource(this.createQueryGraph()); + this.buildGraphObservation = this.ownObservation( + this.inspector.observeGraph(this.buildGraph) + ); + this.queryGraphObservation = this.ownObservation( + this.inspector.observeGraph(this.queryGraph) + ); + const commandEncoder = device.createCommandEncoder({id: `${this.id}-index-build`}); + const encoding = this.buildGraphObservation.encode(commandEncoder, {parameters: undefined}); + this.buildEncodingMilliseconds = encoding.stats.cpuEncodeTimeMilliseconds; + device.submit(commandEncoder.finish()); + this.publishStats(); + if (this.diagnosticsEnabled && encoding.canReadGPUTimings) { + setTimeout(() => { + if (!this.destroyed) { + void this.buildGraphObservation.recordGPUTimings(encoding); + } + }, 0); + } + } catch (error) { + this.destroyOwnedResources(); + throw error; + } + } + + setup(context: EffectContext): void { + if (context.device !== this.device) { + throw new Error(`${this.id} must be adopted by the device used during construction`); + } + this.deck = context.deck; + } + + /** Moves the local radius query and optionally replaces its radius. */ + setSelection(center: readonly [number, number], radiusKilometres?: number): void { + validateLongitudeLatitude(center, `${this.id} selection center`); + this.selectionCenter = center; + if (radiusKilometres !== undefined) { + if (!Number.isFinite(radiusKilometres)) { + throw new Error(`${this.id} selection radius must be finite`); + } + this.selectionRadiusKilometres = clamp( + radiusKilometres, + this.selectionRadiusRangeKilometres[0], + this.selectionRadiusRangeKilometres[1] + ); + } + this.queryInputsChanged = true; + this.deck?.redraw(`${this.id} selection changed`); + } + + /** Replaces the local query radius within the configured inclusive range. */ + setSelectionRadius(radiusKilometres: number): void { + if (!Number.isFinite(radiusKilometres)) { + throw new Error(`${this.id} selection radius must be finite`); + } + this.selectionRadiusKilometres = clamp( + radiusKilometres, + this.selectionRadiusRangeKilometres[0], + this.selectionRadiusRangeKilometres[1] + ); + this.queryInputsChanged = true; + this.deck?.redraw(`${this.id} selection radius changed`); + } + + /** Returns the current geographic center and clamped local radius. */ + getSelection(): LuSpatialGeographicPointSelection { + return { + center: this.selectionCenter, + radiusKilometres: this.selectionRadiusKilometres + }; + } + + preRender(options: Parameters[0]): void { + if (this.destroyed) return; + const viewport = this.viewportId + ? options.viewports.find(candidate => candidate.id === this.viewportId) + : options.viewports[0]; + if (!viewport || viewport.width <= 0 || viewport.height <= 0) { + this.clearQueryOutputs(); + return; + } + + const projectedCorners = [ + viewport.unproject([0, 0]), + viewport.unproject([viewport.width, 0]), + viewport.unproject([0, viewport.height]), + viewport.unproject([viewport.width, viewport.height]) + ].map(coordinate => { + const projected = projectLongitudeLatitude( + [coordinate[0], coordinate[1]], + this.projectionOrigin + ); + return [ + projected[0] - this.projectionDestinationOrigin[0], + projected[1] - this.projectionDestinationOrigin[1] + ] as const; + }); + const viewportBounds = new Float32Array([ + Math.min(...projectedCorners.map(coordinate => coordinate[0])) - + this.viewportProjectionPaddingKilometres, + Math.min(...projectedCorners.map(coordinate => coordinate[1])) - + this.viewportProjectionPaddingKilometres, + Math.max(...projectedCorners.map(coordinate => coordinate[0])) + + this.viewportProjectionPaddingKilometres, + Math.max(...projectedCorners.map(coordinate => coordinate[1])) + + this.viewportProjectionPaddingKilometres + ]); + const viewportBoundsChanged = + !this.lastViewportBounds || + viewportBounds.some((value, index) => value !== this.lastViewportBounds?.[index]); + if (!viewportBoundsChanged && !this.queryInputsChanged) return; + + this.lastViewportBounds = viewportBounds; + const queryChanged = viewportBoundsChanged || this.queryInputsChanged; + this.viewportQuery.write(viewportBounds); + this.selectionLongitudeLatitude.write(new Float32Array(this.selectionCenter)); + this.selectionQuery.write( + new Float32Array([this.selectionRadiusKilometres]), + 2 * Float32Array.BYTES_PER_ELEMENT + ); + + const encoding = this.queryGraphObservation.encode(this.device.commandEncoder, { + parameters: undefined + }); + this.outputsContainQueryResults = true; + this.queryEncodingMilliseconds = encoding.stats.cpuEncodeTimeMilliseconds; + this.frameIndex++; + if (queryChanged) { + this.queryGeneration++; + this.queryInputsChanged = false; + if (this.diagnosticsEnabled) this.scheduleCountSample(80); + } + if (this.diagnosticsEnabled && (this.frameIndex === 1 || this.frameIndex % 30 === 0)) { + this.scheduleCountSample(0); + if (encoding.canReadGPUTimings) { + setTimeout(() => { + if (!this.destroyed) { + void this.queryGraphObservation.recordGPUTimings(encoding); + } + }, 0); + } + } + if (this.diagnosticsEnabled && (this.frameIndex === 1 || this.frameIndex % 15 === 0)) { + this.publishStats(); + } + } + + cleanup(_context: EffectContext): void { + this.deck = null; + this.destroy(); + } + + /** Releases GPU resources when a newly constructed effect cannot be adopted by deck.gl. */ + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + this.deck = null; + if (this.countSampleTimer !== null) clearTimeout(this.countSampleTimer); + this.destroyOwnedResources(); + } + + private ownResource(resource: T): T { + this.ownedResources.push(resource); + return resource; + } + + private ownObservation void}>(observation: T): T { + this.ownedResources.push({destroy: () => observation.detach()}); + return observation; + } + + private destroyOwnedResources(): void { + for (let index = this.ownedResources.length - 1; index >= 0; index--) { + try { + this.ownedResources[index]?.destroy(); + } catch { + // Continue releasing the remaining resources after device loss or partial construction. + } + } + this.ownedResources.length = 0; + } + + private clearQueryOutputs(): void { + this.lastViewportBounds = null; + this.queryGeneration++; + this.queryInputsChanged = true; + if (!this.outputsContainQueryResults) return; + const zero = new Uint32Array(1); + this.drawCommands.buffer.write(zero, this.drawCommands.getInstanceCountByteOffset(0)); + this.drawCommands.buffer.write(zero, this.drawCommands.getInstanceCountByteOffset(1)); + this.outputsContainQueryResults = false; + this.viewportIntersectedCellCount = 0; + this.viewportCandidateCount = 0; + this.visiblePointCount = 0; + this.selectionIntersectedCellCount = 0; + this.selectionCandidateCount = 0; + this.selectedPointCount = 0; + this.queryGraphObservation.recordCounters( + makeLuSpatialGeographicPointQueryInspectorCounters({ + viewportIntersectedCellCount: 0, + viewportCandidateCount: 0, + visiblePointCount: 0, + selectionIntersectedCellCount: 0, + selectionCandidateCount: 0, + selectedPointCount: 0 + }) + ); + this.publishStats(); + } + + private createBuildGraph(): CompiledGPUCommandGraph { + const graph = new GPUCommandGraph(this.device, { + id: LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_GRAPH_IDS.build + }); + const sourceBuffer = importBuffer(graph, 'longitude-latitudes', this.longitudeLatitudes); + const projectedBuffer = importBuffer(graph, 'projected-positions', this.projectedPositions); + const cellOffsetsBuffer = importBuffer(graph, 'cell-offsets', this.cellOffsets); + const rowIndicesBuffer = importBuffer(graph, 'index-row-indices', this.indexRowIndices); + const countBuffer = importBuffer(graph, 'index-count', this.indexCount); + const overflowBuffer = importBuffer(graph, 'index-overflow', this.indexOverflow); + const source = graph.createDataView(sourceBuffer, { + format: 'float32x2', + length: this.pointCount + }); + const projected = graph.createDataView(projectedBuffer, { + format: 'float32x2', + length: this.pointCount + }); + const cellOffsets = graph.createDataView(cellOffsetsBuffer, { + format: 'uint32', + length: this.gridSize[0] * this.gridSize[1] + 1 + }); + const rowIndices = graph.createDataView(rowIndicesBuffer, { + format: 'uint32', + length: this.pointCount + }); + const count = graph.createDataView(countBuffer, {format: 'uint32', length: 1}); + const overflow = graph.createDataView(overflowBuffer, {format: 'uint32', length: 1}); + + const projection = this.ownResource( + new GPUProjection({ + id: `${this.id}-project`, + positions: source, + output: projected, + plan: this.projectionPlan + }) + ); + projection.addToGraph(graph); + new GPUGridIndex({ + id: `${this.id}-grid`, + positions: projected, + gridSize: this.gridSize, + bounds: this.projectedBounds, + cellOffsets, + objectIds: rowIndices, + count, + overflow + }).addToGraph(graph); + return graph.compile(); + } + + private createQueryGraph(): CompiledGPUCommandGraph { + const graph = new GPUCommandGraph(this.device, { + id: LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_GRAPH_IDS.query + }); + const projectedBuffer = importBuffer(graph, 'projected-positions', this.projectedPositions); + const cellOffsetsBuffer = importBuffer(graph, 'cell-offsets', this.cellOffsets); + const rowIndicesBuffer = importBuffer(graph, 'index-row-indices', this.indexRowIndices); + const indexCountBuffer = importBuffer(graph, 'index-count', this.indexCount); + const indexOverflowBuffer = importBuffer(graph, 'index-overflow', this.indexOverflow); + const viewportIdsBuffer = importBuffer(graph, 'viewport-ids', this.viewportIds); + const selectedIdsBuffer = importBuffer(graph, 'selected-ids', this.selectedIds); + const viewportQueryBuffer = importBuffer(graph, 'viewport-query', this.viewportQuery); + const selectionQueryBuffer = importBuffer(graph, 'selection-query', this.selectionQuery); + const selectionLongitudeLatitudeBuffer = importBuffer( + graph, + 'selection-longitude-latitude', + this.selectionLongitudeLatitude + ); + const viewportTotalCountBuffer = importBuffer( + graph, + 'viewport-total-count', + this.viewportTotalCount + ); + const selectionTotalCountBuffer = importBuffer( + graph, + 'selection-total-count', + this.selectionTotalCount + ); + const viewportOverflowBuffer = importBuffer(graph, 'viewport-overflow', this.viewportOverflow); + const selectionOverflowBuffer = importBuffer( + graph, + 'selection-overflow', + this.selectionOverflow + ); + const queryDiagnosticsBuffer = importBuffer(graph, 'query-diagnostics', this.queryDiagnostics); + const drawCommandBuffer = importBuffer(graph, 'draw-commands', this.drawCommands.buffer); + + const selectionProjection = this.ownResource( + new GPUProjection({ + id: `${this.id}-selection-project`, + positions: graph.createDataView(selectionLongitudeLatitudeBuffer, { + format: 'float32x2', + length: 1 + }), + output: graph.createDataView(selectionQueryBuffer, { + format: 'float32x2', + length: 1 + }), + plan: this.projectionPlan + }) + ); + selectionProjection.addToGraph(graph); + + const positions = graph.createDataView(projectedBuffer, { + format: 'float32x2', + length: this.pointCount + }); + const cellOffsets = graph.createDataView(cellOffsetsBuffer, { + format: 'uint32', + length: this.gridSize[0] * this.gridSize[1] + 1 + }); + const rowIndices = graph.createDataView(rowIndicesBuffer, { + format: 'uint32', + length: this.pointCount + }); + const index = { + gridSize: this.gridSize, + bounds: this.projectedBounds, + cellOffsets, + rowIndices, + count: graph.createDataView(indexCountBuffer, {format: 'uint32', length: 1}), + overflow: graph.createDataView(indexOverflowBuffer, {format: 'uint32', length: 1}) + }; + const viewportIntersectedCellCount = graph.createDataView(queryDiagnosticsBuffer, { + format: 'uint32', + length: 1, + byteOffset: + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS.viewportIntersectedCellCount + }); + const viewportCandidateCount = graph.createDataView(queryDiagnosticsBuffer, { + format: 'uint32', + length: 1, + byteOffset: LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS.viewportCandidateCount + }); + const selectionIntersectedCellCount = graph.createDataView(queryDiagnosticsBuffer, { + format: 'uint32', + length: 1, + byteOffset: + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionIntersectedCellCount + }); + const selectionCandidateCount = graph.createDataView(queryDiagnosticsBuffer, { + format: 'uint32', + length: 1, + byteOffset: LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionCandidateCount + }); + + new GPUPointSpatialQuery({ + id: `${this.id}-viewport-query`, + positions, + index, + kind: 'bounds', + query: graph.createDataView(viewportQueryBuffer, {format: 'float32', length: 4}), + intersectedCellCount: viewportIntersectedCellCount, + candidateCount: viewportCandidateCount, + output: { + ids: graph.createDataView(viewportIdsBuffer, { + format: 'uint32', + length: this.pointCount + }), + count: graph.createDataView(drawCommandBuffer, { + format: 'uint32', + length: 1, + byteOffset: this.drawCommands.getInstanceCountByteOffset(0) + }), + overflow: graph.createDataView(viewportOverflowBuffer, {format: 'uint32', length: 1}), + totalCount: graph.createDataView(viewportTotalCountBuffer, { + format: 'uint32', + length: 1 + }) + } + }).addToGraph(graph); + + new GPUPointSpatialQuery({ + id: `${this.id}-radius-query`, + positions, + index, + kind: 'radius', + query: graph.createDataView(selectionQueryBuffer, {format: 'float32', length: 3}), + intersectedCellCount: selectionIntersectedCellCount, + candidateCount: selectionCandidateCount, + output: { + ids: graph.createDataView(selectedIdsBuffer, { + format: 'uint32', + length: this.pointCount + }), + count: graph.createDataView(drawCommandBuffer, { + format: 'uint32', + length: 1, + byteOffset: this.drawCommands.getInstanceCountByteOffset(1) + }), + overflow: graph.createDataView(selectionOverflowBuffer, {format: 'uint32', length: 1}), + totalCount: graph.createDataView(selectionTotalCountBuffer, { + format: 'uint32', + length: 1 + }) + } + }).addToGraph(graph); + return graph.compile(); + } + + private async sampleCounts(): Promise { + if (this.destroyed) return; + if (this.countReadPending) { + this.countSampleRequested = true; + return; + } + this.countReadPending = true; + const queryGeneration = this.queryGeneration; + try { + const [drawCommandBytes, queryDiagnosticBytes] = await Promise.all([ + this.drawCommands.buffer.readAsync(), + this.queryDiagnostics.readAsync() + ]); + if (this.destroyed || queryGeneration !== this.queryGeneration) return; + const counters = decodeLuSpatialGeographicPointQueryCounters( + drawCommandBytes, + queryDiagnosticBytes, + { + viewportInstanceCountByteOffset: this.drawCommands.getInstanceCountByteOffset(0), + selectionInstanceCountByteOffset: this.drawCommands.getInstanceCountByteOffset(1) + } + ); + this.viewportIntersectedCellCount = counters.viewportIntersectedCellCount; + this.viewportCandidateCount = counters.viewportCandidateCount; + this.visiblePointCount = counters.visiblePointCount; + this.selectionIntersectedCellCount = counters.selectionIntersectedCellCount; + this.selectionCandidateCount = counters.selectionCandidateCount; + this.selectedPointCount = counters.selectedPointCount; + this.queryGraphObservation.recordCounters( + makeLuSpatialGeographicPointQueryInspectorCounters(counters) + ); + this.publishStats(); + } catch { + // Device loss or teardown can reject optional diagnostics after the render path has ended. + // Rendering stays entirely GPU-driven, so the next requested sample can retry safely. + } finally { + this.countReadPending = false; + if (this.countSampleRequested && !this.destroyed) { + this.countSampleRequested = false; + this.scheduleCountSample(0); + } + } + } + + private scheduleCountSample(delayMilliseconds: number): void { + if (this.destroyed) return; + this.countSampleRequested = false; + if (this.countSampleTimer !== null) clearTimeout(this.countSampleTimer); + this.countSampleTimer = setTimeout(() => { + this.countSampleTimer = null; + void this.sampleCounts(); + }, delayMilliseconds); + } + + private publishStats(): void { + this.onStats?.({ + residentPointCount: this.pointCount, + viewportIntersectedCellCount: this.viewportIntersectedCellCount, + viewportCandidateCount: this.viewportCandidateCount, + visiblePointCount: this.visiblePointCount, + selectionIntersectedCellCount: this.selectionIntersectedCellCount, + selectionCandidateCount: this.selectionCandidateCount, + selectedPointCount: this.selectedPointCount, + graphNodeCount: + this.buildGraph.stats.nodeOrder.length + this.queryGraph.stats.nodeOrder.length, + buildEncodingMilliseconds: this.buildEncodingMilliseconds, + queryEncodingMilliseconds: this.queryEncodingMilliseconds, + inspectorSnapshot: this.inspector.getSnapshot() + }); + } +} + +/** Decodes one pair of sparse GPU query-counter and indirect-draw readbacks. */ +export function decodeLuSpatialGeographicPointQueryCounters( + drawCommandBytes: Uint8Array, + queryDiagnosticBytes: Uint8Array, + drawCommandLayout: { + viewportInstanceCountByteOffset: number; + selectionInstanceCountByteOffset: number; + } +): LuSpatialGeographicPointQueryCounters { + return { + viewportIntersectedCellCount: readUint32AtByteOffset( + queryDiagnosticBytes, + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS.viewportIntersectedCellCount + ), + viewportCandidateCount: readUint32AtByteOffset( + queryDiagnosticBytes, + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS.viewportCandidateCount + ), + visiblePointCount: readUint32AtByteOffset( + drawCommandBytes, + drawCommandLayout.viewportInstanceCountByteOffset + ), + selectionIntersectedCellCount: readUint32AtByteOffset( + queryDiagnosticBytes, + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionIntersectedCellCount + ), + selectionCandidateCount: readUint32AtByteOffset( + queryDiagnosticBytes, + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionCandidateCount + ), + selectedPointCount: readUint32AtByteOffset( + drawCommandBytes, + drawCommandLayout.selectionInstanceCountByteOffset + ) + }; +} + +/** Maps an exact query sample onto stable inspector counter identifiers. */ +export function makeLuSpatialGeographicPointQueryInspectorCounters( + counters: LuSpatialGeographicPointQueryCounters +): Readonly> { + return { + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.viewportIntersectedCells]: + counters.viewportIntersectedCellCount, + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.viewportCandidates]: + counters.viewportCandidateCount, + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.viewportMatches]: counters.visiblePointCount, + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.selectionIntersectedCells]: + counters.selectionIntersectedCellCount, + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.selectionCandidates]: + counters.selectionCandidateCount, + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.selectionMatches]: counters.selectedPointCount + }; +} + +function readUint32AtByteOffset(bytes: Uint8Array, byteOffset: number): number { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(byteOffset, true); +} + +function createScalarBuffer(device: Device, id: string): Buffer { + return device.createBuffer({ + id, + byteLength: UINT32_BYTE_LENGTH, + usage: Buffer.STORAGE | Buffer.COPY_SRC + }); +} + +function importBuffer(graph: GPUCommandGraph, id: string, buffer: Buffer) { + return graph.importBuffer({id, byteLength: buffer.byteLength, usage: buffer.usage}, buffer); +} + +function projectLongitudeLatitude( + longitudeLatitude: readonly [number, number], + origin: readonly [number, number] +): readonly [number, number] { + const [longitude, latitude] = longitudeLatitude; + const midpointLatitudeRadians = (latitude + origin[1]) * 0.5 * DEGREES_TO_RADIANS; + return [ + (origin[0] - longitude) * KILOMETRES_PER_DEGREE * Math.cos(midpointLatitudeRadians), + (origin[1] - latitude) * KILOMETRES_PER_DEGREE + ]; +} + +function validateProps(props: LuSpatialGeographicPointQueryEffectProps): void { + if (props.longitudeLatitudes.length === 0 || props.longitudeLatitudes.length % 2 !== 0) { + throw new Error('longitudeLatitudes must contain one or more packed coordinate pairs'); + } + validateLongitudeLatitude(props.projectionOrigin, 'projectionOrigin'); + validateLongitudeLatitude([props.sourceBounds[0], props.sourceBounds[1]], 'sourceBounds minimum'); + validateLongitudeLatitude([props.sourceBounds[2], props.sourceBounds[3]], 'sourceBounds maximum'); + if ( + props.sourceBounds[0] >= props.sourceBounds[2] || + props.sourceBounds[1] >= props.sourceBounds[3] + ) { + throw new Error('sourceBounds minima must be less than maxima'); + } + if (props.projectedBounds.some(value => !Number.isFinite(value))) { + throw new Error('projectedBounds must contain finite values'); + } + if ( + props.projectedBounds[0] >= props.projectedBounds[2] || + props.projectedBounds[1] >= props.projectedBounds[3] + ) { + throw new Error('projectedBounds minima must be less than maxima'); + } + const gridSize = props.gridSize ?? DEFAULT_GRID_SIZE; + if (gridSize.some(value => !Number.isSafeInteger(value) || value <= 0)) { + throw new Error('gridSize must contain positive safe integers'); + } + if (!Number.isSafeInteger(gridSize[0] * gridSize[1])) { + throw new Error('gridSize cell count must be a safe integer'); + } + const radiusRange = + props.selectionRadiusRangeKilometres ?? DEFAULT_SELECTION_RADIUS_RANGE_KILOMETRES; + if ( + !Number.isFinite(radiusRange[0]) || + radiusRange[0] <= 0 || + Number.isNaN(radiusRange[1]) || + radiusRange[1] < radiusRange[0] + ) { + throw new Error('selectionRadiusRangeKilometres must be a positive ordered range'); + } + if (props.initialSelection) { + validateLongitudeLatitude(props.initialSelection.center, 'initialSelection center'); + if (!Number.isFinite(props.initialSelection.radiusKilometres)) { + throw new Error('initialSelection radius must be finite'); + } + } + if ( + props.viewportProjectionPaddingKilometres !== undefined && + (!Number.isFinite(props.viewportProjectionPaddingKilometres) || + props.viewportProjectionPaddingKilometres < 0) + ) { + throw new Error('viewportProjectionPaddingKilometres must be a non-negative finite number'); + } + if ( + props.maxInspectorSamples !== undefined && + (!Number.isSafeInteger(props.maxInspectorSamples) || props.maxInspectorSamples <= 0) + ) { + throw new Error('maxInspectorSamples must be a positive safe integer'); + } +} + +function validateLongitudeLatitude( + longitudeLatitude: readonly [number, number], + label: string +): void { + const [longitude, latitude] = longitudeLatitude; + if ( + !Number.isFinite(longitude) || + longitude < -180 || + longitude > 180 || + !Number.isFinite(latitude) || + latitude < -90 || + latitude > 90 + ) { + throw new Error(`${label} must be valid longitude/latitude degrees`); + } +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} diff --git a/modules/deck-luspatial/test/index.ts b/modules/deck-luspatial/test/index.ts index 9bfa2bbec4..3ba678afc5 100644 --- a/modules/deck-luspatial/test/index.ts +++ b/modules/deck-luspatial/test/index.ts @@ -3,3 +3,4 @@ // SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import './luspatial-point-layer.node.spec'; +import './luspatial-geographic-point-query-effect.node.spec'; diff --git a/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.node.spec.ts b/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.node.spec.ts new file mode 100644 index 0000000000..b45b63d32b --- /dev/null +++ b/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.node.spec.ts @@ -0,0 +1,150 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +import {readFileSync} from 'node:fs'; +import { + decodeLuSpatialGeographicPointQueryCounters, + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS, + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS, + LuSpatialGeographicPointQueryEffect, + makeLuSpatialGeographicPointQueryInspectorCounters +} from '@deck.gl-community/luspatial/query'; +import type {Device} from '@luma.gl/core'; +import {describe, expect, test} from 'vitest'; + +const TEST_QUERY_PROPS = { + longitudeLatitudes: new Float32Array([-73.99, 40.73]), + sourceBounds: [-74, 40.72, -73.94, 40.78] as const, + projectedBounds: [-1, -1, 1, 1] as const, + projectionOrigin: [-73.97, 40.75] as const +}; + +describe('LuSpatialGeographicPointQueryEffect resource safety', () => { + test('releases every completed effect allocation after a mid-construction failure', () => { + const destroyedResourceIds: string[] = []; + let bufferCreateCount = 0; + const device = { + type: 'webgpu', + createBuffer: ({id}: {id?: string}) => { + bufferCreateCount++; + if (bufferCreateCount === 3) throw new Error('injected buffer allocation failure'); + const resourceId = id ?? `buffer-${bufferCreateCount}`; + return { + destroy: () => destroyedResourceIds.push(resourceId) + }; + } + } as unknown as Device; + expect(() => new LuSpatialGeographicPointQueryEffect(device, TEST_QUERY_PROPS)).toThrow( + 'injected buffer allocation failure' + ); + expect(destroyedResourceIds).toEqual([ + 'luspatial-geographic-point-query-effect-projected-positions', + 'luspatial-geographic-point-query-effect-longitude-latitudes' + ]); + }); + + test('rejects a negative viewport projection pad before allocating resources', () => { + expect( + () => + new LuSpatialGeographicPointQueryEffect({type: 'webgpu'} as Device, { + ...TEST_QUERY_PROPS, + viewportProjectionPaddingKilometres: -1 + }) + ).toThrow('viewportProjectionPaddingKilometres must be a non-negative finite number'); + }); +}); + +describe('luSpatial geographic point query telemetry', () => { + test('decodes aligned GPU diagnostics and publishes a complete inspector sample', () => { + expect(Object.values(LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS)).toEqual([ + 0, 256, 512, 768 + ]); + const drawCommandBytes = new Uint8Array(80).subarray(8, 72); + const viewportInstanceCountByteOffset = 20; + const selectionInstanceCountByteOffset = 52; + const drawCommandView = new DataView( + drawCommandBytes.buffer, + drawCommandBytes.byteOffset, + drawCommandBytes.byteLength + ); + drawCommandView.setUint32(viewportInstanceCountByteOffset, 41, true); + drawCommandView.setUint32(selectionInstanceCountByteOffset, 7, true); + + const queryDiagnosticByteLength = + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionCandidateCount + + Uint32Array.BYTES_PER_ELEMENT; + const queryDiagnosticBytes = new Uint8Array(queryDiagnosticByteLength + 12).subarray( + 4, + 4 + queryDiagnosticByteLength + ); + const queryDiagnosticView = new DataView( + queryDiagnosticBytes.buffer, + queryDiagnosticBytes.byteOffset, + queryDiagnosticBytes.byteLength + ); + queryDiagnosticView.setUint32( + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS.viewportIntersectedCellCount, + 12, + true + ); + queryDiagnosticView.setUint32( + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS.viewportCandidateCount, + 83, + true + ); + queryDiagnosticView.setUint32( + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionIntersectedCellCount, + 3, + true + ); + queryDiagnosticView.setUint32( + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionCandidateCount, + 19, + true + ); + + const counters = decodeLuSpatialGeographicPointQueryCounters( + drawCommandBytes, + queryDiagnosticBytes, + { + viewportInstanceCountByteOffset, + selectionInstanceCountByteOffset + } + ); + expect(counters).toEqual({ + viewportIntersectedCellCount: 12, + viewportCandidateCount: 83, + visiblePointCount: 41, + selectionIntersectedCellCount: 3, + selectionCandidateCount: 19, + selectedPointCount: 7 + }); + expect(makeLuSpatialGeographicPointQueryInspectorCounters(counters)).toEqual({ + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.viewportIntersectedCells]: 12, + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.viewportCandidates]: 83, + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.viewportMatches]: 41, + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.selectionIntersectedCells]: 3, + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.selectionCandidates]: 19, + [LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_COUNTER_IDS.selectionMatches]: 7 + }); + }); +}); + +describe('@deck.gl-community/luspatial/query package boundary', () => { + test('publishes ESM, CJS, and declarations without expanding the root export', () => { + const packageJson = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') + ) as { + exports?: Record; + }; + expect(packageJson.exports?.['./query']).toEqual({ + types: './dist/query/index.d.ts', + import: './dist/query/index.js', + require: './dist/query/index.cjs' + }); + expect(readFileSync(new URL('../src/index.ts', import.meta.url), 'utf8')).not.toContain( + 'LuSpatialGeographicPointQueryEffect' + ); + }); +}); diff --git a/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts b/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts new file mode 100644 index 0000000000..6dde039acb --- /dev/null +++ b/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts @@ -0,0 +1,134 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +import type {Effect, EffectContext} from '@deck.gl/core'; +import {LuSpatialGeographicPointQueryEffect} from '@deck.gl-community/luspatial/query'; +import {getWebGPUTestDevice} from '@luma.gl/test-utils'; +import {expect, test} from 'vitest'; + +test('LuSpatialGeographicPointQueryEffect builds and updates real WebGPU queries', async () => { + const device = await getWebGPUTestDevice(); + if (!device) return; + + const effect = new LuSpatialGeographicPointQueryEffect(device, { + id: 'luspatial-geographic-query-browser-test', + longitudeLatitudes: new Float32Array([ + -73.97, 40.75, -73.971, 40.75, -73.99, 40.75, -74.2, 40.75 + ]), + sourceBounds: [-74.21, 40.74, -73.73, 40.76], + projectionOrigin: [-73.97, 40.75], + projectedBounds: [-3, -3, 3, 3], + gridSize: [4, 4], + initialSelection: {center: [-73.97, 40.75], radiusKilometres: 0.2}, + selectionRadiusRangeKilometres: [0.05, 1] + }); + const redrawReasons: string[] = []; + expect(() => + effect.setup({ + device: {...device}, + deck: {redraw: () => undefined} + } as unknown as EffectContext) + ).toThrow(/must be adopted by the device used during construction/); + const effectContext = { + device, + deck: {redraw: (reason: string) => redrawReasons.push(reason)} + } as unknown as EffectContext; + effect.setup(effectContext); + + try { + effect.preRender({ + viewports: [ + { + id: 'map', + width: 100, + height: 100, + unproject: ([x, y]: number[]) => [-74 + (x / 100) * 0.06, 40.77 - (y / 100) * 0.04] + } + ] + } as unknown as Parameters>[0]); + device.submit(); + + const drawCommandBytes = await effect.drawCommands.buffer.readAsync(); + const drawCommandView = new DataView( + drawCommandBytes.buffer, + drawCommandBytes.byteOffset, + drawCommandBytes.byteLength + ); + const viewportCount = drawCommandView.getUint32( + effect.drawCommands.getInstanceCountByteOffset(effect.outputs.viewport.commandIndex), + true + ); + const selectionCount = drawCommandView.getUint32( + effect.drawCommands.getInstanceCountByteOffset(effect.outputs.selection.commandIndex), + true + ); + expect(viewportCount, 'viewport output contains the three indexed visible rows').toBe(3); + expect(selectionCount, 'selection output applies the local radius').toBe(2); + + effect.setSelection([-73.99, 40.75], 0.1); + expect(redrawReasons.at(-1), 'selection mutation asks Deck to schedule another frame').toMatch( + /selection changed/ + ); + expect(effect.getSelection(), 'selection mutators expose the current geographic query').toEqual( + {center: [-73.99, 40.75], radiusKilometres: 0.1} + ); + effect.preRender({ + viewports: [ + { + id: 'map', + width: 100, + height: 100, + unproject: ([x, y]: number[]) => [-74 + (x / 100) * 0.06, 40.77 - (y / 100) * 0.04] + } + ] + } as unknown as Parameters>[0]); + device.submit(); + const updatedDrawCommandBytes = await effect.drawCommands.buffer.readAsync(); + const updatedDrawCommandView = new DataView( + updatedDrawCommandBytes.buffer, + updatedDrawCommandBytes.byteOffset, + updatedDrawCommandBytes.byteLength + ); + const updatedSelectionCount = updatedDrawCommandView.getUint32( + effect.drawCommands.getInstanceCountByteOffset(effect.outputs.selection.commandIndex), + true + ); + const updatedSelectionIdBytes = await effect.outputs.selection.pointIds.readAsync( + 0, + updatedSelectionCount * 4 + ); + const updatedSelectionIds = new Uint32Array( + updatedSelectionIdBytes.buffer, + updatedSelectionIdBytes.byteOffset, + updatedSelectionIdBytes.byteLength / Uint32Array.BYTES_PER_ELEMENT + ); + expect(updatedSelectionCount, 'the mutable selection reruns without rebuilding').toBe(1); + expect(Array.from(updatedSelectionIds), 'the second selection targets row 2').toEqual([2]); + + effect.preRender({viewports: []} as unknown as Parameters>[0]); + const clearedDrawCommandBytes = await effect.drawCommands.buffer.readAsync(); + const clearedDrawCommandView = new DataView( + clearedDrawCommandBytes.buffer, + clearedDrawCommandBytes.byteOffset, + clearedDrawCommandBytes.byteLength + ); + expect( + clearedDrawCommandView.getUint32( + effect.drawCommands.getInstanceCountByteOffset(effect.outputs.viewport.commandIndex), + true + ), + 'a missing viewport clears the stale viewport draw count' + ).toBe(0); + expect( + clearedDrawCommandView.getUint32( + effect.drawCommands.getInstanceCountByteOffset(effect.outputs.selection.commandIndex), + true + ), + 'a missing viewport clears the stale selection draw count' + ).toBe(0); + } finally { + effect.cleanup(effectContext); + effect.destroy(); + } +}, 60_000); diff --git a/test/examples/luspatial-taxi-runtime.node.spec.ts b/test/examples/luspatial-taxi-runtime.node.spec.ts deleted file mode 100644 index 2ee45b0d92..0000000000 --- a/test/examples/luspatial-taxi-runtime.node.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -// luma.gl -// SPDX-License-Identifier: MIT -// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors - -import type {Device} from '@luma.gl/core'; -import {describe, expect, test} from 'vitest'; -import { - LU_SPATIAL_TAXI_QUERY_COUNTER_IDS, - LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS, - LuSpatialTaxiQueryEffect, - decodeLuSpatialTaxiQueryCounters, - makeLuSpatialTaxiQueryInspectorCounters -} from '../../examples/deck/luspatial-taxi/luspatial-query-effect'; -import type {LuSpatialTaxiData} from '../../examples/deck/luspatial-taxi/taxi-data'; - -describe('luSpatial taxi query-effect resource safety', () => { - test('releases every completed effect allocation after a mid-construction failure', () => { - const destroyedResourceIds: string[] = []; - let bufferCreateCount = 0; - const device = { - type: 'webgpu', - createBuffer: ({id}: {id?: string}) => { - bufferCreateCount++; - if (bufferCreateCount === 3) throw new Error('injected buffer allocation failure'); - const resourceId = id ?? `buffer-${bufferCreateCount}`; - return { - destroy: () => destroyedResourceIds.push(resourceId) - }; - } - } as unknown as Device; - const taxiData: LuSpatialTaxiData = { - pointCount: 1, - corpusPointCount: 1, - longitudeLatitudes: new Float32Array([-73.99, 40.73]), - sourceBounds: [-74, 40.72, -73.94, 40.78], - projectedBounds: [-1, -1, 1, 1], - sourceKind: 'synthetic', - sourceLabel: 'test fixture' - }; - - expect(() => new LuSpatialTaxiQueryEffect(device, taxiData)).toThrow( - 'injected buffer allocation failure' - ); - expect(destroyedResourceIds).toEqual([ - 'luspatial-taxi-projected-positions', - 'luspatial-taxi-longitude-latitudes' - ]); - }); -}); - -describe('luSpatial taxi query telemetry', () => { - test('decodes aligned GPU diagnostics and publishes a complete inspector sample', () => { - expect(Object.values(LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS)).toEqual([ - 0, 256, 512, 768 - ]); - const drawCommandBytes = new Uint8Array(80).subarray(8, 72); - const viewportInstanceCountByteOffset = 20; - const selectionInstanceCountByteOffset = 52; - const drawCommandView = new DataView( - drawCommandBytes.buffer, - drawCommandBytes.byteOffset, - drawCommandBytes.byteLength - ); - drawCommandView.setUint32(viewportInstanceCountByteOffset, 41, true); - drawCommandView.setUint32(selectionInstanceCountByteOffset, 7, true); - - const queryDiagnosticByteLength = - LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionCandidateCount + - Uint32Array.BYTES_PER_ELEMENT; - const queryDiagnosticBytes = new Uint8Array(queryDiagnosticByteLength + 12).subarray( - 4, - 4 + queryDiagnosticByteLength - ); - const queryDiagnosticView = new DataView( - queryDiagnosticBytes.buffer, - queryDiagnosticBytes.byteOffset, - queryDiagnosticBytes.byteLength - ); - queryDiagnosticView.setUint32( - LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS.viewportIntersectedCellCount, - 12, - true - ); - queryDiagnosticView.setUint32( - LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS.viewportCandidateCount, - 83, - true - ); - queryDiagnosticView.setUint32( - LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionIntersectedCellCount, - 3, - true - ); - queryDiagnosticView.setUint32( - LU_SPATIAL_TAXI_QUERY_DIAGNOSTIC_BYTE_OFFSETS.selectionCandidateCount, - 19, - true - ); - - const counters = decodeLuSpatialTaxiQueryCounters(drawCommandBytes, queryDiagnosticBytes, { - viewportInstanceCountByteOffset, - selectionInstanceCountByteOffset - }); - expect(counters).toEqual({ - viewportIntersectedCellCount: 12, - viewportCandidateCount: 83, - visiblePointCount: 41, - selectionIntersectedCellCount: 3, - selectionCandidateCount: 19, - selectedPointCount: 7 - }); - expect(makeLuSpatialTaxiQueryInspectorCounters(counters)).toEqual({ - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.viewportIntersectedCells]: 12, - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.viewportCandidates]: 83, - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.viewportMatches]: 41, - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.selectionIntersectedCells]: 3, - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.selectionCandidates]: 19, - [LU_SPATIAL_TAXI_QUERY_COUNTER_IDS.selectionMatches]: 7 - }); - }); -}); diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 11c74d029d..baa78ed4ae 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -212,6 +212,10 @@ module.exports = { '@deck.gl-community/luspatial$': path.resolve( __dirname, '../modules/deck-luspatial/src/index.ts' + ), + '@deck.gl-community/luspatial/query$': path.resolve( + __dirname, + '../modules/deck-luspatial/src/query/index.ts' ) } }, From edd838d750e3ffdc6721acd5320f026dc4cbd532 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 4 Aug 2026 04:41:21 -0400 Subject: [PATCH 2/6] test(deck): skip precise radius smoke on software WebGPU --- ...luspatial-geographic-point-query-effect.spec.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts b/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts index 6dde039acb..48fbd5ec42 100644 --- a/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts +++ b/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts @@ -4,12 +4,16 @@ import type {Effect, EffectContext} from '@deck.gl/core'; import {LuSpatialGeographicPointQueryEffect} from '@deck.gl-community/luspatial/query'; +import type {Device} from '@luma.gl/core'; import {getWebGPUTestDevice} from '@luma.gl/test-utils'; import {expect, test} from 'vitest'; -test('LuSpatialGeographicPointQueryEffect builds and updates real WebGPU queries', async () => { +test('LuSpatialGeographicPointQueryEffect builds and updates real hardware WebGPU queries', async () => { const device = await getWebGPUTestDevice(); - if (!device) return; + // The precise radius kernel uses integer fp64 emulation and is intentionally not exercised on + // SwiftShader. Its focused geospatial tests apply the same exclusion; hardware coverage below + // still verifies the complete projection, index, mutable query, and indirect-draw path. + if (!device || isSoftwareBackedDevice(device)) return; const effect = new LuSpatialGeographicPointQueryEffect(device, { id: 'luspatial-geographic-query-browser-test', @@ -132,3 +136,9 @@ test('LuSpatialGeographicPointQueryEffect builds and updates real WebGPU queries effect.destroy(); } }, 60_000); + +function isSoftwareBackedDevice(device: Device): boolean { + return ( + device.info.gpu === 'software' || device.info.gpuType === 'cpu' || Boolean(device.info.fallback) + ); +} From 9382b676fa5643d150fd1f8c87cac3189e2c6c54 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 4 Aug 2026 05:02:14 -0400 Subject: [PATCH 3/6] fix(deck): address geographic query review feedback --- examples/deck/luspatial-taxi/app.ts | 5 ++- ...luspatial-geographic-point-query-effect.ts | 6 +-- ...geographic-point-query-effect.node.spec.ts | 40 +++++++++++++++++++ .../gpgpu-responsive-data.node.spec.ts | 6 +-- 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/examples/deck/luspatial-taxi/app.ts b/examples/deck/luspatial-taxi/app.ts index 008e200422..02a06dcffa 100644 --- a/examples/deck/luspatial-taxi/app.ts +++ b/examples/deck/luspatial-taxi/app.ts @@ -780,7 +780,10 @@ function createControlPanel( visibleElement.textContent = formatCount(stats.visiblePointCount); selectedElement.textContent = formatCount(stats.selectedPointCount); queryTimeElement.textContent = `${stats.queryEncodingMilliseconds.toFixed(2)} ms`; - graphInspectorPanel.update(stats.inspectorSnapshot, 'luspatial-taxi-query-graph'); + graphInspectorPanel.update( + stats.inspectorSnapshot, + LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_GRAPH_IDS.query + ); } }; } diff --git a/modules/deck-luspatial/src/query/luspatial-geographic-point-query-effect.ts b/modules/deck-luspatial/src/query/luspatial-geographic-point-query-effect.ts index 49addd8804..7cae7f17f8 100644 --- a/modules/deck-luspatial/src/query/luspatial-geographic-point-query-effect.ts +++ b/modules/deck-luspatial/src/query/luspatial-geographic-point-query-effect.ts @@ -948,10 +948,10 @@ function validateProps(props: LuSpatialGeographicPointQueryEffectProps): void { throw new Error('projectedBounds must contain finite values'); } if ( - props.projectedBounds[0] >= props.projectedBounds[2] || - props.projectedBounds[1] >= props.projectedBounds[3] + props.projectedBounds[0] > props.projectedBounds[2] || + props.projectedBounds[1] > props.projectedBounds[3] ) { - throw new Error('projectedBounds minima must be less than maxima'); + throw new Error('projectedBounds minima must not exceed maxima'); } const gridSize = props.gridSize ?? DEFAULT_GRID_SIZE; if (gridSize.some(value => !Number.isSafeInteger(value) || value <= 0)) { diff --git a/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.node.spec.ts b/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.node.spec.ts index b45b63d32b..ebae0e90b5 100644 --- a/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.node.spec.ts +++ b/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.node.spec.ts @@ -53,6 +53,37 @@ describe('LuSpatialGeographicPointQueryEffect resource safety', () => { }) ).toThrow('viewportProjectionPaddingKilometres must be a non-negative finite number'); }); + + test('accepts axis-degenerate projected bounds but rejects reversed bounds', () => { + const allocationSentinel = 'effect validation reached buffer allocation'; + const device = { + type: 'webgpu', + createBuffer: () => { + throw new Error(allocationSentinel); + } + } as unknown as Device; + + for (const projectedBounds of [ + [0, -1, 0, 1], + [0, 0, 0, 0] + ] as const) { + expect( + () => + new LuSpatialGeographicPointQueryEffect(device, { + ...TEST_QUERY_PROPS, + projectedBounds + }) + ).toThrow(allocationSentinel); + } + + expect( + () => + new LuSpatialGeographicPointQueryEffect({type: 'webgpu'} as Device, { + ...TEST_QUERY_PROPS, + projectedBounds: [1, -1, 0, 1] + }) + ).toThrow('projectedBounds minima must not exceed maxima'); + }); }); describe('luSpatial geographic point query telemetry', () => { @@ -147,4 +178,13 @@ describe('@deck.gl-community/luspatial/query package boundary', () => { 'LuSpatialGeographicPointQueryEffect' ); }); + + test('uses the public query graph ID in the Taxi inspector', () => { + const appSource = readFileSync( + new URL('../../../examples/deck/luspatial-taxi/app.ts', import.meta.url), + 'utf8' + ); + expect(appSource).toContain('LU_SPATIAL_GEOGRAPHIC_POINT_QUERY_GRAPH_IDS.query'); + expect(appSource).not.toContain("'luspatial-taxi-query-graph'"); + }); }); diff --git a/test/examples/gpgpu-responsive-data.node.spec.ts b/test/examples/gpgpu-responsive-data.node.spec.ts index 0b186f3278..82fa98f8d0 100644 --- a/test/examples/gpgpu-responsive-data.node.spec.ts +++ b/test/examples/gpgpu-responsive-data.node.spec.ts @@ -13,9 +13,9 @@ import { TAXI_PROJECTION_ORIGIN } from '../../examples/deck/luspatial-taxi/taxi-data'; -const TAXI_EFFECT_SOURCE_PATH = path.join( +const GEOGRAPHIC_QUERY_EFFECT_SOURCE_PATH = path.join( process.cwd(), - 'examples/deck/luspatial-taxi/luspatial-query-effect.ts' + 'modules/deck-luspatial/src/query/luspatial-geographic-point-query-effect.ts' ); const TAXI_APP_SOURCE_PATH = path.join(process.cwd(), 'examples/deck/luspatial-taxi/app.ts'); const ATLAS_APP_SOURCE_PATH = path.join( @@ -106,7 +106,7 @@ describe('responsive GPU data examples', () => { }); test('composes actual luProj projection with cancellable luSpatial graph execution', () => { - const effectSource = readFileSync(TAXI_EFFECT_SOURCE_PATH, 'utf8'); + const effectSource = readFileSync(GEOGRAPHIC_QUERY_EFFECT_SOURCE_PATH, 'utf8'); const appSource = readFileSync(TAXI_APP_SOURCE_PATH, 'utf8'); expect(effectSource).toMatch(/from ['"]@luma\.gl\/experimental\/luproj['"]/); From a36a4d641589f191f794370a3f888cd01dbc31c0 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 4 Aug 2026 10:30:23 -0400 Subject: [PATCH 4/6] fix(deck): return mutable projection coordinates --- .../src/query/luspatial-geographic-point-query-effect.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/modules/deck-luspatial/src/query/luspatial-geographic-point-query-effect.ts b/modules/deck-luspatial/src/query/luspatial-geographic-point-query-effect.ts index 7cae7f17f8..09a26d9f7c 100644 --- a/modules/deck-luspatial/src/query/luspatial-geographic-point-query-effect.ts +++ b/modules/deck-luspatial/src/query/luspatial-geographic-point-query-effect.ts @@ -212,8 +212,13 @@ export class LuSpatialGeographicPointQueryEffect implements Effect { this.pointCount = props.longitudeLatitudes.length / 2; this.projectionOrigin = props.projectionOrigin; this.projectionPlan = compileProjectionPlan({ - projection: coordinates => - projectLongitudeLatitude([coordinates[0], coordinates[1]], this.projectionOrigin), + projection: coordinates => { + const projected = projectLongitudeLatitude( + [coordinates[0], coordinates[1]], + this.projectionOrigin + ); + return [projected[0], projected[1]]; + }, bounds: props.sourceBounds, degree: 2, tolerance: 0.0005, From be1a45d730ab1978876fa32e3da884721cab8c23 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Wed, 5 Aug 2026 19:35:08 -0400 Subject: [PATCH 5/6] test(deck): cover geographic query validation in browser --- ...tial-geographic-point-query-effect.spec.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts b/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts index 48fbd5ec42..0fbc29d6d2 100644 --- a/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts +++ b/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts @@ -8,6 +8,31 @@ import type {Device} from '@luma.gl/core'; import {getWebGPUTestDevice} from '@luma.gl/test-utils'; import {expect, test} from 'vitest'; +test('LuSpatialGeographicPointQueryEffect validates props before browser GPU allocation', () => { + const props = { + longitudeLatitudes: new Float32Array([-73.97, 40.75]), + sourceBounds: [-74, 40.72, -73.94, 40.78] as const, + projectedBounds: [-1, -1, 1, 1] as const, + projectionOrigin: [-73.97, 40.75] as const + }; + expect( + () => + new LuSpatialGeographicPointQueryEffect({type: 'webgpu'} as Device, { + ...props, + viewportProjectionPaddingKilometres: -1 + }) + ).toThrow('viewportProjectionPaddingKilometres must be a non-negative finite number'); + + const allocationSentinel = 'browser validation reached buffer allocation'; + const device = { + type: 'webgpu', + createBuffer: () => { + throw new Error(allocationSentinel); + } + } as unknown as Device; + expect(() => new LuSpatialGeographicPointQueryEffect(device, props)).toThrow(allocationSentinel); +}); + test('LuSpatialGeographicPointQueryEffect builds and updates real hardware WebGPU queries', async () => { const device = await getWebGPUTestDevice(); // The precise radius kernel uses integer fp64 emulation and is intentionally not exercised on From a81a314c2fb1070638ffcea8889e8c88a1edacc9 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Wed, 5 Aug 2026 20:26:17 -0400 Subject: [PATCH 6/6] test(deck): cover geographic query browser lifecycle --- ...tial-geographic-point-query-effect.spec.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts b/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts index 0fbc29d6d2..e9432b90b7 100644 --- a/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts +++ b/modules/deck-luspatial/test/luspatial-geographic-point-query-effect.spec.ts @@ -33,6 +33,92 @@ test('LuSpatialGeographicPointQueryEffect validates props before browser GPU all expect(() => new LuSpatialGeographicPointQueryEffect(device, props)).toThrow(allocationSentinel); }); +test('LuSpatialGeographicPointQueryEffect supports a browser-safe software WebGPU lifecycle', async () => { + const device = await getWebGPUTestDevice(); + if (!device) return; + + const publishedStats: unknown[] = []; + const effect = new LuSpatialGeographicPointQueryEffect(device, { + id: 'luspatial-geographic-query-software-lifecycle-test', + longitudeLatitudes: new Float32Array([-73.97, 40.75, -73.99, 40.74]), + sourceBounds: [-74, 40.72, -73.94, 40.78], + projectionOrigin: [-73.97, 40.75], + projectedBounds: [-3, -3, 3, 3], + gridSize: [2, 2], + initialSelection: {center: [-73.97, 40.75], radiusKilometres: 2}, + selectionRadiusRangeKilometres: [0.1, 1], + viewportId: 'map', + viewportProjectionPaddingKilometres: 0, + enableDiagnostics: false, + maxInspectorSamples: 4, + onStats: stats => publishedStats.push(stats) + }); + const redrawReasons: string[] = []; + const effectContext = { + device, + deck: {redraw: (reason: string) => redrawReasons.push(reason)} + } as unknown as EffectContext; + + try { + expect(publishedStats).toHaveLength(1); + expect(effect.getSelection()).toEqual({ + center: [-73.97, 40.75], + radiusKilometres: 1 + }); + expect(() => + effect.setup({...effectContext, device: {...device}} as unknown as EffectContext) + ).toThrow(/must be adopted by the device used during construction/); + effect.setup(effectContext); + + expect(() => effect.setSelection([181, 0])).toThrow(/must be valid longitude\/latitude/); + expect(() => effect.setSelection([-73.98, 40.75], Number.POSITIVE_INFINITY)).toThrow( + /selection radius must be finite/ + ); + effect.setSelection([-73.98, 40.75], 0.5); + expect(effect.getSelection()).toEqual({center: [-73.98, 40.75], radiusKilometres: 0.5}); + expect(() => effect.setSelectionRadius(Number.NaN)).toThrow(/selection radius must be finite/); + effect.setSelectionRadius(0.01); + expect(effect.getSelection().radiusKilometres).toBe(0.1); + expect(redrawReasons).toEqual([ + 'luspatial-geographic-query-software-lifecycle-test selection changed', + 'luspatial-geographic-query-software-lifecycle-test selection radius changed' + ]); + + const defaultCommandEncoder = device.commandEncoder; + const lifecycleCommandEncoder = device.createCommandEncoder({ + id: 'luspatial-geographic-query-software-lifecycle-test' + }); + device.commandEncoder = lifecycleCommandEncoder; + try { + const preRenderOptions = { + viewports: [ + { + id: 'map', + width: 100, + height: 100, + unproject: ([x, y]: number[]) => [-74 + (x / 100) * 0.06, 40.77 - (y / 100) * 0.04] + } + ] + } as unknown as Parameters>[0]; + effect.preRender(preRenderOptions); + effect.preRender(preRenderOptions); + effect.preRender({ + viewports: [{id: 'other', width: 100, height: 100}] + } as unknown as Parameters>[0]); + } finally { + device.commandEncoder = defaultCommandEncoder; + const discardedCommandBuffer = lifecycleCommandEncoder.finish(); + discardedCommandBuffer.destroy(); + } + expect(publishedStats).toHaveLength(2); + } finally { + effect.cleanup(effectContext); + effect.destroy(); + } + + effect.preRender({viewports: []} as unknown as Parameters>[0]); +}); + test('LuSpatialGeographicPointQueryEffect builds and updates real hardware WebGPU queries', async () => { const device = await getWebGPUTestDevice(); // The precise radius kernel uses integer fp64 emulation and is intentionally not exercised on