diff --git a/docs/api-reference/geo-layers/shared-tile-2d-layer.md b/docs/api-reference/geo-layers/shared-tile-2d-layer.md new file mode 100644 index 00000000000..f3d5b71057c --- /dev/null +++ b/docs/api-reference/geo-layers/shared-tile-2d-layer.md @@ -0,0 +1,97 @@ +# SharedTile2DLayer (Experimental) + +`_SharedTile2DLayer` is an experimental composite layer for rendering 2D tiled data when multiple layer instances or viewports should reuse one tile-content cache. It is a parallel API to [`TileLayer`](./tile-layer.md), not a replacement for `TileLayer`, `MVTLayer`, or `TerrainLayer`. + +Use `_SharedTile2DLayer` when the same tiled payload should feed multiple views, such as a main map and minimap. The layer keeps selection and visibility state per viewport, while [`_SharedTileset2D`](./shared-tileset-2d.md) owns loading, request scheduling, cache eviction, stats, and TileSource metadata. + +```ts +import {Deck, MapView} from '@deck.gl/core'; +import {BitmapLayer} from '@deck.gl/layers'; +import { + _SharedTile2DLayer as SharedTile2DLayer, + _SharedTileset2D as SharedTileset2D, + sharedTile2DDeckAdapter +} from '@deck.gl/geo-layers'; + +const tileset = new SharedTileset2D({ + adapter: sharedTile2DDeckAdapter, + minZoom: 0, + maxZoom: 19, + getTileData: async ({index, signal}) => { + const {x, y, z} = index; + const response = await fetch(`https://tile.openstreetmap.org/${z}/${x}/${y}.png`, {signal}); + return createImageBitmap(await response.blob()); + } +}); + +const layer = new SharedTile2DLayer({ + id: 'shared-raster-tiles', + data: tileset, + renderSubLayers: props => { + const [[west, south], [east, north]] = props.tile.boundingBox; + return new BitmapLayer(props, { + data: null, + image: props.data, + bounds: [west, south, east, north] + }); + } +}); + +new Deck({ + views: [ + new MapView({id: 'main', controller: true}), + new MapView({id: 'minimap', x: 16, y: 16, width: 240, height: 160}) + ], + initialViewState: {longitude: -122.4, latitude: 37.74, zoom: 11}, + layers: [layer] +}); +``` + +## Installation + +```bash +npm install deck.gl +# or +npm install @deck.gl/core @deck.gl/layers @deck.gl/geo-layers +``` + +```ts +import {_SharedTile2DLayer as SharedTile2DLayer} from '@deck.gl/geo-layers'; +import type {SharedTile2DLayerPickingInfo, SharedTile2DLayerProps} from '@deck.gl/geo-layers'; + +new SharedTile2DLayer(...props: SharedTile2DLayerProps[]); +``` + +## Properties + +Inherits all properties from base [`Layer`](../core/layer.md). If using the default `renderSubLayers`, supports all [`GeoJSONLayer`](../layers/geojson-layer.md) properties. + +### `data` (string | string[] | `_SharedTileset2D` | TileSource, optional) {#data} + +- Default: `[]` + +Accepts the same URL-template input as [`TileLayer`](./tile-layer.md#data), a loaders.gl `TileSource`, or an external `_SharedTileset2D`. + +When `data` is a URL template, the layer creates and owns an internal tileset. When `data` is a `TileSource`, the internal tileset uses `TileSource.getTileData()` and adopts supported metadata such as `minZoom`, `maxZoom`, and `boundingBox` unless the layer explicitly overrides those props. When `data` is an external `_SharedTileset2D`, the caller owns and finalizes that tileset. + +### `getTileData` (Function, optional) {#gettiledata} + +Called for URL-template data with the same tile load props documented by [`TileLayer`](./tile-layer.md#gettiledata). This prop is ignored when `data` is a `TileSource` or external `_SharedTileset2D`. + +### `renderSubLayers` (Function, optional) {#rendersublayers} + +Receives the loaded tile payload as `props.data` and the shared tile header as `props.tile`. Return one layer, an array of layers, or `null`. + +### Tile options + +Owned tilesets accept the same core tile options as `TileLayer`: `extent`, `tileSize`, `maxZoom`, `minZoom`, `maxCacheSize`, `maxCacheByteSize`, `zRange`, `maxRequests`, `debounceTime`, `zoomOffset`, `visibleMinZoom`, and `visibleMaxZoom`. + +`refinementStrategy` is intentionally narrower than `TileLayer`: supported values are `'best-available'`, `'no-overlap'`, and `'never'`. Custom refinement callbacks are not supported because tile visibility is view-specific in a shared cache. + +### Callbacks + +`onViewportLoad`, `onTileLoad`, `onTileUnload`, and `onTileError` follow the `TileLayer` callback shape, using `_SharedTile2DHeader` tile objects. + +## Picking + +Use `SharedTile2DLayerPickingInfo` for typed picking callbacks. It includes `tile`, `sourceTile`, and `sourceTileSubLayer`, matching `TileLayerPickingInfo`. diff --git a/docs/api-reference/geo-layers/shared-tileset-2d.md b/docs/api-reference/geo-layers/shared-tileset-2d.md new file mode 100644 index 00000000000..5216be5dfef --- /dev/null +++ b/docs/api-reference/geo-layers/shared-tileset-2d.md @@ -0,0 +1,62 @@ +# SharedTileset2D (Experimental) + +`_SharedTileset2D` is the experimental shared tile-content cache used by [`_SharedTile2DLayer`](./shared-tile-2d-layer.md). It owns tile headers, tile payloads, request scheduling, cache eviction, loaders.gl `TileSource` metadata, live stats, and lifecycle subscriptions. Per-viewport selection and visibility are intentionally owned by the layer's internal views instead of the tileset. + +```ts +import { + _SharedTileset2D as SharedTileset2D, + sharedTile2DDeckAdapter +} from '@deck.gl/geo-layers'; +import type {SharedTileset2DProps} from '@deck.gl/geo-layers'; + +const props: SharedTileset2DProps = { + adapter: sharedTile2DDeckAdapter, + getTileData: async ({index, signal}) => { + const {x, y, z} = index; + const response = await fetch(`https://tile.openstreetmap.org/${z}/${x}/${y}.png`, {signal}); + return createImageBitmap(await response.blob()); + } +}; + +const tileset = new SharedTileset2D(props); +``` + +## Construction + +```ts +import {_SharedTileset2D as SharedTileset2D} from '@deck.gl/geo-layers'; +import type { + SharedRefinementStrategy, + SharedTileset2DAdapter, + SharedTileset2DBaseProps, + SharedTileset2DProps, + SharedTileset2DTileContext, + SharedTileset2DTraversalContext +} from '@deck.gl/geo-layers'; + +new SharedTileset2D(props: SharedTileset2DProps); +SharedTileset2D.fromTileSource(tileSource, props); +``` + +Provide either `getTileData` or `tileSource`. A shared tileset also needs an adapter before traversal is used. `_SharedTile2DLayer` installs `sharedTile2DDeckAdapter` automatically for deck.gl viewport traversal; applications constructing the tileset directly should usually pass that adapter themselves. + +## TileSource metadata + +When created from a loaders.gl `TileSource`, `_SharedTileset2D` calls `getMetadata()` asynchronously and adopts supported `minZoom`, `maxZoom`, and `boundingBox` metadata. Explicit tileset options win over metadata. Replacing the `tileSource` ignores late metadata from the previous source. + +## Ownership + +An external `_SharedTileset2D` can be passed to one or more `_SharedTile2DLayer` instances. Those layers do not finalize the external tileset. The owner should call `tileset.finalize()` when the shared cache is no longer needed. + +## Runtime API + +- `tiles`, `selectedTiles`, `visibleTiles`, `loadingTiles`, and `cacheByteSize` expose current shared cache state. +- `stats` is a `@probe.gl/stats` `Stats` object with tile cache, visibility, loading, eviction, and consumer counters. +- `setOptions()` updates effective tileset options. Pass `{replace: true}` as the second argument to replace prior caller options instead of merging them. +- `reloadAll()` marks selected tiles stale and drops unselected cached tiles. +- `subscribe()` listens for tile load, tile error, tile unload, metadata/config update, metadata error, and stats change events. +- `finalize()` aborts in-flight requests and clears the shared cache. + +## Refinement + +`SharedRefinementStrategy` supports `'best-available'`, `'no-overlap'`, and `'never'`. Unlike `TileLayer`, `_SharedTileset2D` does not accept a custom refinement callback because one tile header may be selected or visible in one viewport and hidden in another. diff --git a/docs/api-reference/layers/README.md b/docs/api-reference/layers/README.md index 35df921a87b..02994c3f473 100644 --- a/docs/api-reference/layers/README.md +++ b/docs/api-reference/layers/README.md @@ -48,6 +48,8 @@ The [Geo Layers](https://www.npmjs.com/package/@deck.gl/geo-layers) collects lay - [MVTLayer](../geo-layers/mvt-layer.md) - [QuadkeyLayer](../geo-layers/quadkey-layer.md) - [S2Layer](../geo-layers/s2-layer.md) + - [SharedTile2DLayer](../geo-layers/shared-tile-2d-layer.md) (experimental) + - [SharedTileset2D](../geo-layers/shared-tileset-2d.md) (experimental) - [TerrainLayer](../geo-layers/terrain-layer.md) - [TileLayer](../geo-layers/tile-layer.md) - [Tile3DLayer](../geo-layers/tile-3d-layer.md) diff --git a/docs/developer-guide/views.md b/docs/developer-guide/views.md index ba68c27371d..816d4f77403 100644 --- a/docs/developer-guide/views.md +++ b/docs/developer-guide/views.md @@ -766,7 +766,30 @@ function App({carPose}: { -Some layers, including `TileLayer`, `MVTLayer`, `HeatmapLayer` and `ScreenGridLayer`, perform expensive operations (data fetching and/or aggregation) on viewport change. Therefore, it is generally *NOT* recommended to render them into multiple views. If you do need to show e.g. tiled base map in multiple views, create one layer instance for each view and limit their rendering with `layerFilter`: +Some layers, including `TileLayer`, `MVTLayer`, `HeatmapLayer` and `ScreenGridLayer`, perform expensive operations (data fetching and/or aggregation) on viewport change. Therefore, it is generally *NOT* recommended to render them into multiple views. + +For 2D tiled content that can share one payload cache, the experimental [`_SharedTile2DLayer`](../api-reference/geo-layers/shared-tile-2d-layer.md) and [`_SharedTileset2D`](../api-reference/geo-layers/shared-tileset-2d.md) support one shared tileset feeding multiple viewports: + +```ts +import { + _SharedTile2DLayer as SharedTile2DLayer, + _SharedTileset2D as SharedTileset2D, + sharedTile2DDeckAdapter +} from '@deck.gl/geo-layers'; + +const tileset = new SharedTileset2D({ + adapter: sharedTile2DDeckAdapter, + getTileData: ({index, signal}) => fetchTile(index, signal) +}); + +const layer = new SharedTile2DLayer({ + id: 'shared-tiles', + data: tileset, + renderSubLayers +}); +``` + +For tiled layers that do not use that experimental shared path, including `MVTLayer`, create one layer instance for each view and limit their rendering with `layerFilter`: diff --git a/docs/developer-guide/webgpu.md b/docs/developer-guide/webgpu.md index 6f71ff8864c..44cf4242f2f 100644 --- a/docs/developer-guide/webgpu.md +++ b/docs/developer-guide/webgpu.md @@ -60,6 +60,7 @@ The table below covers the public layer exports from the layer packages. It is d | `@deck.gl/geo-layers` | `S2Layer` | ✅ | ❌ | | `@deck.gl/geo-layers` | `QuadkeyLayer` | ✅ | ❌ | | `@deck.gl/geo-layers` | `TileLayer` | ✅ | ❌ | +| `@deck.gl/geo-layers` | `_SharedTile2DLayer` | ✅ | ❌ | | `@deck.gl/geo-layers` | `TripsLayer` | ✅ | ❌ | | `@deck.gl/geo-layers` | `H3ClusterLayer` | ✅ | ❌ | | `@deck.gl/geo-layers` | `H3HexagonLayer` | ✅ | ❌ | diff --git a/docs/table-of-contents.json b/docs/table-of-contents.json index a339e73cfc1..8d211d7dbc9 100644 --- a/docs/table-of-contents.json +++ b/docs/table-of-contents.json @@ -2,13 +2,7 @@ { "type": "category", "label": "Overview", - "items": [ - "README", - "whats-new", - "upgrade-guide", - "contributing", - "faq" - ] + "items": ["README", "whats-new", "upgrade-guide", "contributing", "faq"] }, { "type": "category", @@ -120,6 +114,8 @@ "api-reference/layers/scatterplot-layer", "api-reference/mesh-layers/scenegraph-layer", "api-reference/aggregation-layers/screen-grid-layer", + "api-reference/geo-layers/shared-tile-2d-layer", + "api-reference/geo-layers/shared-tileset-2d", "api-reference/mesh-layers/simple-mesh-layer", "api-reference/layers/solid-polygon-layer", "api-reference/geo-layers/terrain-layer", @@ -144,9 +140,7 @@ { "type": "category", "label": "Scripting Interface", - "items": [ - "api-reference/core/deckgl" - ] + "items": ["api-reference/core/deckgl"] }, { "type": "category", @@ -203,10 +197,7 @@ { "type": "category", "label": "Effects", - "items": [ - "api-reference/core/lighting-effect", - "api-reference/core/post-process-effect" - ] + "items": ["api-reference/core/lighting-effect", "api-reference/core/post-process-effect"] }, { "type": "category", @@ -289,10 +280,7 @@ { "type": "category", "label": "@deck.gl/mapbox", - "items": [ - "api-reference/mapbox/overview", - "api-reference/mapbox/mapbox-overlay" - ] + "items": ["api-reference/mapbox/overview", "api-reference/mapbox/mapbox-overlay"] }, { "type": "category", diff --git a/docs/whats-new.md b/docs/whats-new.md index 2da341753ff..4342e6dafd9 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -79,6 +79,7 @@ Class-specific improvements: - [TextLayer](./api-reference/layers/text-layer.md) now supports per-object clipping box; and making text "sticky" when its container is partially off-screen. See a demo with this [new example](https://deck.gl/examples/text-layer-clipping). - [TileLayer](./api-reference/geo-layers/tile-layer.md) adds new `visibleMinZoom` and `visibleMaxZoom` props to control the zoom range at which tiles are drawn, independent of the zoom range at which data is loaded. +- Experimental [`_SharedTile2DLayer`](./api-reference/geo-layers/shared-tile-2d-layer.md) and [`_SharedTileset2D`](./api-reference/geo-layers/shared-tileset-2d.md) let 2D tiled layers reuse one tile-content cache across multiple views. - Improvements to [Tile3DLayer](./api-reference/geo-layers/tile-3d-layer.md) including better performance and tile tracking. - WebGPU now materializes constant layer attributes into full buffers through `AttributeManager`, improving compatibility for layers that rely on constant accessors. diff --git a/examples/website/shared-tile-2d-layer/README.md b/examples/website/shared-tile-2d-layer/README.md new file mode 100644 index 00000000000..30351ffbd06 --- /dev/null +++ b/examples/website/shared-tile-2d-layer/README.md @@ -0,0 +1,22 @@ +This is a standalone experimental SharedTile2DLayer example using one SharedTileset2D to feed +tiled BitmapLayer sublayers in a main view and minimap on the [deck.gl](http://deck.gl) website. + +### Usage + +Copy the content of this folder to your project. + +```bash +# install dependencies +npm install +# or +yarn +# bundle and serve the app with vite +npm start +``` + +### Data Source + +OpenStreetMap raster tiles from [OpenStreetMap contributors](https://www.openstreetmap.org/copyright). + +For more information, check out the +[documentation of SharedTile2DLayer](../../../docs/api-reference/geo-layers/shared-tile-2d-layer.md). diff --git a/examples/website/shared-tile-2d-layer/app.tsx b/examples/website/shared-tile-2d-layer/app.tsx new file mode 100644 index 00000000000..4006871fbd7 --- /dev/null +++ b/examples/website/shared-tile-2d-layer/app.tsx @@ -0,0 +1,177 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/* global fetch */ +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {createRoot} from 'react-dom/client'; + +import {MapView} from '@deck.gl/core'; +import { + _SharedTile2DLayer as SharedTile2DLayer, + _SharedTileset2D as SharedTileset2D, + sharedTile2DDeckAdapter +} from '@deck.gl/geo-layers'; +import {BitmapLayer} from '@deck.gl/layers'; +import {DeckGL} from '@deck.gl/react'; + +import type {MapViewState, ViewStateChangeParameters} from '@deck.gl/core'; +import type {SharedTile2DLayerPickingInfo} from '@deck.gl/geo-layers'; + +const TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; +const mainView = new MapView({id: 'main', controller: true, repeat: true}); +const minimapView = new MapView({ + id: 'minimap', + x: 16, + y: 16, + width: 280, + height: 180, + clear: true +}); + +const INITIAL_VIEW_STATE: {main: MapViewState; minimap: MapViewState} = { + main: { + longitude: -122.42, + latitude: 37.78, + zoom: 11, + minZoom: 0, + maxZoom: 19 + }, + minimap: { + longitude: -122.42, + latitude: 37.78, + zoom: 7 + } +}; + +const MINIMAP_FRAME_STYLE: React.CSSProperties = { + position: 'absolute', + left: 16, + top: 16, + width: 280, + height: 180, + pointerEvents: 'none', + border: '1px solid rgba(255,255,255,0.9)', + boxShadow: '0 8px 28px rgba(0,0,0,0.35)' +}; + +const LABEL_STYLE: React.CSSProperties = { + position: 'absolute', + left: 28, + top: 28, + pointerEvents: 'none', + color: '#fff', + background: 'rgba(0,0,0,0.66)', + padding: '6px 8px', + font: '12px/1.2 Helvetica Neue,Arial,sans-serif' +}; + +const COPYRIGHT_LICENSE_STYLE: React.CSSProperties = { + position: 'absolute', + right: 0, + bottom: 0, + backgroundColor: 'hsla(0,0%,100%,.75)', + padding: '0 5px', + font: '12px/20px Helvetica Neue,Arial,Helvetica,sans-serif' +}; + +const LINK_STYLE: React.CSSProperties = { + textDecoration: 'none', + color: 'rgba(0,0,0,.75)' +}; + +function getTileUrl({x, y, z}: {x: number; y: number; z: number}): string { + return TILE_URL.replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y)); +} + +function getTooltip({tile}: SharedTile2DLayerPickingInfo) { + if (!tile) { + return null; + } + const {x, y, z} = tile.index; + return `tile: x: ${x}, y: ${y}, z: ${z}`; +} + +export default function App({onTilesLoad}: {onTilesLoad?: (tileCount: number) => void}) { + const [viewState, setViewState] = useState(INITIAL_VIEW_STATE); + const tileset = useMemo( + () => + new SharedTileset2D({ + adapter: sharedTile2DDeckAdapter, + tileSize: 256, + minZoom: 0, + maxZoom: 19, + maxRequests: 20, + getTileData: async ({index, signal}) => { + const response = await fetch(getTileUrl(index), {signal}); + if (!response.ok) { + throw new Error(`Tile request failed: ${response.status}`); + } + return createImageBitmap(await response.blob()); + } + }), + [] + ); + + useEffect(() => () => tileset.finalize(), [tileset]); + + const onViewStateChange = useCallback( + ({viewId, viewState: nextViewState}: ViewStateChangeParameters) => { + if (viewId !== 'main') { + return; + } + setViewState(currentViewState => ({ + main: nextViewState, + minimap: { + ...currentViewState.minimap, + longitude: nextViewState.longitude, + latitude: nextViewState.latitude + } + })); + }, + [] + ); + + const tileLayer = useMemo( + () => + new SharedTile2DLayer({ + id: 'shared-raster-tiles', + data: tileset, + pickable: true, + onViewportLoad: tiles => onTilesLoad?.(tiles.length), + renderSubLayers: props => { + const [[west, south], [east, north]] = props.tile.boundingBox; + const {data, ...otherProps} = props; + return new BitmapLayer(otherProps, { + data: null, + image: data, + bounds: [west, south, east, north] + }); + } + }), + [onTilesLoad, tileset] + ); + + return ( + +
+
one shared tileset
+ + + ); +} + +export function renderToDOM(container: HTMLDivElement) { + createRoot(container).render(); +} diff --git a/examples/website/shared-tile-2d-layer/index.html b/examples/website/shared-tile-2d-layer/index.html new file mode 100644 index 00000000000..b91f10eab46 --- /dev/null +++ b/examples/website/shared-tile-2d-layer/index.html @@ -0,0 +1,25 @@ + + + + + deck.gl Example + + + + +
+ + + diff --git a/examples/website/shared-tile-2d-layer/package.json b/examples/website/shared-tile-2d-layer/package.json new file mode 100644 index 00000000000..311a5b11153 --- /dev/null +++ b/examples/website/shared-tile-2d-layer/package.json @@ -0,0 +1,22 @@ +{ + "name": "deckgl-examples-shared-tile-2d-layer", + "version": "0.0.0", + "private": true, + "license": "MIT", + "scripts": { + "start": "vite --open", + "start-local": "vite --config ../../vite.config.local.mjs", + "build": "vite build" + }, + "dependencies": { + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "deck.gl": "^9.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "typescript": "^4.6.0", + "vite": "^7.3.3" + } +} diff --git a/examples/website/shared-tile-2d-layer/tsconfig.json b/examples/website/shared-tile-2d-layer/tsconfig.json new file mode 100644 index 00000000000..9b3c020493c --- /dev/null +++ b/examples/website/shared-tile-2d-layer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "es2020", + "jsx": "react", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true + } +} diff --git a/modules/geo-layers/package.json b/modules/geo-layers/package.json index ed94b0e369b..d55e68561cd 100644 --- a/modules/geo-layers/package.json +++ b/modules/geo-layers/package.json @@ -51,6 +51,7 @@ "@math.gl/core": "^4.1.0", "@math.gl/culling": "^4.1.0", "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/stats": "^4.1.1", "@types/geojson": "^7946.0.8", "a5-js": "^0.7.2", "h3-js": "^4.4.0", diff --git a/modules/geo-layers/src/index.ts b/modules/geo-layers/src/index.ts index e9b8fcaba2d..acda8aa0360 100644 --- a/modules/geo-layers/src/index.ts +++ b/modules/geo-layers/src/index.ts @@ -10,6 +10,7 @@ export {default as GreatCircleLayer} from './great-circle-layer/great-circle-lay export {default as S2Layer} from './s2-layer/s2-layer'; export {default as QuadkeyLayer} from './quadkey-layer/quadkey-layer'; export {default as TileLayer} from './tile-layer/tile-layer'; +export {SharedTile2DLayer as _SharedTile2DLayer} from './shared-tile-2d-layer/index'; export {default as TripsLayer} from './trips-layer/trips-layer'; export {default as H3ClusterLayer} from './h3-layers/h3-cluster-layer'; export {default as H3HexagonLayer} from './h3-layers/h3-hexagon-layer'; @@ -28,6 +29,10 @@ export type {H3HexagonLayerProps} from './h3-layers/h3-hexagon-layer'; export type {GreatCircleLayerProps} from './great-circle-layer/great-circle-layer'; export type {S2LayerProps} from './s2-layer/s2-layer'; export type {TileLayerProps, TileLayerPickingInfo} from './tile-layer/tile-layer'; +export type { + SharedTile2DLayerPickingInfo, + SharedTile2DLayerProps +} from './shared-tile-2d-layer/index'; export type {TripsLayerProps} from './trips-layer/trips-layer'; export type {QuadkeyLayerProps} from './quadkey-layer/quadkey-layer'; export type {TerrainLayerProps} from './terrain-layer/terrain-layer'; @@ -41,7 +46,18 @@ export type {GeohashLayerProps} from './geohash-layer/geohash-layer'; export type {GeoBoundingBox, NonGeoBoundingBox} from './tileset-2d/index'; export type {TileLoadProps as _TileLoadProps} from './tileset-2d/index'; export type {Tileset2DProps as _Tileset2DProps} from './tileset-2d/index'; +export type { + SharedRefinementStrategy, + SharedTileset2DAdapter, + SharedTileset2DBaseProps, + SharedTileset2DProps, + SharedTileset2DTileContext, + SharedTileset2DTraversalContext +} from './shared-tileset-2d/index'; export {getURLFromTemplate as _getURLFromTemplate} from './tileset-2d/index'; export {Tileset2D as _Tileset2D} from './tileset-2d/index'; export {Tile2DHeader as _Tile2DHeader} from './tileset-2d/index'; +export {SharedTileset2D as _SharedTileset2D} from './shared-tileset-2d/index'; +export {SharedTile2DHeader as _SharedTile2DHeader} from './shared-tileset-2d/index'; +export {sharedTile2DDeckAdapter} from './shared-tile-2d-layer/index'; diff --git a/modules/geo-layers/src/shared-tile-2d-layer/deck-tileset-adapter.ts b/modules/geo-layers/src/shared-tile-2d-layer/deck-tileset-adapter.ts new file mode 100644 index 00000000000..9a2926c6b44 --- /dev/null +++ b/modules/geo-layers/src/shared-tile-2d-layer/deck-tileset-adapter.ts @@ -0,0 +1,47 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Viewport} from '@deck.gl/core'; + +import type { + SharedTileset2DAdapter, + SharedTileset2DTileContext, + SharedTileset2DTraversalContext +} from '../shared-tileset-2d/adapter'; +import {getTileIndices, tileToBoundingBox} from '../tileset-2d/utils'; + +/** deck.gl viewport type used by the shared tile layer adapter. */ +export type SharedTile2DDeckViewState = Viewport; + +/** Adapts the shared tileset traversal contract to deck.gl viewports. */ +export const sharedTile2DDeckAdapter: SharedTileset2DAdapter = { + getTileIndices: ({ + viewState, + maxZoom, + minZoom, + zRange, + tileSize, + extent, + modelMatrix, + modelMatrixInverse, + zoomOffset, + visibleMinZoom, + visibleMaxZoom + }: SharedTileset2DTraversalContext) => + getTileIndices({ + viewport: viewState, + maxZoom, + minZoom, + zRange: zRange ?? null, + tileSize, + extent: extent || undefined, + modelMatrix: modelMatrix || undefined, + modelMatrixInverse: modelMatrixInverse || undefined, + zoomOffset, + visibleMinZoom, + visibleMaxZoom + }), + getTileBoundingBox: ({viewState, tileSize}: SharedTileset2DTileContext, {x, y, z}) => + tileToBoundingBox(viewState, x, y, z, tileSize) +}; diff --git a/modules/geo-layers/src/shared-tile-2d-layer/index.ts b/modules/geo-layers/src/shared-tile-2d-layer/index.ts new file mode 100644 index 00000000000..7911e11b28d --- /dev/null +++ b/modules/geo-layers/src/shared-tile-2d-layer/index.ts @@ -0,0 +1,8 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +export {sharedTile2DDeckAdapter} from './deck-tileset-adapter'; +export type {SharedTile2DDeckViewState} from './deck-tileset-adapter'; +export type {SharedTile2DLayerPickingInfo, SharedTile2DLayerProps} from './shared-tile-2d-layer'; +export {default as SharedTile2DLayer} from './shared-tile-2d-layer'; diff --git a/modules/geo-layers/src/shared-tile-2d-layer/shared-tile-2d-layer.ts b/modules/geo-layers/src/shared-tile-2d-layer/shared-tile-2d-layer.ts new file mode 100644 index 00000000000..a2b07057624 --- /dev/null +++ b/modules/geo-layers/src/shared-tile-2d-layer/shared-tile-2d-layer.ts @@ -0,0 +1,573 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import { + CompositeLayer, + type CompositeLayerProps, + type DefaultProps, + type FilterContext, + type GetPickingInfoParams, + type Layer, + type LayerProps, + type LayersList, + type PickingInfo, + type UpdateParameters, + type Viewport, + _flatten as flatten +} from '@deck.gl/core'; +import {GeoJsonLayer} from '@deck.gl/layers'; +import type {TileSource} from '@loaders.gl/loader-utils'; +import {Matrix4} from '@math.gl/core'; + +import { + SharedTile2DHeader, + SharedTileset2D, + STRATEGY_DEFAULT, + type SharedRefinementStrategy, + type SharedTileset2DProps +} from '../shared-tileset-2d/index'; +import type {TileLoadProps, ZRange} from '../tileset-2d/index'; +import {getURLFromTemplate, type URLTemplate, urlType} from '../tileset-2d/index'; +import {sharedTile2DDeckAdapter} from './deck-tileset-adapter'; +import {SharedTile2DView} from './shared-tile-2d-view'; + +function isTileSource(value: unknown): value is TileSource { + return Boolean( + value && + typeof value === 'object' && + 'getTileData' in value && + typeof (value as TileSource).getTileData === 'function' && + 'getMetadata' in value && + typeof (value as TileSource).getMetadata === 'function' + ); +} + +function isURLTemplate(value: unknown): value is URLTemplate { + return ( + value === null || + typeof value === 'string' || + (Array.isArray(value) && value.every(url => typeof url === 'string')) + ); +} + +const tile2DDataType = { + type: 'object' as const, + value: null as URLTemplate | SharedTileset2D | TileSource, + validate: (value, propType) => + (propType.optional && value === null) || + value instanceof SharedTileset2D || + isTileSource(value) || + isURLTemplate(value), + equal: (value1, value2) => + isURLTemplate(value1) && isURLTemplate(value2) + ? urlType.equal(value1, value2) + : value1 === value2 +}; + +const defaultProps: DefaultProps = { + TilesetClass: SharedTileset2D, + data: tile2DDataType, + dataComparator: tile2DDataType.equal, + renderSubLayers: {type: 'function', value: (props: any) => new GeoJsonLayer(props)}, + getTileData: {type: 'function', optional: true, value: null}, + onViewportLoad: {type: 'function', optional: true, value: null}, + onTileLoad: {type: 'function', value: () => {}}, + onTileUnload: {type: 'function', value: () => {}}, + onTileError: {type: 'function', value: () => {}}, + extent: {type: 'array', optional: true, value: null, compare: true}, + tileSize: 512, + maxZoom: null, + minZoom: 0, + maxCacheSize: null, + maxCacheByteSize: null, + refinementStrategy: STRATEGY_DEFAULT, + zRange: null, + maxRequests: 6, + debounceTime: 0, + zoomOffset: 0, + visibleMinZoom: null, + visibleMaxZoom: null +}; + +const TILESET_OPTION_KEYS = [ + 'maxCacheSize', + 'maxCacheByteSize', + 'maxZoom', + 'minZoom', + 'tileSize', + 'refinementStrategy', + 'extent', + 'maxRequests', + 'debounceTime', + 'zoomOffset', + 'visibleMinZoom', + 'visibleMaxZoom' +] as const; + +/** Props for {@link SharedTile2DLayer}. */ +export type SharedTile2DLayerProps = CompositeLayerProps & { + /** URL template, shared tileset, or loaders.gl TileSource backing the layer. */ + data: URLTemplate | SharedTileset2D | TileSource; + /** Tileset class used when the layer creates its own internal tileset. */ + TilesetClass?: typeof SharedTileset2D; + /** Sub-layer factory invoked for each loaded tile. */ + renderSubLayers?: ( + props: SharedTile2DLayerProps & { + id: string; + data: DataT; + _offset: number; + tile: SharedTile2DHeader; + } + ) => Layer | null | LayersList; + /** Optional tile loader used with URL-template data. */ + getTileData?: ((props: TileLoadProps) => Promise | DataT) | null; + /** Callback fired when the current viewport's selected tiles are loaded. */ + onViewportLoad?: ((tiles: SharedTile2DHeader[]) => void) | null; + /** Callback fired when any tile loads. */ + onTileLoad?: (tile: SharedTile2DHeader) => void; + /** Callback fired when any tile is evicted. */ + onTileUnload?: (tile: SharedTile2DHeader) => void; + /** Callback fired when any tile fails to load. */ + onTileError?: (err: any, tile?: SharedTile2DHeader) => void; + /** Bounding box limiting tile generation. */ + extent?: number[] | null; + /** Tile size in pixels. */ + tileSize?: number; + /** Maximum zoom level to request. */ + maxZoom?: number | null; + /** Minimum zoom level to request. */ + minZoom?: number | null; + /** Maximum tile count kept in cache. */ + maxCacheSize?: number | null; + /** Maximum byte size kept in cache. */ + maxCacheByteSize?: number | null; + /** Shared-safe placeholder refinement strategy. */ + refinementStrategy?: SharedRefinementStrategy; + /** Elevation bounds used during geospatial tile selection. */ + zRange?: ZRange | null; + /** Maximum concurrent requests. */ + maxRequests?: number; + /** Debounce interval before issuing queued requests. */ + debounceTime?: number; + /** Integer zoom offset applied during tile selection. */ + zoomOffset?: number; + /** The minimum zoom level at which tiles are visible. */ + visibleMinZoom?: number | null; + /** The maximum zoom level at which tiles are visible. */ + visibleMaxZoom?: number | null; +}; + +/** Picking info returned from {@link SharedTile2DLayer}. */ +export type SharedTile2DLayerPickingInfo< + DataT = any, + SubLayerPickingInfo = PickingInfo +> = SubLayerPickingInfo & { + /** Picked tile when a tile sub-layer is hit. */ + tile?: SharedTile2DHeader; + /** Tile that produced the picked sub-layer. */ + sourceTile: SharedTile2DHeader; + /** Concrete sub-layer instance that handled the pick. */ + sourceTileSubLayer: Layer; +}; + +type SharedTile2DLayerState = { + tileset: SharedTileset2D | null; + tilesetViews: Map>; + ownsTileset: boolean; + isLoaded: boolean; + frameNumbers: Map; + tileLayers: Map; + unsubscribeTilesetEvents: (() => void) | null; +}; + +/** Composite layer that can reuse a shared tileset across layers and views. */ +export default class SharedTile2DLayer< + DataT = any, + ExtraPropsT extends {} = {} +> extends CompositeLayer>> { + /** Layer default props consumed by deck.gl prop management. */ + static defaultProps: DefaultProps = defaultProps; + /** Stable layer name used in logs and devtools. */ + static layerName = 'SharedTile2DLayer'; + + private _knownViewports: Map = new Map(); + + state = null as unknown as SharedTile2DLayerState; + + /** Initializes layer-owned tileset state. */ + initializeState(): void { + this._knownViewports.clear(); + if (this.context.viewport) { + this._knownViewports.set(this.context.viewport.id || 'default', this.context.viewport); + } + this.state = { + tileset: null, + tilesetViews: new Map(), + ownsTileset: false, + isLoaded: false, + frameNumbers: new Map(), + tileLayers: new Map(), + unsubscribeTilesetEvents: null + }; + } + + /** Finalizes owned resources and detaches from any shared tileset. */ + finalizeState(): void { + this.state.unsubscribeTilesetEvents?.(); + for (const tilesetView of this.state.tilesetViews.values()) { + tilesetView.finalize(); + } + if (this.state.ownsTileset) { + this.state.tileset?.finalize(); + } + } + + /** Returns whether all visible sub-layers for all tracked views are loaded. */ + get isLoaded(): boolean { + const {tilesetViews, tileLayers} = this.state; + if (!tilesetViews.size) { + return false; + } + return Boolean( + Array.from(tilesetViews.values()).every(tilesetView => + tilesetView.selectedTiles?.every(tile => { + const cachedLayers = tileLayers.get(tile.id); + return ( + tile.isLoaded && + (!tile.content || !cachedLayers || cachedLayers.every(layer => layer.isLoaded)) + ); + }) + ) + ); + } + + /** Triggers updates whenever props, data, or update triggers change. */ + shouldUpdateState({changeFlags}: UpdateParameters): boolean { + return changeFlags.somethingChanged; + } + + /** Creates, reuses, or reconfigures the backing shared tileset and per-view state. */ + updateState({changeFlags}: UpdateParameters): void { + if (this.context.viewport) { + this._knownViewports.set(this._getViewportKey(), this.context.viewport); + } + const propsChanged = Boolean( + changeFlags.propsOrDataChanged || changeFlags.updateTriggersChanged + ); + const dataChanged = + changeFlags.dataChanged || + (changeFlags.updateTriggersChanged && + (changeFlags.updateTriggersChanged.all || changeFlags.updateTriggersChanged.getTileData)); + + let {tileset, ownsTileset} = this.state; + const nextExternalTileset = this.props.data instanceof SharedTileset2D ? this.props.data : null; + if (nextExternalTileset && !nextExternalTileset.adapter) { + nextExternalTileset.setOptions({adapter: sharedTile2DDeckAdapter}); + } + const nextOwnsTileset = !nextExternalTileset; + const nextTileset = this._resolveTileset(tileset, ownsTileset, nextExternalTileset); + const tilesetChanged = nextTileset !== tileset || nextOwnsTileset !== ownsTileset; + + if (tilesetChanged) { + this._releaseTileset(tileset, ownsTileset); + tileset = nextTileset; + ownsTileset = nextOwnsTileset; + this.setState({ + tileset, + tilesetViews: new Map(), + ownsTileset, + tileLayers: new Map(), + frameNumbers: new Map(), + unsubscribeTilesetEvents: nextTileset.subscribe({ + onTileLoad: this._onTileLoad.bind(this), + onTileError: this._onTileError.bind(this), + onTileUnload: this._onTileUnload.bind(this), + onUpdate: () => this.setNeedsUpdate(), + onError: error => this.raiseError(error, 'loading TileSource metadata') + }) + }); + } else { + this._updateExistingTileset(propsChanged, ownsTileset, Boolean(dataChanged), nextTileset); + } + + this._updateTileset(); + } + + /** Registers additional viewports in multi-view rendering scenarios. */ + activateViewport(viewport: Viewport): void { + const viewportKey = viewport.id || 'default'; + const previousViewport = this._knownViewports.get(viewportKey); + this._knownViewports.set(viewportKey, viewport); + if (!previousViewport || !viewport.equals(previousViewport)) { + this.setNeedsUpdate(); + } + super.activateViewport(viewport); + } + + /** Calls the URL-template loader path for a tile when the layer owns the tileset. */ + getTileData(tile: TileLoadProps): Promise | DataT | null { + const {data, getTileData, fetch} = this.props; + const {signal} = tile; + if (!isURLTemplate(data)) { + return null; + } + tile.url = getURLFromTemplate(data, tile); + if (getTileData) { + return getTileData(tile); + } + if (fetch && tile.url) { + return fetch(tile.url, {propName: 'data', layer: this, signal}); + } + return null; + } + + /** Default tile sub-layer renderer, delegating to `renderSubLayers`. */ + renderSubLayers( + props: SharedTile2DLayer['props'] & { + id: string; + data: DataT; + _offset: number; + tile: SharedTile2DHeader; + } + ): Layer | null | LayersList { + return this.props.renderSubLayers(props); + } + + /** Hook for subclasses to provide extra sub-layer props per tile. */ + getSubLayerPropsByTile(_tile: SharedTile2DHeader): Partial | null { + return null; + } + + /** Adds tile references to picking info returned from sub-layers. */ + getPickingInfo(params: GetPickingInfoParams): SharedTile2DLayerPickingInfo { + const sourceLayer = params.sourceLayer!; + const sourceTile: SharedTile2DHeader = (sourceLayer.props as any).tile; + const info = params.info as SharedTile2DLayerPickingInfo; + if (info.picked) { + info.tile = sourceTile; + } + info.sourceTile = sourceTile; + info.sourceTileSubLayer = sourceLayer; + return info; + } + + /** Forwards auto-highlight updates to the picked sub-layer. */ + protected _updateAutoHighlight(info: SharedTile2DLayerPickingInfo): void { + info.sourceTileSubLayer.updateAutoHighlight(info); + } + + /** Renders cached or newly generated sub-layers for each loaded tile. */ + renderLayers(): Layer | null | LayersList { + const {tileset, tileLayers} = this.state; + if (!tileset) { + return null; + } + return tileset.tiles.map(tile => { + const subLayerProps = this.getSubLayerPropsByTile(tile); + let layers = tileLayers.get(tile.id); + if (!tile.isLoaded && !tile.content) { + return layers; + } + if (!layers) { + const rendered = this.renderSubLayers({ + ...this.props, + ...this.getSubLayerProps({ + id: tile.id, + updateTriggers: this.props.updateTriggers + }), + data: tile.content as DataT, + _offset: 0, + tile + }); + layers = this._flattenTileLayers(rendered).map(layer => + layer.clone({tile, ...subLayerProps}) + ); + tileLayers.set(tile.id, layers); + } else if ( + subLayerProps && + layers[0] && + Object.keys(subLayerProps).some( + propName => layers![0].props[propName] !== subLayerProps[propName] + ) + ) { + layers = layers.map(layer => layer.clone(subLayerProps)); + tileLayers.set(tile.id, layers); + } + return layers; + }); + } + + /** Filters tile sub-layers based on the active view-specific visibility state. */ + filterSubLayer({layer, cullRect}: FilterContext) { + const {tile} = (layer as Layer<{tile: SharedTile2DHeader}>).props; + const tilesetView = this._getOrCreateTilesetView(this._getViewportKey()); + return tilesetView.isTileVisible( + tile, + cullRect, + this.props.modelMatrix ? new Matrix4(this.props.modelMatrix) : null + ); + } + + private _resolveTileset( + currentTileset: SharedTileset2D | null, + ownsCurrentTileset: boolean, + nextExternalTileset: SharedTileset2D | null + ): SharedTileset2D { + if (nextExternalTileset) { + return nextExternalTileset; + } + if (currentTileset && ownsCurrentTileset) { + return currentTileset; + } + return new this.props.TilesetClass(this._getTilesetOptions()); + } + + private _releaseTileset( + tileset: SharedTileset2D | null, + ownsTileset: boolean + ): void { + this.state.unsubscribeTilesetEvents?.(); + for (const tilesetView of this.state.tilesetViews.values()) { + tilesetView.finalize(); + } + if (ownsTileset) { + tileset?.finalize(); + } + } + + private _updateExistingTileset( + propsChanged: boolean, + ownsTileset: boolean, + dataChanged: boolean, + tileset: SharedTileset2D + ): void { + if (!propsChanged) { + return; + } + if (ownsTileset) { + tileset.setOptions(this._getTilesetOptions(), {replace: true}); + if (dataChanged) { + tileset.reloadAll(); + return; + } + } + this.state.tileLayers.clear(); + } + + _getTilesetOptions(): SharedTileset2DProps { + const tileSource = isTileSource(this.props.data) ? this.props.data : undefined; + const options: SharedTileset2DProps = { + adapter: sharedTile2DDeckAdapter, + getTileData: tileSource ? undefined : this.getTileData.bind(this), + onTileLoad: () => {}, + onTileError: () => {}, + onTileUnload: () => {} + }; + + if (tileSource) { + options.tileSource = tileSource; + } + for (const key of TILESET_OPTION_KEYS) { + if (Object.hasOwn(this.props, key)) { + options[key] = this.props[key] as never; + } + } + + return options; + } + + private _updateTileset(): void { + const {zRange, modelMatrix} = this.props; + let anyTilesetChanged = false; + + for (const [viewportKey, viewport] of this._knownViewports) { + this._prunePlaceholderViewportView(viewportKey); + const tilesetView = this._getOrCreateTilesetView(viewportKey); + const frameNumber = tilesetView.update(viewport, {zRange, modelMatrix}); + const previousFrameNumber = this.state.frameNumbers.get(viewportKey); + const tilesetChanged = previousFrameNumber !== frameNumber; + anyTilesetChanged ||= tilesetChanged; + + if (tilesetView.isLoaded && tilesetChanged) { + this._onViewportLoad(tilesetView); + } + if (tilesetChanged) { + this.state.frameNumbers.set(viewportKey, frameNumber); + } + } + + const nextIsLoaded = this.isLoaded; + const loadingStateChanged = this.state.isLoaded !== nextIsLoaded; + if (loadingStateChanged) { + for (const tilesetView of this.state.tilesetViews.values()) { + if (tilesetView.isLoaded) { + this._onViewportLoad(tilesetView); + } + } + } + + if (anyTilesetChanged) { + this.setState({frameNumbers: new Map(this.state.frameNumbers)}); + } + this.state.isLoaded = nextIsLoaded; + } + + _onViewportLoad(tilesetView: SharedTile2DView): void { + if (tilesetView.selectedTiles) { + this.props.onViewportLoad?.(tilesetView.selectedTiles); + } + } + + _onTileLoad(tile: SharedTile2DHeader): void { + this.state.tileLayers.delete(tile.id); + this.props.onTileLoad(tile); + this.setNeedsUpdate(); + } + + _onTileError(error: any, tile: SharedTile2DHeader): void { + this.state.tileLayers.delete(tile.id); + this.props.onTileError(error, tile); + this.setNeedsUpdate(); + } + + _onTileUnload(tile: SharedTile2DHeader): void { + this.state.tileLayers.delete(tile.id); + this.props.onTileUnload(tile); + } + + private _getViewportKey(): string { + return this.context.viewport?.id || 'default'; + } + + private _prunePlaceholderViewportView(viewportKey: string): void { + const placeholderViewportKey = 'DEFAULT-INITIAL-VIEWPORT'; + if (viewportKey !== placeholderViewportKey) { + const placeholderView = this.state.tilesetViews.get(placeholderViewportKey); + if (placeholderView) { + placeholderView.finalize(); + this.state.tilesetViews.delete(placeholderViewportKey); + this.state.frameNumbers.delete(placeholderViewportKey); + } + } + } + + private _getOrCreateTilesetView(viewportKey: string): SharedTile2DView { + let tilesetView = this.state.tilesetViews.get(viewportKey); + if (!tilesetView) { + const tileset = this.state.tileset; + if (!tileset) { + throw new Error('SharedTile2DLayer tileset was not initialized.'); + } + tilesetView = new SharedTile2DView(tileset); + this.state.tilesetViews.set(viewportKey, tilesetView); + } + return tilesetView; + } + + private _flattenTileLayers( + rendered: Layer | null | LayersList + ): Layer<{tile?: SharedTile2DHeader}>[] { + return flatten(rendered as any, Boolean) as Layer<{tile?: SharedTile2DHeader}>[]; + } +} diff --git a/modules/geo-layers/src/shared-tile-2d-layer/shared-tile-2d-view.ts b/modules/geo-layers/src/shared-tile-2d-layer/shared-tile-2d-view.ts new file mode 100644 index 00000000000..e0d5c2de16a --- /dev/null +++ b/modules/geo-layers/src/shared-tile-2d-layer/shared-tile-2d-view.ts @@ -0,0 +1,313 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Viewport} from '@deck.gl/core'; +import {Matrix4, equals, type NumericArray} from '@math.gl/core'; + +import { + STRATEGY_DEFAULT, + STRATEGY_NEVER, + STRATEGY_REPLACE, + SharedTile2DHeader, + type SharedTileset2D +} from '../shared-tileset-2d/index'; +import {memoize} from '../tileset-2d/memoize'; +import type {ZRange} from '../tileset-2d/types'; +import {getCullBounds, transformBox} from '../tileset-2d/utils'; +import {sharedTile2DDeckAdapter} from './deck-tileset-adapter'; + +const TILE_STATE_VISITED = 1; +const TILE_STATE_VISIBLE = 2; + +const STRATEGIES = { + [STRATEGY_DEFAULT]: updateTileStateDefault, + [STRATEGY_REPLACE]: updateTileStateReplace, + [STRATEGY_NEVER]: () => {} +}; + +type TileViewState = { + isSelected: boolean; + isVisible: boolean; + state: number; +}; + +/** Per-viewport traversal state for the deck-facing shared tile layer. */ +export class SharedTile2DView { + /** Unique consumer identifier used by the shared tileset cache. */ + readonly id = Symbol('tile-2d-view'); + + private _tileset: SharedTileset2D; + private _selectedTiles: SharedTile2DHeader[] | null = null; + private _frameNumber = 0; + private _viewport: Viewport | null = null; + private _zRange: ZRange | null = null; + private _modelMatrix = new Matrix4(); + private _modelMatrixInverse = new Matrix4(); + private _state = new Map, TileViewState>(); + + /** Creates a viewport-specific view of a shared tileset. */ + constructor(tileset: SharedTileset2D) { + this._tileset = tileset; + if (!this._tileset.adapter) { + this._tileset.setOptions({adapter: sharedTile2DDeckAdapter}); + } + this._tileset.attachConsumer(this.id); + } + + /** Releases this view and detaches it from the shared tileset. */ + finalize(): void { + this._tileset.detachConsumer(this.id); + this._selectedTiles = null; + this._state.clear(); + } + + /** Tiles selected for the last viewport update. */ + get selectedTiles(): SharedTile2DHeader[] | null { + return this._selectedTiles; + } + + /** Indicates whether all selected tiles are fully loaded for this view. */ + get isLoaded(): boolean { + return this._selectedTiles !== null && this._selectedTiles.every(tile => tile.isLoaded); + } + + /** Indicates whether any selected tile needs to be re-requested. */ + get needsReload(): boolean { + return this._selectedTiles !== null && this._selectedTiles.some(tile => tile.needsReload); + } + + /** Updates tile selection and visibility for a viewport and returns the current frame number. */ + update( + viewport: Viewport, + { + zRange, + modelMatrix + }: { + zRange: ZRange | null; + modelMatrix: NumericArray | null; + } = {zRange: null, modelMatrix: null} + ): number { + const modelMatrixAsMatrix4 = modelMatrix ? new Matrix4(modelMatrix) : new Matrix4(); + const isModelMatrixNew = !modelMatrixAsMatrix4.equals(this._modelMatrix); + + if ( + !this._viewport || + !viewport.equals(this._viewport) || + !equals(this._zRange, zRange) || + isModelMatrixNew + ) { + if (isModelMatrixNew) { + this._modelMatrixInverse = modelMatrixAsMatrix4.clone().invert(); + this._modelMatrix = modelMatrixAsMatrix4; + } + this._viewport = viewport; + this._zRange = zRange; + const tileIndices = this._tileset.getTileIndices({ + viewState: viewport, + maxZoom: this._tileset.maxZoom, + minZoom: this._tileset.minZoom, + zRange, + modelMatrix: this._modelMatrix, + modelMatrixInverse: this._modelMatrixInverse + }); + this._selectedTiles = tileIndices.map(index => this._tileset.getTile(index, true)); + this._tileset.prepareTiles(); + } else if (this.needsReload) { + this._selectedTiles = (this._selectedTiles || []).map(tile => + this._tileset.getTile(tile.index, true) + ); + this._tileset.prepareTiles(); + } + + const changed = this._updateTileStates(); + this._tileset.updateConsumer(this.id, this._selectedTiles || [], this._getVisibleTiles()); + + if (changed) { + this._frameNumber++; + } + return this._frameNumber; + } + + /** Tests whether a tile should render in the current viewport and culling rectangle. */ + isTileVisible( + tile: SharedTile2DHeader, + cullRect?: {x: number; y: number; width: number; height: number}, + modelMatrix?: Matrix4 | null + ): boolean { + const state = this._state.get(tile); + if (!state?.isVisible) { + return false; + } + + if (!cullRect || !this._viewport) { + return true; + } + const boundsArr = this._getCullBounds({ + viewport: this._viewport, + z: this._zRange, + cullRect + }); + return boundsArr.some(bounds => this._tileOverlapsBounds(tile, bounds, modelMatrix)); + } + + private _getVisibleTiles(): SharedTile2DHeader[] { + const result: SharedTile2DHeader[] = []; + for (const tile of this._tileset.tiles) { + if (this._state.get(tile)?.isVisible) { + result.push(tile); + } + } + return result; + } + + private _getCullBounds = memoize(getCullBounds); + + private _updateTileStates(): boolean { + const refinementStrategy = this._tileset.refinementStrategy || STRATEGY_DEFAULT; + const allTiles = this._tileset.tiles; + const previousVisibility = new Map, boolean>(); + + for (const tile of allTiles) { + const existing = this._state.get(tile); + previousVisibility.set(tile, existing?.isVisible || false); + this._state.set(tile, {isSelected: false, isVisible: false, state: 0}); + } + + for (const tile of this._selectedTiles || []) { + const state = this._state.get(tile) || {isSelected: false, isVisible: false, state: 0}; + state.isSelected = true; + state.isVisible = true; + this._state.set(tile, state); + } + + STRATEGIES[refinementStrategy](allTiles, this._state); + + let changed = false; + for (const tile of allTiles) { + const state = this._state.get(tile); + if (state && state.isVisible !== previousVisibility.get(tile)) { + changed = true; + } + } + return changed; + } + + private _tileOverlapsBounds( + tile: SharedTile2DHeader, + [minX, minY, maxX, maxY]: [number, number, number, number], + modelMatrix?: Matrix4 | null + ): boolean { + const bbox = this._getTileBoundingBox(tile, modelMatrix); + if ('west' in bbox) { + return bbox.west < maxX && bbox.east > minX && bbox.south < maxY && bbox.north > minY; + } + const y0 = Math.min(bbox.top, bbox.bottom); + const y1 = Math.max(bbox.top, bbox.bottom); + return bbox.left < maxX && bbox.right > minX && y0 < maxY && y1 > minY; + } + + private _getTileBoundingBox(tile: SharedTile2DHeader, modelMatrix?: Matrix4 | null) { + const {bbox} = tile; + if ('west' in bbox || !modelMatrix || Matrix4.IDENTITY.equals(modelMatrix)) { + return bbox; + } + const [left, top, right, bottom] = transformBox( + [bbox.left, bbox.top, bbox.right, bbox.bottom], + modelMatrix + ); + return {left, top, right, bottom}; + } +} + +function updateTileStateDefault( + allTiles: SharedTile2DHeader[], + stateMap: Map +) { + for (const tile of allTiles) { + getTileState(stateMap, tile).state = 0; + } + for (const tile of allTiles) { + if (getTileState(stateMap, tile).isSelected && !getPlaceholderInAncestors(tile, stateMap)) { + getPlaceholderInChildren(tile, stateMap); + } + } + for (const tile of allTiles) { + const state = getTileState(stateMap, tile); + state.isVisible = Boolean(state.state & TILE_STATE_VISIBLE); + } +} + +function updateTileStateReplace( + allTiles: SharedTile2DHeader[], + stateMap: Map +) { + for (const tile of allTiles) { + getTileState(stateMap, tile).state = 0; + } + for (const tile of allTiles) { + if (getTileState(stateMap, tile).isSelected) { + getPlaceholderInAncestors(tile, stateMap); + } + } + const sortedTiles = Array.from(allTiles).sort((t1, t2) => t1.zoom - t2.zoom); + for (const tile of sortedTiles) { + const tileState = getTileState(stateMap, tile); + tileState.isVisible = Boolean(tileState.state & TILE_STATE_VISIBLE); + + if (tile.children && (tileState.isVisible || tileState.state & TILE_STATE_VISITED)) { + for (const child of tile.children) { + getTileState(stateMap, child).state = TILE_STATE_VISITED; + } + } else if (tileState.isSelected) { + getPlaceholderInChildren(tile, stateMap); + } + } +} + +function getPlaceholderInAncestors( + startTile: SharedTile2DHeader, + stateMap: Map +): boolean { + let tile: SharedTile2DHeader | null = startTile.parent; + while (tile) { + const state = getTileState(stateMap, tile); + state.state |= TILE_STATE_VISIBLE | TILE_STATE_VISITED; + if (tile.isLoaded || tile.content) { + return true; + } + tile = tile.parent; + } + return false; +} + +function getPlaceholderInChildren( + tile: SharedTile2DHeader, + stateMap: Map +): void { + const state = getTileState(stateMap, tile); + state.state |= TILE_STATE_VISIBLE | TILE_STATE_VISITED; + + if (!tile.children || !tile.children.length || tile.isLoaded || tile.content) { + return; + } + + for (const child of tile.children) { + const childState = getTileState(stateMap, child); + if (!(childState.state & TILE_STATE_VISITED)) { + getPlaceholderInChildren(child, stateMap); + } + } +} + +function getTileState( + stateMap: Map, + tile: SharedTile2DHeader +): TileViewState { + let tileState = stateMap.get(tile); + if (!tileState) { + tileState = {isSelected: false, isVisible: false, state: 0}; + stateMap.set(tile, tileState); + } + return tileState; +} diff --git a/modules/geo-layers/src/shared-tileset-2d/adapter.ts b/modules/geo-layers/src/shared-tileset-2d/adapter.ts new file mode 100644 index 00000000000..6c86a57f604 --- /dev/null +++ b/modules/geo-layers/src/shared-tileset-2d/adapter.ts @@ -0,0 +1,52 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Matrix4} from '@math.gl/core'; + +import type {Bounds, TileBoundingBox, TileIndex, ZRange} from '../tileset-2d/types'; + +/** Traversal inputs required by a shared tileset adapter. */ +export type SharedTileset2DTraversalContext = { + /** Consumer-defined view state used by the adapter. */ + viewState: ViewStateT; + /** Tile size in pixels. */ + tileSize: number; + /** Bounding box limiting tile generation. */ + extent?: Bounds | null; + /** Minimum zoom level to request. */ + minZoom?: number; + /** Maximum zoom level to request. */ + maxZoom?: number; + /** Integer zoom offset applied during tile selection. */ + zoomOffset?: number; + /** The minimum zoom level at which tiles are visible. */ + visibleMinZoom?: number | null; + /** The maximum zoom level at which tiles are visible. */ + visibleMaxZoom?: number | null; + /** Elevation range used by geospatial tile selection. */ + zRange?: ZRange | null; + /** Optional model matrix applied by the surrounding layer stack. */ + modelMatrix?: Matrix4 | null; + /** Inverse of the current model matrix. */ + modelMatrixInverse?: Matrix4 | null; +}; + +/** Minimal tile metadata inputs required by a shared tileset adapter. */ +export type SharedTileset2DTileContext = { + /** Consumer-defined view state used by the adapter. */ + viewState: ViewStateT; + /** Tile size in pixels. */ + tileSize: number; +}; + +/** Adapter used by a shared tileset to compute traversal and tile bounds. */ +export type SharedTileset2DAdapter = { + /** Returns tile indices that should be selected for one traversal context. */ + getTileIndices: (context: SharedTileset2DTraversalContext) => TileIndex[]; + /** Returns the structured bounding box for one tile index. */ + getTileBoundingBox: ( + context: SharedTileset2DTileContext, + index: TileIndex + ) => TileBoundingBox; +}; diff --git a/modules/geo-layers/src/shared-tileset-2d/index.ts b/modules/geo-layers/src/shared-tileset-2d/index.ts new file mode 100644 index 00000000000..60d482ff124 --- /dev/null +++ b/modules/geo-layers/src/shared-tileset-2d/index.ts @@ -0,0 +1,24 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +export type { + SharedTileset2DAdapter, + SharedTileset2DTileContext, + SharedTileset2DTraversalContext +} from './adapter'; +export type {SharedTile2DLoadDataProps} from './shared-tile-2d-header'; +export type { + SharedRefinementStrategy, + SharedTileset2DBaseProps, + SharedTileset2DListener, + SharedTileset2DProps +} from './shared-tileset-2d'; + +export { + SharedTileset2D, + STRATEGY_DEFAULT, + STRATEGY_NEVER, + STRATEGY_REPLACE +} from './shared-tileset-2d'; +export {SharedTile2DHeader} from './shared-tile-2d-header'; diff --git a/modules/geo-layers/src/shared-tileset-2d/shared-tile-2d-header.ts b/modules/geo-layers/src/shared-tileset-2d/shared-tile-2d-header.ts new file mode 100644 index 00000000000..034337743a0 --- /dev/null +++ b/modules/geo-layers/src/shared-tileset-2d/shared-tile-2d-header.ts @@ -0,0 +1,203 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/* eslint-env browser */ +import {RequestScheduler} from '@loaders.gl/loader-utils'; + +import type {TileBoundingBox, TileIndex, TileLoadProps} from '../tileset-2d/types'; + +/** Parameters used by {@link SharedTile2DHeader.loadData}. */ +export type SharedTile2DLoadDataProps = { + /** Shared request scheduler for throttling tile fetches. */ + requestScheduler: RequestScheduler; + /** Application-provided tile data loader. */ + getData: (props: TileLoadProps) => Promise | DataT | null; + /** Callback fired after tile content resolves successfully. */ + onLoad: (tile: SharedTile2DHeader) => void; + /** Callback fired after tile content fails to load. */ + onError: (error: any, tile: SharedTile2DHeader) => void; +}; + +/** Shared tile cache entry used by {@link SharedTileset2D}. */ +export class SharedTile2DHeader { + /** x/y/z tile coordinate. */ + index: TileIndex; + /** Closest cached ancestor tile in the current tree. */ + parent: SharedTile2DHeader | null; + /** Cached child tiles beneath this tile. */ + children: SharedTile2DHeader[] | null; + /** Loaded tile payload. */ + content: DataT | null; + + /** Stable tile cache id. */ + id!: string; + /** Resolved zoom level for the tile. */ + zoom!: number; + /** Optional application data associated with the tile. */ + userData?: Record; + /** Bounds represented as `[[minX, minY], [maxX, maxY]]`. */ + boundingBox!: [min: number[], max: number[]]; + + private _abortController: AbortController | null; + private _loader: Promise | undefined; + private _loaderId: number; + private _isLoaded: boolean; + private _isCancelled: boolean; + private _needsReload: boolean; + private _bbox!: TileBoundingBox; + + /** Creates a tile header for a specific tile index. */ + constructor(index: TileIndex) { + this.index = index; + this.parent = null; + this.children = []; + this.content = null; + + this._loader = undefined; + this._abortController = null; + this._loaderId = 0; + this._isLoaded = false; + this._isCancelled = false; + this._needsReload = false; + } + + /** Structured bounds for the tile in the active coordinate system. */ + get bbox(): TileBoundingBox { + return this._bbox; + } + + /** Initializes the tile bounds once during tile creation. */ + set bbox(value: TileBoundingBox) { + if (this._bbox) return; + + this._bbox = value; + if ('west' in value) { + this.boundingBox = [ + [value.west, value.south], + [value.east, value.north] + ]; + } else { + this.boundingBox = [ + [value.left, value.top], + [value.right, value.bottom] + ]; + } + } + + /** Resolves to loaded content while a request is in flight. */ + get data(): Promise | DataT | null { + const loader = this._loader; + if (!this._isCancelled && loader !== undefined) { + return loader.then(() => this.data); + } + return this.content; + } + + /** Indicates whether tile content is available and up to date. */ + get isLoaded(): boolean { + return this._isLoaded && !this._needsReload; + } + + /** Indicates whether a tile request is currently in flight. */ + get isLoading(): boolean { + return Boolean(this._loader) && !this._isCancelled; + } + + /** Indicates whether the tile should be requested again. */ + get needsReload(): boolean { + return this._needsReload || this._isCancelled; + } + + /** Estimated byte size of the cached payload. */ + get byteLength(): number { + const result = this.content ? (this.content as any).byteLength : 0; + return Number.isFinite(result) ? result : 0; + } + + private async _loadData({ + getData, + requestScheduler, + onLoad, + onError + }: SharedTile2DLoadDataProps): Promise { + const completeLoad = (tileData: DataT | null, error: unknown, loaderId: number): void => { + if (loaderId !== this._loaderId) { + return; + } + + this._loader = undefined; + this.content = tileData; + + if (this._isCancelled && !tileData) { + this._isLoaded = false; + return; + } + + this._isLoaded = true; + this._isCancelled = false; + + if (error) { + onError(error, this); + return; + } + onLoad(this); + }; + + const {index, id, bbox, userData, zoom} = this; + const loaderId = this._loaderId; + + this._abortController = new AbortController(); + const {signal} = this._abortController; + const requestToken = await requestScheduler.scheduleRequest(this, () => 1); + + if (!requestToken) { + this._isCancelled = true; + return; + } + if (this._isCancelled) { + requestToken.done(); + return; + } + + let tileData: DataT | null = null; + let error; + try { + tileData = await getData({index, id, bbox, userData, zoom, signal}); + } catch (err) { + error = err || true; + } finally { + requestToken.done(); + } + + completeLoad(tileData, error, loaderId); + } + + /** Loads tile data through the shared scheduler. */ + loadData(opts: SharedTile2DLoadDataProps): Promise { + this._isLoaded = false; + this._isCancelled = false; + this._needsReload = false; + this._loaderId++; + this._loader = this._loadData(opts); + return this._loader; + } + + /** Marks the tile stale so it is refreshed on the next traversal. */ + setNeedsReload(): void { + if (this.isLoading) { + this.abort(); + this._loader = undefined; + } + this._needsReload = true; + } + + /** Cancels an in-flight tile request. */ + abort(): void { + if (this.isLoaded) { + return; + } + this._isCancelled = true; + this._abortController?.abort(); + } +} diff --git a/modules/geo-layers/src/shared-tileset-2d/shared-tileset-2d.ts b/modules/geo-layers/src/shared-tileset-2d/shared-tileset-2d.ts new file mode 100644 index 00000000000..3a723094514 --- /dev/null +++ b/modules/geo-layers/src/shared-tileset-2d/shared-tileset-2d.ts @@ -0,0 +1,726 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {RequestScheduler, type TileSource, type TileSourceMetadata} from '@loaders.gl/loader-utils'; +import type {Matrix4} from '@math.gl/core'; +import {Stats} from '@probe.gl/stats'; + +import {STRATEGY_DEFAULT, STRATEGY_NEVER, STRATEGY_REPLACE} from '../tileset-2d/tileset-2d'; +import type {TileIndex, TileLoadProps, ZRange} from '../tileset-2d/types'; +import type {SharedTileset2DAdapter, SharedTileset2DTileContext} from './adapter'; +import {SharedTile2DHeader} from './shared-tile-2d-header'; + +export {STRATEGY_DEFAULT, STRATEGY_NEVER, STRATEGY_REPLACE}; + +/** Shared-safe placeholder refinement strategies. */ +export type SharedRefinementStrategy = + | typeof STRATEGY_NEVER + | typeof STRATEGY_REPLACE + | typeof STRATEGY_DEFAULT; + +/** Core configuration shared by all {@link SharedTileset2D} instances. */ +export type SharedTileset2DBaseProps = { + /** Callback used to load tile payloads. */ + getTileData: (props: TileLoadProps) => Promise | DataT | null; + /** Adapter used to compute tile traversal and tile metadata. */ + adapter?: SharedTileset2DAdapter | null; + /** Bounding box limiting tile generation. */ + extent?: number[] | null; + /** Tile size in pixels. */ + tileSize?: number; + /** Maximum zoom level to request. */ + maxZoom?: number | null; + /** Minimum zoom level to request. */ + minZoom?: number | null; + /** Maximum number of tiles kept in cache. */ + maxCacheSize?: number | null; + /** Maximum bytes kept in cache. */ + maxCacheByteSize?: number | null; + /** Placeholder refinement strategy. */ + refinementStrategy?: SharedRefinementStrategy; + /** Elevation range used by geospatial tile selection. */ + zRange?: ZRange | null; + /** Maximum concurrent tile requests. */ + maxRequests?: number; + /** Debounce interval applied before issuing queued requests. */ + debounceTime?: number; + /** Integer zoom offset applied when choosing tile levels. */ + zoomOffset?: number; + /** The minimum zoom level at which tiles are visible. */ + visibleMinZoom?: number | null; + /** The maximum zoom level at which tiles are visible. */ + visibleMaxZoom?: number | null; + /** Callback fired when a tile loads successfully. */ + onTileLoad?: (tile: SharedTile2DHeader) => void; + /** Callback fired when a tile is evicted from cache. */ + onTileUnload?: (tile: SharedTile2DHeader) => void; + /** Callback fired when a tile request fails. */ + onTileError?: (err: any, tile: SharedTile2DHeader) => void; +}; + +/** Options for creating a shared tile cache that can be reused by multiple layers and views. */ +export type SharedTileset2DProps = Omit< + SharedTileset2DBaseProps, + 'getTileData' +> & { + /** Optional tile loader used when not sourcing data from a loaders.gl TileSource. */ + getTileData?: (props: TileLoadProps) => Promise | DataT | null; + /** Optional loaders.gl TileSource backing this shared tileset. */ + tileSource?: TileSource; +}; + +/** Subscription callbacks emitted by {@link SharedTileset2D}. */ +export type SharedTileset2DListener = { + /** Fired after a tile loads successfully. */ + onTileLoad?: (tile: SharedTile2DHeader) => void; + /** Fired after a tile request fails. */ + onTileError?: (error: any, tile: SharedTile2DHeader) => void; + /** Fired after a tile is evicted from cache. */ + onTileUnload?: (tile: SharedTile2DHeader) => void; + /** Fired when metadata or effective configuration changes. */ + onUpdate?: () => void; + /** Fired when asynchronous metadata initialization fails. */ + onError?: (error: Error) => void; + /** Fired after live tileset counters are recomputed. */ + onStatsChange?: (stats: Stats) => void; +}; + +type ResolvedSharedTileset2DProps = Required< + SharedTileset2DProps +>; + +type ConsumerState = { + selectedTiles: Set>; + visibleTiles: Set>; +}; + +const DEFAULT_SHARED_TILESET2D_PROPS: Omit< + ResolvedSharedTileset2DProps, + 'getTileData' | 'tileSource' +> = { + adapter: null, + extent: null, + tileSize: 512, + maxZoom: null, + minZoom: null, + maxCacheSize: 100, + maxCacheByteSize: null, + refinementStrategy: STRATEGY_DEFAULT, + zRange: null, + maxRequests: 6, + debounceTime: 0, + zoomOffset: 0, + visibleMinZoom: null, + visibleMaxZoom: null, + onTileLoad: () => {}, + onTileUnload: () => {}, + onTileError: () => {} +}; + +/** Shared tile cache and loading engine for one or more shared tile layer instances. */ +export class SharedTileset2D { + /** Live counters describing shared tileset state. */ + readonly stats: Stats; + /** Effective runtime options after defaults and metadata overrides have been applied. */ + protected opts: ResolvedSharedTileset2DProps; + /** Cached metadata returned by the backing TileSource, if any. */ + protected sourceMetadata: TileSourceMetadata | null = null; + + private _requestScheduler: RequestScheduler; + private _cache: Map>; + private _dirty: boolean; + private _tiles: SharedTile2DHeader[]; + private _cacheByteSize: number; + private _unloadedTileCount: number; + private _listeners = new Set>(); + private _consumers = new Map>(); + private _explicitOptionKeys = new Set(); + private _baseOpts: Partial> = {}; + private _sourceMetadataOverrides: Partial> = {}; + private _sourceMetadataGeneration = 0; + private _maxZoom?: number; + private _minZoom?: number; + private _lastTileContext: SharedTileset2DTileContext | null = null; + + /** Creates a tileset from either `getTileData` or a loaders.gl `TileSource`. */ + constructor(opts: SharedTileset2DProps) { + if (!opts.tileSource && !opts.getTileData) { + throw new Error('SharedTileset2D requires either `getTileData` or `tileSource`.'); + } + + this.stats = new Stats({ + id: 'SharedTileset2D', + stats: [ + {name: 'Tiles In Cache'}, + {name: 'Cache Size'}, + {name: 'Visible Tiles'}, + {name: 'Selected Tiles'}, + {name: 'Loading Tiles'}, + {name: 'Unloaded Tiles'}, + {name: 'Consumers'} + ] + }); + this.opts = this._resolveOptions(); + this._requestScheduler = this._createRequestScheduler(); + this._cache = new Map(); + this._tiles = []; + this._dirty = false; + this._cacheByteSize = 0; + this._unloadedTileCount = 0; + + this.setOptions(opts); + this._updateStats(); + } + + /** Convenience factory for wrapping a loaders.gl `TileSource`. */ + static fromTileSource( + tileSource: TileSource, + opts: Omit, 'tileSource' | 'getTileData'> = {} + ): SharedTileset2D { + return new SharedTileset2D({...opts, tileSource}); + } + + /** All tiles currently present in the shared cache. */ + get tiles(): SharedTile2DHeader[] { + return this._tiles; + } + + /** Estimated byte size of all tile content currently retained in cache. */ + get cacheByteSize(): number { + return this._cacheByteSize; + } + + /** Union of tiles selected by all attached consumers. */ + get selectedTiles(): SharedTile2DHeader[] { + return Array.from(this._getSelectedTilesUnion()); + } + + /** Union of tiles contributing to the visible result across all consumers and views. */ + get visibleTiles(): SharedTile2DHeader[] { + const union = this._getVisibleTilesUnion(); + for (const tile of this._getSelectedTilesUnion()) { + union.add(tile); + } + return Array.from(union); + } + + /** Tiles currently loading anywhere in the shared cache. */ + get loadingTiles(): SharedTile2DHeader[] { + return Array.from(this._cache.values()).filter(tile => tile.isLoading); + } + + /** Maximum resolved zoom level after applying metadata and explicit options. */ + get maxZoom(): number | undefined { + return this._maxZoom; + } + + /** Minimum resolved zoom level after applying metadata and explicit options. */ + get minZoom(): number | undefined { + return this._minZoom; + } + + /** Active refinement strategy for placeholder handling. */ + get refinementStrategy(): SharedRefinementStrategy { + return this.opts.refinementStrategy || STRATEGY_DEFAULT; + } + + /** Adapter currently used for traversal and tile metadata. */ + get adapter(): SharedTileset2DAdapter | null { + return this.opts.adapter; + } + + /** Subscribes to tileset lifecycle events. */ + subscribe(listener: SharedTileset2DListener): () => void { + this._listeners.add(listener); + return () => this._listeners.delete(listener); + } + + /** Registers a consumer so cache pruning can account for its selected tiles. */ + attachConsumer(id: symbol): void { + this._consumers.set(id, {selectedTiles: new Set(), visibleTiles: new Set()}); + this._updateStats(); + } + + /** Unregisters a consumer and prunes unused requests and tiles. */ + detachConsumer(id: symbol): void { + this._consumers.delete(id); + this._pruneRequests(); + this._resizeCache(); + this._updateStats(); + } + + /** Updates tileset options and reapplies TileSource metadata overrides. */ + setOptions( + opts: Partial>, + {replace = false}: {replace?: boolean} = {} + ): void { + const previousTileSource = this._baseOpts.tileSource; + const previousMaxRequests = this.opts.maxRequests; + const previousDebounceTime = this.opts.debounceTime; + + if (replace) { + this._baseOpts = {}; + this._explicitOptionKeys.clear(); + } + + this._rememberExplicitOptions(opts); + this._baseOpts = {...this._baseOpts, ...opts}; + + const nextTileSource = this._baseOpts.tileSource; + const tileSourceChanged = nextTileSource !== previousTileSource; + if (tileSourceChanged) { + this.sourceMetadata = null; + this._sourceMetadataOverrides = {}; + this._sourceMetadataGeneration++; + } else { + this._sourceMetadataOverrides = this._getMetadataOverrides(this.sourceMetadata); + } + + this.opts = this._resolveOptions(); + if ( + previousMaxRequests !== this.opts.maxRequests || + previousDebounceTime !== this.opts.debounceTime + ) { + this._requestScheduler = this._createRequestScheduler(); + } + + if (nextTileSource && tileSourceChanged) { + const generation = this._sourceMetadataGeneration; + this._initializeTileSource(nextTileSource, generation).catch(() => {}); + } + if (tileSourceChanged && this._cache.size > 0) { + this.reloadAll(); + } + + this._notifyUpdate(); + } + + /** Aborts in-flight requests and clears the shared cache. */ + finalize(): void { + for (const tile of this._cache.values()) { + if (tile.isLoading) { + tile.abort(); + } + } + this._cache.clear(); + this._tiles = []; + this._consumers.clear(); + this._cacheByteSize = 0; + this._unloadedTileCount = 0; + this._updateStats(); + } + + /** Marks all retained tiles stale and drops unused cached tiles. */ + reloadAll(): void { + const selectedTiles = this._getSelectedTilesUnion(); + for (const [id, tile] of this._cache) { + if (!selectedTiles.has(tile)) { + this._cache.delete(id); + this._handleTileUnload(tile); + } else { + tile.setNeedsReload(); + } + } + this._cacheByteSize = this._getCacheByteSize(); + this._dirty = true; + this.prepareTiles(); + this._updateStats(); + } + + /** Updates the selected and visible tile sets for one consumer. */ + updateConsumer( + id: symbol, + selectedTiles: SharedTile2DHeader[], + visibleTiles: SharedTile2DHeader[] + ): void { + this._consumers.set(id, { + selectedTiles: new Set(selectedTiles), + visibleTiles: new Set(visibleTiles) + }); + this._pruneRequests(); + this._resizeCache(); + this._updateStats(); + } + + /** Rebuilds parent/child links if the cache changed since the last traversal. */ + prepareTiles(): void { + if (this._dirty) { + this._rebuildTree(); + this._syncTiles(); + this._dirty = false; + } + } + + /** Returns tile indices needed to cover a viewport. */ + getTileIndices({ + viewState, + maxZoom, + minZoom, + zRange, + modelMatrix, + modelMatrixInverse + }: { + viewState: ViewStateT; + maxZoom?: number; + minZoom?: number; + zRange: ZRange | null; + modelMatrix?: Matrix4 | null; + modelMatrixInverse?: Matrix4 | null; + }): TileIndex[] { + const {adapter, tileSize, extent, zoomOffset, visibleMinZoom, visibleMaxZoom} = this.opts; + if (!adapter) { + throw new Error('SharedTileset2D requires an adapter before tile traversal can be used.'); + } + this._lastTileContext = {viewState, tileSize}; + return adapter.getTileIndices({ + viewState, + maxZoom, + minZoom, + zRange, + tileSize, + extent: normalizeBounds(extent), + modelMatrix, + modelMatrixInverse, + zoomOffset, + visibleMinZoom, + visibleMaxZoom + }); + } + + /** Returns the stable cache id for a tile index. */ + getTileId(index: TileIndex): string { + return `${index.x}-${index.y}-${index.z}`; + } + + /** Returns the zoom level represented by a tile index. */ + getTileZoom(index: TileIndex): number { + return index.z; + } + + /** Returns derived metadata used to initialize a tile header. */ + getTileMetadata(index: TileIndex): Record { + if (!this._lastTileContext) { + throw new Error('SharedTileset2D metadata requested before traversal context was set.'); + } + if (!this.opts.adapter) { + throw new Error('SharedTileset2D requires an adapter before tile metadata can be derived.'); + } + return {bbox: this.opts.adapter.getTileBoundingBox(this._lastTileContext, index)}; + } + + /** Returns the parent tile index in the quadtree. */ + getParentIndex(index: TileIndex): TileIndex { + return {x: Math.floor(index.x / 2), y: Math.floor(index.y / 2), z: index.z - 1}; + } + + /** Returns a cached tile and optionally creates and loads it on demand. */ + getTile(index: TileIndex, create: true): SharedTile2DHeader; + getTile(index: TileIndex, create?: false): SharedTile2DHeader | undefined; + getTile(index: TileIndex, create?: boolean): SharedTile2DHeader | undefined { + const id = this.getTileId(index); + let tile = this._cache.get(id); + let needsReload = false; + + if (!tile && create) { + tile = new SharedTile2DHeader(index); + Object.assign(tile, this.getTileMetadata(tile.index)); + Object.assign(tile, {id, zoom: this.getTileZoom(tile.index)}); + needsReload = true; + this._cache.set(id, tile); + this._dirty = true; + this._updateStats(); + } else if (tile && tile.needsReload) { + needsReload = true; + } + + if (tile) { + this._touchTile(id, tile); + } + + if (tile && needsReload) { + tile + .loadData({ + getData: this.opts.getTileData, + requestScheduler: this._requestScheduler, + onLoad: this._handleTileLoad.bind(this), + onError: this._handleTileError.bind(this) + }) + .catch(() => {}); + this._updateStats(); + } + + return tile; + } + + private _createRequestScheduler(): RequestScheduler { + return new RequestScheduler({ + throttleRequests: this.opts.maxRequests > 0 || this.opts.debounceTime > 0, + maxRequests: this.opts.maxRequests, + debounceTime: this.opts.debounceTime + }); + } + + private async _initializeTileSource(tileSource: TileSource, generation: number): Promise { + try { + const sourceMetadata = await tileSource.getMetadata(); + if ( + generation !== this._sourceMetadataGeneration || + tileSource !== this._baseOpts.tileSource + ) { + return; + } + this.sourceMetadata = sourceMetadata; + this._sourceMetadataOverrides = this._getMetadataOverrides(sourceMetadata); + this.opts = this._resolveOptions(); + this._notifyUpdate(); + } catch (error: any) { + if ( + generation !== this._sourceMetadataGeneration || + tileSource !== this._baseOpts.tileSource + ) { + return; + } + const normalizedError = + error instanceof Error ? error : new Error(`TileSource metadata error: ${String(error)}`); + this._notifyError(normalizedError); + } + } + + private _rememberExplicitOptions(opts: Partial>): void { + for (const key of Object.keys(opts)) { + this._explicitOptionKeys.add(key); + } + } + + private _resolveOptions(): ResolvedSharedTileset2DProps { + const resolvedOpts = { + ...DEFAULT_SHARED_TILESET2D_PROPS, + getTileData: () => null, + tileSource: undefined, + ...this._sourceMetadataOverrides, + ...this._baseOpts + } as ResolvedSharedTileset2DProps; + + if (resolvedOpts.tileSource) { + const tileSource = resolvedOpts.tileSource; + resolvedOpts.getTileData = (loadProps: TileLoadProps) => + tileSource.getTileData(loadProps) as Promise | DataT | null; + } + + this._maxZoom = + typeof resolvedOpts.maxZoom === 'number' && Number.isFinite(resolvedOpts.maxZoom) + ? Math.floor(resolvedOpts.maxZoom) + : undefined; + this._minZoom = + typeof resolvedOpts.minZoom === 'number' && Number.isFinite(resolvedOpts.minZoom) + ? Math.ceil(resolvedOpts.minZoom) + : undefined; + + return resolvedOpts; + } + + private _getMetadataOverrides( + metadata: TileSourceMetadata | null + ): Partial> { + if (!metadata) { + return {}; + } + const overrides: Partial> = {}; + if (!this._explicitOptionKeys.has('minZoom') && Number.isFinite(metadata.minZoom)) { + overrides.minZoom = metadata.minZoom; + } + if (!this._explicitOptionKeys.has('maxZoom') && Number.isFinite(metadata.maxZoom)) { + overrides.maxZoom = metadata.maxZoom; + } + if (!this._explicitOptionKeys.has('extent') && metadata.boundingBox) { + overrides.extent = [ + metadata.boundingBox[0][0], + metadata.boundingBox[0][1], + metadata.boundingBox[1][0], + metadata.boundingBox[1][1] + ]; + } + return overrides; + } + + private _handleTileLoad(tile: SharedTile2DHeader): void { + this.opts.onTileLoad?.(tile); + this._cacheByteSize = this._getCacheByteSize(); + this._resizeCache(); + for (const listener of this._listeners) { + listener.onTileLoad?.(tile); + } + this._updateStats(); + } + + private _handleTileError(error: any, tile: SharedTile2DHeader): void { + this.opts.onTileError?.(error, tile); + for (const listener of this._listeners) { + listener.onTileError?.(error, tile); + } + this._updateStats(); + } + + private _handleTileUnload(tile: SharedTile2DHeader): void { + this._unloadedTileCount++; + this.opts.onTileUnload?.(tile); + for (const listener of this._listeners) { + listener.onTileUnload?.(tile); + } + this._updateStats(); + } + + private _notifyUpdate(): void { + for (const listener of this._listeners) { + listener.onUpdate?.(); + } + } + + private _notifyError(error: Error): void { + for (const listener of this._listeners) { + listener.onError?.(error); + } + } + + private _updateStats(): void { + this._setStatCount('Tiles In Cache', this._cache.size); + this._setStatCount('Cache Size', this.cacheByteSize); + this._setStatCount('Visible Tiles', this.visibleTiles.length); + this._setStatCount('Selected Tiles', this.selectedTiles.length); + this._setStatCount('Loading Tiles', this.loadingTiles.length); + this._setStatCount('Unloaded Tiles', this._unloadedTileCount); + this._setStatCount('Consumers', this._consumers.size); + + for (const listener of this._listeners) { + listener.onStatsChange?.(this.stats); + } + } + + private _setStatCount(name: string, value: number): void { + this.stats.get(name).reset().addCount(value); + } + + private _getSelectedTilesUnion(): Set> { + const union = new Set>(); + for (const consumer of this._consumers.values()) { + for (const tile of consumer.selectedTiles) { + union.add(tile); + } + } + return union; + } + + private _getVisibleTilesUnion(): Set> { + const union = new Set>(); + for (const consumer of this._consumers.values()) { + for (const tile of consumer.visibleTiles) { + union.add(tile); + } + } + return union; + } + + private _touchTile(id: string, tile: SharedTile2DHeader): void { + this._cache.delete(id); + this._cache.set(id, tile); + } + + private _getCacheByteSize(): number { + let byteLength = 0; + for (const tile of this._cache.values()) { + byteLength += tile.byteLength; + } + return byteLength; + } + + private _pruneRequests(): void { + const {maxRequests = 0} = this.opts; + const selectedTiles = this._getSelectedTilesUnion(); + const visibleTiles = this._getVisibleTilesUnion(); + const abortCandidates: SharedTile2DHeader[] = []; + let ongoingRequestCount = 0; + + for (const tile of this._cache.values()) { + if (tile.isLoading) { + ongoingRequestCount++; + if (!selectedTiles.has(tile) && !visibleTiles.has(tile)) { + abortCandidates.push(tile); + } + } + } + + while (maxRequests > 0 && ongoingRequestCount > maxRequests && abortCandidates.length > 0) { + const tile = abortCandidates.shift(); + if (tile) { + tile.abort(); + } + ongoingRequestCount--; + } + } + + private _rebuildTree(): void { + for (const tile of this._cache.values()) { + tile.parent = null; + if (tile.children) { + tile.children.length = 0; + } + } + for (const tile of this._cache.values()) { + const parent = this._getNearestAncestor(tile); + tile.parent = parent; + if (parent?.children) { + parent.children.push(tile); + } + } + } + + private _syncTiles(): void { + this._tiles = Array.from(this._cache.values()).sort((t1, t2) => t1.zoom - t2.zoom); + } + + private _resizeCache(): void { + const maxCacheSize = this.opts.maxCacheSize ?? 100; + const maxCacheByteSize = this.opts.maxCacheByteSize ?? Infinity; + const visibleTiles = this._getVisibleTilesUnion(); + const selectedTiles = this._getSelectedTilesUnion(); + const overflown = this._cache.size > maxCacheSize || this._cacheByteSize > maxCacheByteSize; + + if (overflown) { + for (const [id, tile] of this._cache) { + if (!visibleTiles.has(tile) && !selectedTiles.has(tile)) { + this._cache.delete(id); + this._cacheByteSize = this._getCacheByteSize(); + this._handleTileUnload(tile); + } + if (this._cache.size <= maxCacheSize && this._cacheByteSize <= maxCacheByteSize) { + break; + } + } + this._dirty = true; + } + + if (this._dirty) { + this._rebuildTree(); + this._syncTiles(); + this._dirty = false; + } + } + + private _getNearestAncestor(tile: SharedTile2DHeader): SharedTile2DHeader | null { + const {_minZoom = 0} = this; + let index = tile.index; + while (this.getTileZoom(index) > _minZoom) { + index = this.getParentIndex(index); + const parent = this._cache.get(this.getTileId(index)); + if (parent) { + return parent; + } + } + return null; + } +} + +function normalizeBounds(extent: number[] | null | undefined) { + return extent && extent.length === 4 ? (extent as [number, number, number, number]) : undefined; +} diff --git a/modules/main/src/index.ts b/modules/main/src/index.ts index 655aa8ce3e3..67817e9a566 100644 --- a/modules/main/src/index.ts +++ b/modules/main/src/index.ts @@ -119,6 +119,10 @@ export { H3ClusterLayer, H3HexagonLayer, TileLayer, + _SharedTile2DLayer, + _SharedTileset2D, + _SharedTile2DHeader, + sharedTile2DDeckAdapter, _Tileset2D, TripsLayer, Tile3DLayer, @@ -224,7 +228,19 @@ export type { ScreenGridLayerProps } from '@deck.gl/aggregation-layers'; -export type {MVTLayerProps, QuadkeyLayerProps, TileLayerProps} from '@deck.gl/geo-layers'; +export type { + MVTLayerProps, + QuadkeyLayerProps, + SharedRefinementStrategy, + SharedTile2DLayerPickingInfo, + SharedTile2DLayerProps, + SharedTileset2DAdapter, + SharedTileset2DBaseProps, + SharedTileset2DProps, + SharedTileset2DTileContext, + SharedTileset2DTraversalContext, + TileLayerProps +} from '@deck.gl/geo-layers'; export type {DeckGLProps, DeckGLRef, DeckGLContextValue} from '@deck.gl/react'; diff --git a/test/modules/geo-layers/index.ts b/test/modules/geo-layers/index.ts index 5521444b3ba..afdf93f53fc 100644 --- a/test/modules/geo-layers/index.ts +++ b/test/modules/geo-layers/index.ts @@ -12,6 +12,9 @@ import { _WMSLayer as WMSLayer, QuadkeyLayer, S2Layer, + _SharedTile2DHeader as SharedTile2DHeader, + _SharedTile2DLayer as SharedTile2DLayer, + _SharedTileset2D as SharedTileset2D, TileLayer, TripsLayer, TerrainLayer, @@ -26,6 +29,9 @@ test('Top-level imports', () => { expect(H3HexagonLayer, 'H3HexagonLayer symbol imported').toBeTruthy(); expect(H3ClusterLayer, 'H3ClusterLayer symbol imported').toBeTruthy(); expect(TileLayer, 'TileLayer symbol imported').toBeTruthy(); + expect(SharedTile2DLayer, 'SharedTile2DLayer symbol imported').toBeTruthy(); + expect(SharedTileset2D, 'SharedTileset2D symbol imported').toBeTruthy(); + expect(SharedTile2DHeader, 'SharedTile2DHeader symbol imported').toBeTruthy(); expect(WMSLayer, 'WMSLayer symbol imported').toBeTruthy(); expect(TripsLayer, 'TripsLayer symbol imported').toBeTruthy(); expect(TerrainLayer, 'TerrainLayer symbol imported').toBeTruthy(); @@ -34,6 +40,7 @@ test('Top-level imports', () => { import './a5-layer.spec'; import './tile-layer'; +import './shared-tile-2d-layer.spec'; import './wms-layer.spec'; import './quadkey-layer.spec'; import './s2-layer.spec'; diff --git a/test/modules/geo-layers/shared-tile-2d-layer.spec.ts b/test/modules/geo-layers/shared-tile-2d-layer.spec.ts new file mode 100644 index 00000000000..783920fed6d --- /dev/null +++ b/test/modules/geo-layers/shared-tile-2d-layer.spec.ts @@ -0,0 +1,567 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {OrthographicViewport, WebMercatorViewport} from '@deck.gl/core'; +import {ScatterplotLayer} from '@deck.gl/layers'; +import { + _SharedTile2DHeader as SharedTile2DHeader, + _SharedTile2DLayer as SharedTile2DLayer, + _SharedTileset2D as SharedTileset2D, + sharedTile2DDeckAdapter, + type SharedTileset2DAdapter +} from '@deck.gl/geo-layers'; +import {RequestScheduler, type TileSource} from '@loaders.gl/loader-utils'; +import {testLayerAsync} from '@deck.gl/test-utils/vitest'; +import {describe, expect, it} from 'vitest'; + +import {SharedTile2DView} from '../../../modules/geo-layers/src/shared-tile-2d-layer/shared-tile-2d-view'; + +const mockTilesetAdapter: SharedTileset2DAdapter = { + getTileIndices: () => [], + getTileBoundingBox: (_context, index) => ({ + left: index.x, + top: index.y, + right: index.x + 1, + bottom: index.y + 1 + }) +}; + +type TestTileData = Array<{tileId: string; position: [number, number]}> & {byteLength?: number}; + +function createTestTileData(tileId: string): TestTileData { + const result = [{tileId, position: [0, 0]}] as TestTileData; + result.byteLength = 16; + return result; +} + +function createMockTileSource( + overrides: Partial & { + getMetadata?: TileSource['getMetadata']; + getTileData?: TileSource['getTileData']; + } = {} +): TileSource { + return { + getMetadata: () => + Promise.resolve({ + minZoom: 1, + maxZoom: 4, + boundingBox: [ + [-10, -20], + [30, 40] + ] + }), + getTile: () => Promise.resolve(null), + getTileData: ({id}) => Promise.resolve(createTestTileData(id)), + ...overrides + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise(resolvePromise => { + resolve = resolvePromise; + }); + return {promise, resolve}; +} + +async function waitFor(condition: () => boolean, message: string): Promise { + for (let i = 0; i < 50; i++) { + if (condition()) { + return; + } + await new Promise(resolve => setTimeout(resolve, 0)); + } + throw new Error(message); +} + +describe('SharedTile2DLayer', () => { + it('exports from the package surface', () => { + expect(SharedTile2DLayer).toBeDefined(); + expect(SharedTileset2D).toBeDefined(); + expect(sharedTile2DDeckAdapter).toBeDefined(); + }); + + it('builds URL-template requests through the layer path', () => { + let requestedUrl: string | undefined | null; + const layer = new SharedTile2DLayer({ + id: 'shared-tile-url-template', + data: 'https://example.com/{z}/{x}/{y}.json', + getTileData: tile => { + requestedUrl = tile.url; + return null; + } + }); + + const tileData = layer.getTileData({ + index: {x: 3, y: 5, z: 2}, + id: '3-5-2', + bbox: {west: 0, south: 0, east: 1, north: 1}, + zoom: 2 + }); + + expect(tileData).toBeNull(); + expect(requestedUrl).toBe('https://example.com/2/3/5.json'); + }); + + it('runs the deck layer lifecycle with TileLayer-style rendering and refetches', async () => { + let tileDataLoadCount = 0; + let tileLoadCount = 0; + let viewportLoadCount = 0; + let autoHighlightCount = 0; + + const viewport = new WebMercatorViewport({ + id: 'main', + width: 100, + height: 100, + longitude: 0, + latitude: 60, + zoom: 2 + }); + const renderSubLayers = props => + new ScatterplotLayer(props, { + id: `${props.id}-points`, + data: props.data + }); + + await testLayerAsync({ + Layer: SharedTile2DLayer, + viewport, + testCases: [ + { + title: 'initial load', + props: { + data: 'https://example.com/{z}/{x}/{y}.json', + getTileData: ({id}) => { + tileDataLoadCount++; + return createTestTileData(id); + }, + onTileLoad: () => { + tileLoadCount++; + }, + onViewportLoad: () => { + viewportLoadCount++; + }, + renderSubLayers + }, + onAfterUpdate: ({layer, subLayers}) => { + if (!layer.isLoaded) { + return; + } + expect(tileDataLoadCount).toBe(2); + expect(tileLoadCount).toBe(2); + expect(viewportLoadCount).toBeGreaterThan(0); + expect(subLayers).toHaveLength(2); + expect(subLayers.every(subLayer => layer.filterSubLayer({layer: subLayer}))).toBe(true); + + const sourceLayer = subLayers[0]; + sourceLayer.updateAutoHighlight = () => { + autoHighlightCount++; + }; + const pickedInfo = layer.getPickingInfo({ + info: {picked: true}, + sourceLayer + } as any); + expect(pickedInfo.tile).toBe(sourceLayer.props.tile); + expect(pickedInfo.sourceTile).toBe(sourceLayer.props.tile); + expect(pickedInfo.sourceTileSubLayer).toBe(sourceLayer); + (layer as any)._updateAutoHighlight(pickedInfo); + expect(autoHighlightCount).toBe(1); + + const unpickedInfo = layer.getPickingInfo({ + info: {picked: false}, + sourceLayer + } as any); + expect(unpickedInfo.tile).toBeUndefined(); + } + }, + { + title: 'reuse loaded sublayers', + onAfterUpdate: ({layer, subLayers}) => { + if (layer.isLoaded) { + expect(subLayers).toHaveLength(2); + expect(tileDataLoadCount).toBe(2); + } + } + }, + { + title: 'invalidate rendered sublayers', + updateProps: {opacity: 0.5}, + onAfterUpdate: ({layer, subLayers}) => { + if (layer.isLoaded) { + expect(subLayers).toHaveLength(2); + expect(tileDataLoadCount).toBe(2); + } + } + }, + { + title: 'refetch loaded tiles', + updateProps: {updateTriggers: {getTileData: 1}}, + onAfterUpdate: ({layer, subLayers}) => { + if (layer.isLoaded) { + expect(subLayers).toHaveLength(2); + expect(tileDataLoadCount).toBe(4); + expect(tileLoadCount).toBe(4); + } + } + } + ], + onError: error => { + throw error; + } + }); + }); + + it('adopts TileSource metadata with explicit overrides winning', async () => { + const tileset = SharedTileset2D.fromTileSource(createMockTileSource(), {minZoom: 2}); + await waitFor(() => tileset.maxZoom === 4, 'expected TileSource metadata to resolve'); + expect(tileset.minZoom).toBe(2); + expect(tileset.maxZoom).toBe(4); + expect((tileset as any).opts.extent).toEqual([-10, -20, 30, 40]); + tileset.finalize(); + }); + + it('ignores stale TileSource metadata after the source changes', async () => { + const firstMetadata = createDeferred(); + const secondMetadata = createDeferred(); + const firstSource = createMockTileSource({getMetadata: () => firstMetadata.promise}); + const secondSource = createMockTileSource({getMetadata: () => secondMetadata.promise}); + const tileset = SharedTileset2D.fromTileSource(firstSource); + + tileset.setOptions({tileSource: secondSource}); + secondMetadata.resolve({ + minZoom: 2, + maxZoom: 8, + boundingBox: [ + [0, 0], + [1, 1] + ] + }); + await waitFor(() => tileset.maxZoom === 8, 'expected second TileSource metadata to resolve'); + + firstMetadata.resolve({ + minZoom: 1, + maxZoom: 4, + boundingBox: [ + [-1, -1], + [1, 1] + ] + }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(tileset.minZoom).toBe(2); + expect(tileset.maxZoom).toBe(8); + expect((tileset as any).opts.extent).toEqual([0, 0, 1, 1]); + tileset.finalize(); + }); + + it('normalizes TileSource metadata errors for subscribers', async () => { + let metadataError: Error | null = null; + const tileset = SharedTileset2D.fromTileSource( + createMockTileSource({getMetadata: () => Promise.reject('metadata failure')}) + ); + const unsubscribe = tileset.subscribe({ + onError: error => { + metadataError = error; + } + }); + + await waitFor(() => metadataError !== null, 'expected TileSource metadata error'); + expect(metadataError).toBeInstanceOf(Error); + expect(metadataError?.message).toBe('TileSource metadata error: metadata failure'); + + unsubscribe(); + tileset.finalize(); + }); + + it('accepts TileSource data in SharedTile2DLayer options', () => { + const source = createMockTileSource(); + const layer = new SharedTile2DLayer({ + id: 'shared-tile-source', + data: source + }); + + const tilesetOptions = (layer as any)._getTilesetOptions(); + expect(tilesetOptions.tileSource).toBe(source); + expect(tilesetOptions.getTileData).toBeUndefined(); + expect(tilesetOptions).not.toHaveProperty('minZoom'); + expect(tilesetOptions).not.toHaveProperty('maxZoom'); + }); + + it('shares one SharedTileset2D across multiple consumers and views', async () => { + const sharedTileset = SharedTileset2D.fromTileSource(createMockTileSource()); + const leftView = new SharedTile2DView(sharedTileset); + const rightView = new SharedTile2DView(sharedTileset); + let statsChangeCount = 0; + const unsubscribe = sharedTileset.subscribe({ + onStatsChange: () => { + statsChangeCount++; + } + }); + + const leftViewport = new OrthographicViewport({ + id: 'left', + width: 200, + height: 200, + target: [100, 100], + zoom: 1 + }); + const rightViewport = new OrthographicViewport({ + id: 'right', + width: 200, + height: 200, + target: [500, 500], + zoom: 1 + }); + + try { + leftView.update(leftViewport); + rightView.update(rightViewport); + + await waitFor( + () => Boolean(leftView.isLoaded && rightView.isLoaded), + 'expected both views to finish loading shared tiles' + ); + leftView.update(leftViewport); + rightView.update(rightViewport); + + const leftTileIds = new Set(leftView.selectedTiles?.map(tile => tile.id)); + const rightTileIds = new Set(rightView.selectedTiles?.map(tile => tile.id)); + + expect(leftTileIds.size).toBeGreaterThan(0); + expect(rightTileIds.size).toBeGreaterThan(0); + expect([...leftTileIds].some(id => !rightTileIds.has(id))).toBe(true); + expect(sharedTileset.tiles.length).toBeGreaterThanOrEqual( + leftTileIds.size + rightTileIds.size - 1 + ); + expect(sharedTileset.visibleTiles.some(tile => leftTileIds.has(tile.id))).toBe(true); + expect(sharedTileset.visibleTiles.some(tile => rightTileIds.has(tile.id))).toBe(true); + expect(sharedTileset.stats.get('Visible Tiles').count).toBe( + sharedTileset.visibleTiles.length + ); + expect(sharedTileset.stats.get('Tiles In Cache').count).toBe(sharedTileset.tiles.length); + expect(sharedTileset.stats.get('Cache Size').count).toBeGreaterThan(0); + expect(sharedTileset.stats.get('Consumers').count).toBe(2); + expect(statsChangeCount).toBeGreaterThan(0); + + leftView.finalize(); + rightView.update(rightViewport); + expect(rightView.selectedTiles?.length).toBeGreaterThan(0); + } finally { + unsubscribe(); + rightView.finalize(); + sharedTileset.finalize(); + } + }); + + it('evicts least recently used non-visible tiles once the cache exceeds the high-water mark', () => { + const tileset = new SharedTileset2D({ + getTileData: () => null, + maxCacheSize: 2, + adapter: mockTilesetAdapter + }); + tileset.getTileIndices({viewState: 'cache-test', zRange: null}); + + const consumerId = Symbol('consumer'); + tileset.attachConsumer(consumerId); + + const tile1 = tileset.getTile({x: 0, y: 0, z: 1}, true); + const tile2 = tileset.getTile({x: 1, y: 0, z: 1}, true); + tileset.updateConsumer(consumerId, [tile1, tile2], [tile1, tile2]); + + tileset.getTile({x: 0, y: 0, z: 1}, true); + + const tile3 = tileset.getTile({x: 0, y: 1, z: 1}, true); + tileset.updateConsumer(consumerId, [tile3], [tile3]); + + expect(tileset.tiles.map(tile => tile.id)).toContain('0-0-1'); + expect(tileset.tiles.map(tile => tile.id)).toContain('0-1-1'); + expect(tileset.tiles.map(tile => tile.id)).not.toContain('1-0-1'); + expect(tileset.stats.get('Tiles In Cache').count).toBe(2); + expect(tileset.stats.get('Unloaded Tiles').count).toBe(1); + + tileset.detachConsumer(consumerId); + tileset.finalize(); + }); + + it('throws if traversal is requested without an adapter', () => { + const tileset = new SharedTileset2D({ + getTileData: () => null + }); + + expect(() => + tileset.getTileIndices({ + viewState: 'missing-adapter', + zRange: null + }) + ).toThrow('SharedTileset2D requires an adapter before tile traversal can be used.'); + + tileset.finalize(); + }); + + it('covers shared tile header loading, reload, and abort branches', async () => { + const tile = new SharedTile2DHeader({x: 0, y: 0, z: 0}); + tile.id = '0-0-0'; + tile.zoom = 0; + tile.bbox = {left: 0, top: 1, right: 2, bottom: 3}; + tile.bbox = {west: 0, south: 0, east: 1, north: 1}; + expect(tile.boundingBox).toEqual([ + [0, 1], + [2, 3] + ]); + + const requestScheduler = new RequestScheduler({throttleRequests: false}); + const firstLoad = createDeferred(); + const firstLoadPromise = tile.loadData({ + requestScheduler, + getData: () => firstLoad.promise, + onLoad: () => {}, + onError: () => {} + }); + expect(tile.data).toBeInstanceOf(Promise); + tile.setNeedsReload(); + firstLoad.resolve(null); + await firstLoadPromise; + expect(tile.isLoaded).toBe(false); + expect(tile.needsReload).toBe(true); + + const tileData = createTestTileData(tile.id); + await tile.loadData({ + requestScheduler, + getData: () => tileData, + onLoad: () => {}, + onError: () => {} + }); + expect(tile.data).toBe(tileData); + expect(tile.byteLength).toBe(16); + expect(tile.isLoaded).toBe(true); + tile.abort(); + expect(tile.isLoaded).toBe(true); + + const geoTile = new SharedTile2DHeader({x: 1, y: 1, z: 1}); + geoTile.bbox = {west: -1, south: -2, east: 3, north: 4}; + expect(geoTile.boundingBox).toEqual([ + [-1, -2], + [3, 4] + ]); + }); + + it('does not finalize an external shared tileset when the layer finalizes', async () => { + const sharedTileset = SharedTileset2D.fromTileSource(createMockTileSource()); + const layer = new SharedTile2DLayer({ + id: 'shared-tile-external-tileset', + data: sharedTileset + }); + const externalView = new SharedTile2DView(sharedTileset); + const viewport = new WebMercatorViewport({ + width: 256, + height: 256, + longitude: 0, + latitude: 0, + zoom: 2 + }); + + externalView.update(viewport); + await waitFor( + () => externalView.isLoaded, + 'expected shared tileset to load before finalization' + ); + + (layer as any).state = { + tileset: sharedTileset, + tilesetViews: new Map(), + ownsTileset: false, + isLoaded: false, + frameNumbers: new Map(), + tileLayers: new Map(), + unsubscribeTilesetEvents: null + }; + + layer.finalizeState(); + + externalView.update(viewport); + expect(sharedTileset.tiles.length).toBeGreaterThan(0); + + externalView.finalize(); + sharedTileset.finalize(); + }); + + it('requests an update when a new viewport is activated', () => { + const layer = new SharedTile2DLayer({ + id: 'shared-tile-activate-viewport', + data: createMockTileSource() + }); + let updatesRequested = 0; + (layer as any).setNeedsUpdate = () => { + updatesRequested++; + }; + + const minimapViewport = new WebMercatorViewport({ + id: 'minimap', + width: 256, + height: 256, + longitude: 0, + latitude: 0, + zoom: 2 + }); + + layer.activateViewport(minimapViewport); + expect(updatesRequested).toBe(1); + + layer.activateViewport(minimapViewport); + expect(updatesRequested).toBe(1); + }); + + it('settles errored tile loads so the view can finish loading', async () => { + let errors = 0; + const tileset = new SharedTileset2D({ + adapter: sharedTile2DDeckAdapter, + getTileData: async () => { + throw new Error('expected tile error'); + }, + onTileError: () => { + errors++; + } + }); + const view = new SharedTile2DView(tileset); + const viewport = new WebMercatorViewport({ + width: 256, + height: 256, + longitude: 0, + latitude: 0, + zoom: 2 + }); + + view.update(viewport); + await waitFor(() => view.isLoaded, 'expected errored tiles to settle'); + + expect(errors).toBeGreaterThan(0); + expect(view.selectedTiles?.every(tile => tile.isLoaded && tile.content === null)).toBe(true); + + view.finalize(); + tileset.finalize(); + }); + + it('honors visible zoom bounds during shared traversal', () => { + const tileset = new SharedTileset2D({ + adapter: sharedTile2DDeckAdapter, + getTileData: () => null, + visibleMinZoom: 2 + }); + const view = new SharedTile2DView(tileset); + const viewport = new WebMercatorViewport({ + width: 256, + height: 256, + longitude: 0, + latitude: 0, + zoom: 1 + }); + + view.update(viewport); + expect(view.selectedTiles).toEqual([]); + expect(tileset.tiles).toEqual([]); + + view.finalize(); + tileset.finalize(); + }); +}); diff --git a/website/src/examples-sidebar.js b/website/src/examples-sidebar.js index cdf4e6c5318..e2293d6beb8 100644 --- a/website/src/examples-sidebar.js +++ b/website/src/examples-sidebar.js @@ -36,6 +36,7 @@ const sidebars = { 'scatterplot-layer', 'scenegraph-layer', 'screen-grid-layer', + 'shared-tile-2d-layer', 'terrain-layer', 'text-layer', 'text-layer-clipping', diff --git a/website/src/examples/shared-tile-2d-layer.js b/website/src/examples/shared-tile-2d-layer.js new file mode 100644 index 00000000000..0eb50c7572e --- /dev/null +++ b/website/src/examples/shared-tile-2d-layer.js @@ -0,0 +1,44 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import React, {Component} from 'react'; +import {GITHUB_TREE} from '../constants/defaults'; +import App from 'website-examples/shared-tile-2d-layer/app'; + +import {makeExample} from '../components'; + +class SharedTile2DLayerDemo extends Component { + static title = 'Shared Raster Tiles'; + + static code = `${GITHUB_TREE}/examples/website/shared-tile-2d-layer`; + + static mapStyle = null; + + static renderInfo(meta) { + return ( +
+

+ One experimental SharedTileset2D feeds tiled BitmapLayers in the main view and minimap. +

+

+ OpenStreetMap data source: + Tile Servers +

+
+ Tiles in last viewport{meta.tileCount || 0} +
+
+ ); + } + + _onTilesLoad = tileCount => { + this.props.onStateChange({tileCount}); + }; + + render() { + return ; + } +} + +export default makeExample(SharedTile2DLayerDemo); diff --git a/website/src/examples/shared-tile-2d-layer.mdx b/website/src/examples/shared-tile-2d-layer.mdx new file mode 100644 index 00000000000..fb4d138da63 --- /dev/null +++ b/website/src/examples/shared-tile-2d-layer.mdx @@ -0,0 +1,5 @@ +# Shared Tile 2D Layer + +import Demo from './shared-tile-2d-layer'; + +