diff --git a/docs/api-reference/geo-layers/terrain-layer.md b/docs/api-reference/geo-layers/terrain-layer.md index 6af090ed051..8600c76a45e 100644 --- a/docs/api-reference/geo-layers/terrain-layer.md +++ b/docs/api-reference/geo-layers/terrain-layer.md @@ -145,7 +145,10 @@ new deck.TerrainLayer({}); ## Properties -When in Tiled Mode, inherits from all [TileLayer](./tile-layer.md) properties. Forwards `wireframe` property to [SimpleMeshLayer](../mesh-layers/simple-mesh-layer.md). +When in Tiled Mode, inherits from all [TileLayer](./tile-layer.md) properties. Tiled terrain +forwards `renderPlaceholder` to its internal `TileLayer`; this can be used to render loading +footprints before terrain meshes are available. Forwards `wireframe` property to +[SimpleMeshLayer](../mesh-layers/simple-mesh-layer.md). diff --git a/docs/api-reference/geo-layers/tile-layer.md b/docs/api-reference/geo-layers/tile-layer.md index f0326a28cfe..30c0627660b 100644 --- a/docs/api-reference/geo-layers/tile-layer.md +++ b/docs/api-reference/geo-layers/tile-layer.md @@ -302,6 +302,10 @@ If not supplied, the `maxCacheByteSize` is set to `Infinity`. How the tile layer refines the visibility of tiles. When zooming in and out, if the layer only shows tiles from the current zoom level, then the user may observe undesirable flashing while new data is loading. By setting `refinementStrategy` the layer can attempt to maintain visual continuity by displaying cached data from a different zoom level before data is available. +`refinementStrategy` only reuses loaded tile content that is already in the cache. To render +synthetic content while a selected tile has no loaded content and no cached ancestor or child is +visible, use [`renderPlaceholder`](#renderplaceholder). + This prop accepts one of the following: * `'best-available'`: If a tile in the current viewport is waiting for its data to load, use cached content from the closest zoom level to fill the empty space. This approach minimizes the visual flashing due to missing content. @@ -358,6 +362,44 @@ Note that the following sub layer props are overridden by `TileLayer` internally - `visible` (toggled based on tile visibility) - `highlightedObjectIndex` (set based on the parent layer's highlight state) +#### `renderPlaceholder` (Function, optional) {#renderplaceholder} + +Renders one or an array of Layer instances for a selected tile while its data is loading. + +This prop is disabled by default. When supplied, it is called for selected tiles that have no loaded +content and no generated sublayers. With the default `refinementStrategy: 'best-available'`, cached +ancestor or child content still takes priority, so placeholders only fill cold-start or cache-miss +gaps. With `refinementStrategy: 'no-overlap'`, placeholders are shown instead of cached refinement +content for selected loading tiles. + +The callback receives all the `TileLayer` props and the following props: + +* `id` (string): A unique id for this placeholder sublayer +* `data` (null): Placeholder tiles do not have loaded tile data +* `bounds` (number[4]): Bounds of the tile in `[left, bottom, right, top]` order +* `tile` ([Tile](#tile)) + +- Default: `null` + +For raster tiles, return a `BitmapLayer` that spans `props.bounds`. Set `pickable: false` if +placeholder layers should not participate in picking. + +```ts +renderPlaceholder: props => { + const {data, bounds, ...otherProps} = props; + + return new BitmapLayer(otherProps, { + image: 'data:image/png;base64,...', + bounds, + pickable: false, + opacity: 0.35 + }); +} +``` + +Placeholder sublayers do not make the tile or layer loaded. `isLoaded`, `onViewportLoad`, tile cache +behavior, and tile error handling continue to depend on the real tile request. + #### `zRange` (number[2], optional) {#zrange} An array representing the height range of the content in the tiles, as `[minZ, maxZ]`. This is designed to support tiles with 2.5D content, such as buildings or terrains. At high pitch angles, such a tile may "extrude into" the viewport even if its 2D bounding box is out of view. Therefore, it is necessary to provide additional information for the layer to behave correctly. The value of this prop is used for two purposes: 1) to determine the necessary tiles to load and/or render; 2) to determine the possible intersecting tiles during picking. diff --git a/examples/website/image-tile/app.tsx b/examples/website/image-tile/app.tsx index f274c074e66..e9c68d581a5 100644 --- a/examples/website/image-tile/app.tsx +++ b/examples/website/image-tile/app.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -/* global fetch, DOMParser */ +/* global fetch, DOMParser, setTimeout */ import React, {useState, useEffect} from 'react'; import {createRoot} from 'react-dom/client'; @@ -24,6 +24,11 @@ const INITIAL_VIEW_STATE: OrthographicViewState = { const ROOT_URL = 'https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/image-tiles/moon.image'; +const PLACEHOLDER_IMAGE = + 'data:image/svg+xml;charset=utf-8,' + + '%3Csvg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"%3E' + + '%3Crect width="8" height="8" fill="%23242a2e"/%3E' + + '%3Cpath d="M0 8 8 0" stroke="%23404a50" stroke-width="1"/%3E%3C/svg%3E'; function getTooltip({tile, bitmap}: TileLayerPickingInfo) { if (tile && bitmap) { @@ -35,11 +40,19 @@ function getTooltip({tile, bitmap}: TileLayerPickingInfo { + return new Promise(resolve => setTimeout(resolve, ms)); +} + export default function App({ autoHighlight = true, + showPlaceholders = true, + loadDelay = 0, onTilesLoad }: { autoHighlight?: boolean; + showPlaceholders?: boolean; + loadDelay?: number; onTilesLoad?: () => void; }) { const [dimensions, setDimensions] = useState<{width: number; height: number; tileSize: number}>(); @@ -85,14 +98,37 @@ export default function App({ minZoom: -7, maxZoom: 0, extent: [0, 0, dimensions.width, dimensions.height], - getTileData: ({index}) => { + getTileData: async ({index}) => { const {x, y, z} = index; + if (loadDelay > 0) { + await sleep(loadDelay); + } return load( `${ROOT_URL}/moon.image_files/${15 + z}/${x}_${y}.jpeg` ) as Promise; }, onViewportLoad: onTilesLoad, + renderPlaceholder: showPlaceholders + ? props => { + const {width, height} = dimensions; + const {bounds} = props; + const otherProps = {...props} as Partial; + delete otherProps.data; + delete otherProps.bounds; + return new BitmapLayer(otherProps, { + image: PLACEHOLDER_IMAGE, + bounds: [ + clamp(bounds[0], 0, width), + clamp(bounds[1], 0, height), + clamp(bounds[2], 0, width), + clamp(bounds[3], 0, height) + ], + pickable: false, + opacity: 0.5 + }); + } + : undefined, renderSubLayers: props => { const [[left, bottom], [right, top]] = props.tile.boundingBox; const {width, height} = dimensions; diff --git a/examples/website/map-tile/README.md b/examples/website/map-tile/README.md index ab13fbbc60a..2ed70750a7b 100644 --- a/examples/website/map-tile/README.md +++ b/examples/website/map-tile/README.md @@ -1,9 +1,9 @@ -This is a standalone version of the [OpenStreetMap](https://www.openstreetmap.org/) example using TileLayer and BitmapLayer -on [deck.gl](http://deck.gl) website. +This is a standalone version of the GlobeView terrain tile example using TerrainLayer +on the [deck.gl](http://deck.gl) website. ### Usage -Copy the content of this folder to your project. +Copy the content of this folder to your project. ```bash # install dependencies @@ -14,9 +14,12 @@ yarn npm start ``` -### Data Source +### Data Sources -The sample tiles are loaded from [OpenStreetMap](https://www.openstreetmap.org). +The sample elevation tiles are loaded from the +[AWS Open Data Terrain Tiles](https://registry.opendata.aws/terrain-tiles/) dataset. +The satellite texture is loaded from +[ArcGIS World Imagery](https://www.arcgis.com/home/item.html?id=10df2279f9684e4a9f6a7f08febac2a9). To use your own data, check out -the [documentation of TileLayer](../../../docs/api-reference/geo-layers/tile-layer.md). +the [documentation of TerrainLayer](../../../docs/api-reference/geo-layers/terrain-layer.md). diff --git a/examples/website/map-tile/app.tsx b/examples/website/map-tile/app.tsx index fab5b1e160b..de907bd2eab 100644 --- a/examples/website/map-tile/app.tsx +++ b/examples/website/map-tile/app.tsx @@ -2,70 +2,168 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors +/* global fetch, setTimeout */ import React, {useState, useCallback} from 'react'; import {createRoot} from 'react-dom/client'; import {DeckGL} from '@deck.gl/react'; -import {MapView} from '@deck.gl/core'; -import {TileLayer} from '@deck.gl/geo-layers'; -import {BitmapLayer, PathLayer} from '@deck.gl/layers'; +import {_GlobeView as GlobeView} from '@deck.gl/core'; +import {TerrainLayer} from '@deck.gl/geo-layers'; +import {GeoJsonLayer} from '@deck.gl/layers'; import ZoomRangeWidget from './zoom-range-widget'; -import type {Position, MapViewState} from '@deck.gl/core'; -import type {TileLayerPickingInfo} from '@deck.gl/geo-layers'; +import type {GlobeViewState, PickingInfo, Position} from '@deck.gl/core'; +import type {TerrainLayerProps, TileLayerRenderPlaceholderProps} from '@deck.gl/geo-layers'; -const INITIAL_VIEW_STATE: MapViewState = { - latitude: 47.65, - longitude: 7, - zoom: 4.5, - maxZoom: 20, - maxPitch: 89, - bearing: 0 +const INITIAL_VIEW_STATE: GlobeViewState = { + latitude: 34, + longitude: -105, + zoom: 2.2, + maxZoom: 7 }; -// Approximate bounding box of France [west, south, east, north] -const FRANCE_EXTENT = [-5.14, 41.33, 9.56, 51.09]; +const ELEVATION_DATA = 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'; +const SURFACE_IMAGE = + 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; -const COPYRIGHT_LICENSE_STYLE: React.CSSProperties = { - position: 'absolute', - right: 0, - bottom: 0, - backgroundColor: 'hsla(0,0%,100%,.5)', - padding: '0 5px', - font: '12px/20px Helvetica Neue,Arial,Helvetica,sans-serif' +// https://github.com/tilezen/joerd/blob/master/docs/formats.md +const ELEVATION_DECODER: TerrainLayerProps['elevationDecoder'] = { + rScaler: 256, + gScaler: 1, + bScaler: 1 / 256, + offset: -32768 }; -const LINK_STYLE: React.CSSProperties = { - textDecoration: 'none', - color: 'rgba(0,0,0,.75)', - cursor: 'grab' +const PLACEHOLDER_FILL_COLOR: [number, number, number, number] = [236, 240, 239, 255]; +const PLACEHOLDER_LINE_COLOR: [number, number, number, number] = [88, 103, 106, 255]; +const PLACEHOLDER_GRID_ZOOM = 3; +const PLACEHOLDER_ELEVATION = -12000; + +type TileBounds = [west: number, south: number, east: number, north: number]; + +type PlaceholderFeature = { + type: 'Feature'; + properties: Record; + geometry: { + type: 'Polygon'; + coordinates: Position[][]; + }; }; -function getTooltip({tile}: TileLayerPickingInfo) { - if (tile) { - const {x, y, z} = tile.index; - return `tile: x: ${x}, y: ${y}, z: ${z}`; +function getTooltip(info: PickingInfo) { + if (info.picked && info.coordinate && info.coordinate.length === 3) { + return `Elevation: ${info.coordinate[2].toFixed(0)} m`; } return null; } +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function getTileLongitude(x: number, z: number): number { + return (x / Math.pow(2, z)) * 360 - 180; +} + +function getTileLatitude(y: number, z: number): number { + const y2 = Math.PI - (2 * Math.PI * y) / Math.pow(2, z); + return (Math.atan(Math.sinh(y2)) * 180) / Math.PI; +} + +function getTileBounds({x, y, z}: {x: number; y: number; z: number}): TileBounds { + const lastRow = Math.pow(2, z) - 1; + return [ + getTileLongitude(x, z), + y === lastRow ? -90 : getTileLatitude(y + 1, z), + getTileLongitude(x + 1, z), + y === 0 ? 90 : getTileLatitude(y, z) + ]; +} + +function getTileBoundsFromArray( + [west, south, east, north]: TileBounds, + index: {y: number; z: number} +): TileBounds { + const lastRow = Math.pow(2, index.z) - 1; + return [west, index.y === lastRow ? -90 : south, east, index.y === 0 ? 90 : north]; +} + +function getPlaceholderFeature([west, south, east, north]: TileBounds): PlaceholderFeature { + return { + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [west, north], + [east, north], + [east, south], + [west, south], + [west, north] + ].map(([longitude, latitude]) => [longitude, latitude, PLACEHOLDER_ELEVATION]) + ] + } + }; +} + +function getPlaceholderGridFeatures(): PlaceholderFeature[] { + const tileCount = Math.pow(2, PLACEHOLDER_GRID_ZOOM); + const features: PlaceholderFeature[] = []; + + for (let y = 0; y < tileCount; y++) { + for (let x = 0; x < tileCount; x++) { + features.push(getPlaceholderFeature(getTileBounds({x, y, z: PLACEHOLDER_GRID_ZOOM}))); + } + } + return features; +} + +const PLACEHOLDER_GRID_FEATURES = getPlaceholderGridFeatures(); + +function getPlaceholderLayer(id: string, data: PlaceholderFeature | PlaceholderFeature[]) { + return new GeoJsonLayer({ + id, + data, + filled: true, + stroked: true, + getFillColor: PLACEHOLDER_FILL_COLOR, + getLineColor: PLACEHOLDER_LINE_COLOR, + lineWidthUnits: 'pixels', + getLineWidth: 1, + pickable: false + }); +} + +const renderTerrainPlaceholder: NonNullable = ( + props: TileLayerRenderPlaceholderProps +) => { + return getPlaceholderLayer( + `${props.id}-placeholder`, + getPlaceholderFeature(getTileBoundsFromArray(props.bounds as TileBounds, props.tile.index)) + ); +}; + export default function App({ - showBorder = false, + showPlaceholders = true, + loadDelay = 0, onTilesLoad, onZoomChange, - minZoom = 4, - maxZoom = 7, + minZoom = 0, + maxZoom = 6, visibleMinZoom, - visibleMaxZoom = 7, - useExtent = false + visibleMaxZoom, + showBorder = false }: { - showBorder?: boolean; - onTilesLoad?: () => void; + showPlaceholders?: boolean; + loadDelay?: number; + onTilesLoad?: TerrainLayerProps['onViewportLoad']; onZoomChange?: (zoom: number) => void; minZoom?: number; maxZoom?: number; visibleMinZoom?: number; visibleMaxZoom?: number; + showBorder?: boolean; useExtent?: boolean; }) { const [zoom, setZoom] = useState(INITIAL_VIEW_STATE.zoom); @@ -76,61 +174,52 @@ export default function App({ }, [onZoomChange] ); + const loadOptions = + loadDelay > 0 + ? { + fetch: async ( + url: Parameters[0], + options?: Parameters[1] + ) => { + await sleep(loadDelay); + return fetch(url, options); + } + } + : undefined; - const tileLayer = new TileLayer({ - // https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers - data: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], - - // Since these OSM tiles support HTTP/2, we can make many concurrent requests - // and we aren't limited by the browser to a certain number per domain. - maxRequests: 20, - - pickable: true, - onViewportLoad: onTilesLoad, - autoHighlight: showBorder, - highlightColor: [60, 60, 60, 40], - // https://wiki.openstreetmap.org/wiki/Zoom_levels + const terrainLayer = new TerrainLayer({ + id: 'terrain', minZoom, maxZoom, - tileSize: 512, visibleMinZoom, visibleMaxZoom, - extent: useExtent ? FRANCE_EXTENT : undefined, - renderSubLayers: props => { - const [[west, south], [east, north]] = props.tile.boundingBox; - const {data, ...otherProps} = props; - - return [ - new BitmapLayer(otherProps, { - image: data, - bounds: [west, south, east, north] - }), - showBorder && - new PathLayer({ - id: `${props.id}-border`, - data: [ - [ - [west, north], - [west, south], - [east, south], - [east, north], - [west, north] - ] - ], - getPath: d => d, - getColor: [255, 0, 0], - widthMinPixels: 4 - }) - ]; - } + tileSize: 512, + maxRequests: 12, + refinementStrategy: 'no-overlap', + elevationData: ELEVATION_DATA, + texture: SURFACE_IMAGE, + elevationDecoder: ELEVATION_DECODER, + meshMaxError: 12, + wireframe: showBorder, + color: [255, 255, 255], + pickable: '3d', + loadOptions, + onViewportLoad: onTilesLoad, + renderPlaceholder: showPlaceholders ? renderTerrainPlaceholder : undefined }); + const layers = [ + showPlaceholders && getPlaceholderLayer('placeholder-grid', PLACEHOLDER_GRID_FEATURES), + terrainLayer + ]; + return ( @@ -141,16 +230,10 @@ export default function App({ visibleMinZoom={visibleMinZoom} visibleMaxZoom={visibleMaxZoom} /> - ); } export function renderToDOM(container: HTMLDivElement) { - createRoot(container).render(); + createRoot(container).render(); } diff --git a/modules/geo-layers/src/index.ts b/modules/geo-layers/src/index.ts index e9b8fcaba2d..1e4581fe1df 100644 --- a/modules/geo-layers/src/index.ts +++ b/modules/geo-layers/src/index.ts @@ -27,7 +27,12 @@ export type {H3ClusterLayerProps} from './h3-layers/h3-cluster-layer'; 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 { + TileLayerProps, + TileLayerPickingInfo, + TileLayerRenderSubLayersProps, + TileLayerRenderPlaceholderProps +} from './tile-layer/tile-layer'; export type {TripsLayerProps} from './trips-layer/trips-layer'; export type {QuadkeyLayerProps} from './quadkey-layer/quadkey-layer'; export type {TerrainLayerProps} from './terrain-layer/terrain-layer'; diff --git a/modules/geo-layers/src/terrain-layer/terrain-layer.ts b/modules/geo-layers/src/terrain-layer/terrain-layer.ts index 74eefb62696..f1b3de2e8c9 100644 --- a/modules/geo-layers/src/terrain-layer/terrain-layer.ts +++ b/modules/geo-layers/src/terrain-layer/terrain-layer.ts @@ -355,7 +355,8 @@ export default class TerrainLayer extends Composite onTileError, maxCacheSize, maxCacheByteSize, - refinementStrategy + refinementStrategy, + renderPlaceholder } = this.props; if (this.state.isTiled) { @@ -366,6 +367,7 @@ export default class TerrainLayer extends Composite { getTileData: this.getTiledTerrainData.bind(this), renderSubLayers: this.renderSubLayers.bind(this), + renderPlaceholder, updateTriggers: { getTileData: { elevationData: urlTemplateToUpdateTrigger(elevationData), diff --git a/modules/geo-layers/src/tile-layer/tile-layer.ts b/modules/geo-layers/src/tile-layer/tile-layer.ts index de5e6b3a8fb..ad8412055de 100644 --- a/modules/geo-layers/src/tile-layer/tile-layer.ts +++ b/modules/geo-layers/src/tile-layer/tile-layer.ts @@ -17,7 +17,7 @@ import { import {GeoJsonLayer} from '@deck.gl/layers'; import {LayersList} from '@deck.gl/core'; -import type {TileLoadProps, ZRange} from '../tileset-2d/index'; +import type {Bounds, TileBoundingBox, TileLoadProps, ZRange} from '../tileset-2d/index'; import { Tileset2D, Tile2DHeader, @@ -28,11 +28,14 @@ import { import {urlType, URLTemplate, getURLFromTemplate} from '../tileset-2d/index'; import {Matrix4} from '@math.gl/core'; +const TILE_PLACEHOLDER_LAYER_PROP = '_isTilePlaceholder'; + const defaultProps: DefaultProps = { TilesetClass: Tileset2D, data: {type: 'data', value: []}, dataComparator: urlType.equal, renderSubLayers: {type: 'function', value: (props: any) => new GeoJsonLayer(props)}, + renderPlaceholder: {type: 'function', optional: true, value: null}, getTileData: {type: 'function', optional: true, value: null}, // TODO - change to onViewportLoad to align with Tile3DLayer onViewportLoad: {type: 'function', optional: true, value: null}, @@ -58,6 +61,33 @@ const defaultProps: DefaultProps = { /** All props supported by the TileLayer */ export type TileLayerProps = CompositeLayerProps & _TileLayerProps; +/** Properties passed to `TileLayer`'s `renderSubLayers` callback. */ +export type TileLayerRenderSubLayersProps = TileLayerProps & { + /** A unique id for this sublayer. */ + id: string; + /** Tile data resolved from `getTileData`. */ + data: DataT; + /** The tile whose data is being rendered. */ + tile: Tile2DHeader; + _offset: number; +}; + +/** Properties passed to `TileLayer`'s `renderPlaceholder` callback. */ +export type TileLayerRenderPlaceholderProps = Omit< + TileLayerProps, + 'data' +> & { + /** A unique id for this placeholder sublayer. */ + id: string; + /** Placeholder tiles do not have loaded data yet. */ + data: null; + /** Bounds of the tile in `[left, bottom, right, top]` order. */ + bounds: Bounds; + /** The tile whose loading footprint is being rendered. */ + tile: Tile2DHeader; + _offset: number; +}; + /** Props added by the TileLayer */ type _TileLayerProps = { data: URLTemplate; @@ -68,14 +98,16 @@ type _TileLayerProps = { /** * Renders one or an array of Layer instances. */ - renderSubLayers?: ( - props: TileLayerProps & { - id: string; - data: DataT; - _offset: number; - tile: Tile2DHeader; - } - ) => Layer | null | LayersList; + renderSubLayers?: (props: TileLayerRenderSubLayersProps) => Layer | null | LayersList; + /** + * Renders one or an array of Layer instances for a selected tile whose data is still loading. + * + * This callback is only used when no cached ancestor or child tile is already visible for the + * same area. + */ + renderPlaceholder?: + | ((props: TileLayerRenderPlaceholderProps) => Layer | null | LayersList) + | null; /** * If supplied, `getTileData` is called to retrieve the data of each tile. */ @@ -359,17 +391,14 @@ export default class TileLayer extends return null; } - renderSubLayers( - props: TileLayer['props'] & { - id: string; - data: DataT; - _offset: number; - tile: Tile2DHeader; - } - ): Layer | null | LayersList { + renderSubLayers(props: TileLayerRenderSubLayersProps): Layer | null | LayersList { return this.props.renderSubLayers(props); } + renderPlaceholder(props: TileLayerRenderPlaceholderProps): Layer | null | LayersList { + return this.props.renderPlaceholder?.(props) || null; + } + getSubLayerPropsByTile(tile: Tile2DHeader): Partial | null { return null; } @@ -392,23 +421,38 @@ export default class TileLayer extends } renderLayers(): Layer | null | LayersList { - const {visibleMinZoom, visibleMaxZoom, minZoom, extent} = this.props; - const zoom = this.context.viewport.zoom; - const hidden = - (visibleMinZoom != null && zoom < visibleMinZoom) || - (visibleMaxZoom != null && zoom > visibleMaxZoom) || - (minZoom != null && !extent && zoom < minZoom); - if (hidden) { + if (this._isLayerHidden()) { // Clear cached sublayer references so they are recreated when visible again for (const tile of this.state.tileset!.tiles) { tile.layers = null; } return []; } - return this.state.tileset!.tiles.map((tile: Tile2DHeader) => { + return this.state.tileset!.tiles.map((tile: Tile2DHeader) => { const subLayerProps = this.getSubLayerPropsByTile(tile); // cache the rendered layer in the tile - if (!tile.isLoaded && !tile.content) { + if (this._isPlaceholderCandidate(tile)) { + if (!tile.layers) { + const layers = this.renderPlaceholder({ + ...this.props, + ...this.getSubLayerProps({ + id: tile.id, + updateTriggers: this.props.updateTriggers + }), + data: null, + bounds: getTileBounds(tile.bbox), + _offset: 0, + tile + }); + tile.layers = (flatten(layers, Boolean) as Layer<{tile?: Tile2DHeader}>[]).map(layer => + layer.clone({ + [TILE_PLACEHOLDER_LAYER_PROP]: true, + tile, + ...subLayerProps + }) + ); + } + } else if (!tile.isLoaded && !tile.content) { // nothing to show } else if (!tile.layers) { const layers = this.renderSubLayers({ @@ -441,7 +485,19 @@ export default class TileLayer extends } filterSubLayer({layer, cullRect}: FilterContext) { - const {tile} = (layer as Layer<{tile: Tile2DHeader}>).props; + const layerProps = ( + layer as Layer<{ + tile: Tile2DHeader; + [TILE_PLACEHOLDER_LAYER_PROP]?: boolean; + }> + ).props; + const {tile} = layerProps; + if (layerProps[TILE_PLACEHOLDER_LAYER_PROP]) { + return this._isPlaceholderCandidate(tile); + } + if (this._isRefinementTileHiddenByPlaceholder(tile)) { + return false; + } const {modelMatrix} = this.props; return this.state.tileset!.isTileVisible( tile, @@ -449,4 +505,93 @@ export default class TileLayer extends modelMatrix ? new Matrix4(modelMatrix) : null ); } + + private _isLayerHidden(): boolean { + const {visibleMinZoom, visibleMaxZoom, minZoom, extent} = this.props; + const zoom = this.context.viewport.zoom; + return ( + (visibleMinZoom !== null && visibleMinZoom !== undefined && zoom < visibleMinZoom) || + (visibleMaxZoom !== null && visibleMaxZoom !== undefined && zoom > visibleMaxZoom) || + (minZoom !== null && minZoom !== undefined && !extent && zoom < minZoom) + ); + } + + private _isPlaceholderCandidate(tile: Tile2DHeader): boolean { + return Boolean( + this.props.renderPlaceholder && + tile.isSelected && + !tile.isLoaded && + !tile.content && + (this._shouldPreferPlaceholderOverRefinement() || !this._hasVisibleContent(tile)) + ); + } + + private _shouldPreferPlaceholderOverRefinement(): boolean { + return Boolean(this.props.renderPlaceholder && this.props.refinementStrategy === 'no-overlap'); + } + + private _isRefinementTileHiddenByPlaceholder(tile: Tile2DHeader): boolean { + if ( + !this._shouldPreferPlaceholderOverRefinement() || + tile.isSelected || + !tile.isVisible || + (!tile.isLoaded && !tile.content) + ) { + return false; + } + + const selectedTiles = this.state.tileset!.selectedTiles || []; + return selectedTiles.some( + selectedTile => + this._isPlaceholderCandidate(selectedTile) && + this._isTileAncestorOrDescendant(tile, selectedTile) + ); + } + + private _isTileAncestorOrDescendant( + tile: Tile2DHeader, + selectedTile: Tile2DHeader + ): boolean { + return this._isTileAncestor(tile, selectedTile) || this._isTileAncestor(selectedTile, tile); + } + + private _isTileAncestor(ancestorTile: Tile2DHeader, tile: Tile2DHeader): boolean { + let parent = tile.parent; + while (parent) { + if (parent === ancestorTile) { + return true; + } + parent = parent.parent; + } + return false; + } + + private _hasVisibleContent(tile: Tile2DHeader): boolean { + let parent = tile.parent; + while (parent) { + if (parent.isVisible && (parent.isLoaded || parent.content)) { + return true; + } + parent = parent.parent; + } + + const childQueue = tile.children ? [...tile.children] : []; + while (childQueue.length > 0) { + const child = childQueue.shift()!; + if (child.isVisible && (child.isLoaded || child.content)) { + return true; + } + if (child.children) { + childQueue.push(...child.children); + } + } + return false; + } +} + +function getTileBounds(bbox: TileBoundingBox): Bounds { + if ('west' in bbox) { + return [bbox.west, bbox.south, bbox.east, bbox.north]; + } + return [bbox.left, bbox.bottom, bbox.right, bbox.top]; } diff --git a/test/modules/geo-layers/terrain-layer.spec.ts b/test/modules/geo-layers/terrain-layer.spec.ts index 8777e0ad562..7a496087dda 100644 --- a/test/modules/geo-layers/terrain-layer.spec.ts +++ b/test/modules/geo-layers/terrain-layer.spec.ts @@ -3,7 +3,7 @@ // Copyright (c) vis.gl contributors import {test, expect} from 'vitest'; -import {generateLayerTests, testLayerAsync} from '@deck.gl/test-utils/vitest'; +import {generateLayerTests, testLayer, testLayerAsync} from '@deck.gl/test-utils/vitest'; import {TerrainLayer, TileLayer} from '@deck.gl/geo-layers'; import {SimpleMeshLayer} from '@deck.gl/mesh-layers'; import {TerrainLoader} from '@loaders.gl/terrain'; @@ -47,3 +47,27 @@ test('TerrainLayer', async () => { onError: err => expect(err).toBeFalsy() }); }); + +test('TerrainLayer#renderPlaceholder', () => { + const renderPlaceholder = () => null; + + testLayer({ + Layer: TerrainLayer, + testCases: [ + { + title: 'forwards renderPlaceholder to tiled TileLayer', + props: { + elevationData: 'https://example.com/terrain/{z}/{x}/{y}.png', + renderPlaceholder + }, + onAfterUpdate: ({subLayers}) => { + expect(subLayers[0] instanceof TileLayer, 'rendered TileLayer').toBeTruthy(); + expect(subLayers[0].props.renderPlaceholder, 'forwarded renderPlaceholder callback').toBe( + renderPlaceholder + ); + } + } + ], + onError: err => expect(err).toBeFalsy() + }); +}); diff --git a/test/modules/geo-layers/tile-layer/tile-layer.spec.ts b/test/modules/geo-layers/tile-layer/tile-layer.spec.ts index 379373ca3bf..ef43d392e0d 100644 --- a/test/modules/geo-layers/tile-layer/tile-layer.spec.ts +++ b/test/modules/geo-layers/tile-layer/tile-layer.spec.ts @@ -244,6 +244,204 @@ test('TileLayer#error tiles do not block isLoaded', async () => { expect(tileErrorCalled, 'onTileError is called for failed tiles').toBe(2); }); +test('TileLayer#renderPlaceholder', async () => { + let placeholderCalls = 0; + let renderSubLayersCalls = 0; + let observedPending = false; + let observedLoaded = false; + + await testLayerAsync({ + Layer: TileLayer, + viewport: new WebMercatorViewport({ + width: 100, + height: 100, + longitude: 0, + latitude: 60, + zoom: 2 + }), + testCases: [ + { + title: 'renders placeholders while selected tiles load', + props: { + getTileData: () => sleep(20).then(() => []), + renderPlaceholder: props => { + placeholderCalls++; + expect(props.data, 'placeholder data is null').toBe(null); + expect(props.bounds.length, 'placeholder bounds are provided').toBe(4); + return new ScatterplotLayer(props, { + id: `${props.id}-placeholder`, + data: [] + }); + }, + renderSubLayers: props => { + renderSubLayersCalls++; + return new ScatterplotLayer(props, { + id: `${props.id}-content`, + data: props.data + }); + } + }, + onAfterUpdate: ({layer, subLayers}) => { + if (!layer.isLoaded) { + observedPending = true; + expect(layer.isLoaded, 'placeholder does not make layer loaded').toBe(false); + expect(subLayers.length, 'Rendered placeholder sublayers').toBe(2); + expect( + subLayers.every(l => l.props._isTilePlaceholder), + 'All pending sublayers are placeholders' + ).toBeTruthy(); + expect( + subLayers.every(l => layer.filterSubLayer({layer: l})), + 'Placeholder sublayers are visible while selected tiles load' + ).toBeTruthy(); + } else { + observedLoaded = true; + expect(subLayers.length, 'Rendered content sublayers').toBe(2); + expect( + subLayers.every(l => !l.props._isTilePlaceholder), + 'Placeholder sublayers were replaced by content sublayers' + ).toBeTruthy(); + } + } + } + ], + onError: err => expect(err).toBeFalsy() + }); + + expect(observedPending, 'observed pending placeholder state').toBeTruthy(); + expect(observedLoaded, 'observed loaded content state').toBeTruthy(); + expect(placeholderCalls, 'renderPlaceholder was called').toBeGreaterThan(0); + expect(renderSubLayersCalls, 'renderSubLayers was called after load').toBeGreaterThan(0); +}); + +test('TileLayer#renderPlaceholder does not replace cached refinement content', async () => { + let placeholderCalls = 0; + const renderSubLayers = props => { + return new ScatterplotLayer(props, { + id: `${props.id}-content`, + data: props.data + }); + }; + const renderPlaceholder = props => { + placeholderCalls++; + return new ScatterplotLayer(props, { + id: `${props.id}-placeholder`, + data: [] + }); + }; + + await testLayerAsync({ + Layer: TileLayer, + viewport: new WebMercatorViewport({ + width: 100, + height: 100, + longitude: 0, + latitude: 60, + zoom: 1 + }), + testCases: [ + { + title: 'load parent tile', + props: { + getTileData: ({index}) => (index.z <= 1 ? [] : sleep(20).then(() => [])), + renderSubLayers, + renderPlaceholder + }, + onAfterUpdate: ({layer}) => { + if (layer.isLoaded) { + placeholderCalls = 0; + } + } + }, + { + title: 'show cached parent while children load', + viewport: new WebMercatorViewport({ + width: 100, + height: 100, + longitude: 0, + latitude: 60, + zoom: 2 + }), + onAfterUpdate: ({layer, subLayers}) => { + if (!layer.isLoaded) { + expect(placeholderCalls, 'renderPlaceholder is not called over cached content').toBe(0); + expect( + subLayers.every(l => !l.props._isTilePlaceholder), + 'Only cached content sublayers are rendered while children load' + ).toBeTruthy(); + } + } + } + ], + onError: err => expect(err).toBeFalsy() + }); +}); + +test('TileLayer#renderPlaceholder replaces cached refinement content with no-overlap', async () => { + let placeholderCalls = 0; + const renderSubLayers = props => { + return new ScatterplotLayer(props, { + id: `${props.id}-content`, + data: props.data + }); + }; + const renderPlaceholder = props => { + placeholderCalls++; + return new ScatterplotLayer(props, { + id: `${props.id}-placeholder`, + data: [] + }); + }; + + await testLayerAsync({ + Layer: TileLayer, + viewport: new WebMercatorViewport({ + width: 100, + height: 100, + longitude: 0, + latitude: 60, + zoom: 1 + }), + testCases: [ + { + title: 'load parent tile', + props: { + refinementStrategy: 'no-overlap', + getTileData: ({index}) => (index.z <= 1 ? [] : sleep(20).then(() => [])), + renderSubLayers, + renderPlaceholder + }, + onAfterUpdate: ({layer}) => { + if (layer.isLoaded) { + placeholderCalls = 0; + } + } + }, + { + title: 'show placeholders while children load', + viewport: new WebMercatorViewport({ + width: 100, + height: 100, + longitude: 0, + latitude: 60, + zoom: 2 + }), + onAfterUpdate: ({layer, subLayers}) => { + if (!layer.isLoaded) { + const visibleSubLayers = subLayers.filter(l => layer.filterSubLayer({layer: l})); + expect(placeholderCalls, 'renderPlaceholder is called').toBeGreaterThan(0); + expect( + visibleSubLayers.every(l => l.props._isTilePlaceholder), + 'Only placeholder sublayers are visible while selected children load' + ).toBeTruthy(); + } + } + } + ], + onError: err => expect(err).toBeFalsy() + }); +}); + test('TileLayer#AbortRequestsOnUpdateTrigger', async () => { const testViewport = new WebMercatorViewport({ width: 1200, diff --git a/website/src/examples/tile-layer.js b/website/src/examples/tile-layer.js index c785088e0f1..0de44275428 100644 --- a/website/src/examples/tile-layer.js +++ b/website/src/examples/tile-layer.js @@ -10,26 +10,61 @@ import App from 'website-examples/map-tile/app'; import {makeExample} from '../components'; class MapTileDemo extends Component { - static title = 'Raster Map Tiles'; + static title = 'Globe Terrain Tiles'; static code = `${GITHUB_TREE}/examples/website/map-tile`; static parameters = { - minZoom: {displayName: 'Min Zoom', type: 'range', value: 3, step: 1, min: 0, max: 19, accentColor: '#0275ff'}, - maxZoom: {displayName: 'Max Zoom', type: 'range', value: 8, step: 1, min: 0, max: 19, accentColor: '#0275ff'}, - visibleMinZoom: {displayName: 'Visible Min Zoom', type: 'range', value: 1, step: 1, min: 0, max: 19, accentColor: '#1a2b4a'}, - visibleMaxZoom: {displayName: 'Visible Max Zoom', type: 'range', value: 12, step: 1, min: 0, max: 19, accentColor: '#1a2b4a'}, - showBorder: {displayName: 'Show tile borders', type: 'checkbox', value: false}, - useExtent: {displayName: 'Extent (France)', type: 'checkbox', value: false} + minZoom: { + displayName: 'Min Zoom', + type: 'range', + value: 0, + step: 1, + min: 0, + max: 6, + accentColor: '#0275ff' + }, + maxZoom: { + displayName: 'Max Zoom', + type: 'range', + value: 6, + step: 1, + min: 0, + max: 6, + accentColor: '#0275ff' + }, + visibleMinZoom: { + displayName: 'Visible Min Zoom', + type: 'range', + value: 0, + step: 1, + min: 0, + max: 6, + accentColor: '#1a2b4a' + }, + visibleMaxZoom: { + displayName: 'Visible Max Zoom', + type: 'range', + value: 6, + step: 1, + min: 0, + max: 6, + accentColor: '#1a2b4a' + }, + showBorder: {displayName: 'Show terrain wireframe', type: 'checkbox', value: false} }; static renderInfo(meta) { return (

- OpenStreetMap data source: - Wiki and - Tile Servers + Terrain elevation source: + AWS Open Data + and satellite texture source: + + {' '} + ArcGIS World Imagery +

@@ -65,7 +100,6 @@ class MapTileDemo extends Component { maxZoom={params.maxZoom.value} visibleMinZoom={params.visibleMinZoom.value} visibleMaxZoom={params.visibleMaxZoom.value} - useExtent={params.useExtent.value} onTilesLoad={this._onTilesLoad} onZoomChange={this._onZoomChange} />