Skip to content

Commit f145fe8

Browse files
Add tile loading placeholders
1 parent 6795cc9 commit f145fe8

9 files changed

Lines changed: 595 additions & 42 deletions

File tree

docs/api-reference/geo-layers/terrain-layer.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,10 @@ new deck.TerrainLayer({});
145145

146146
## Properties
147147

148-
When in Tiled Mode, inherits from all [TileLayer](./tile-layer.md) properties. Forwards `wireframe` property to [SimpleMeshLayer](../mesh-layers/simple-mesh-layer.md).
148+
When in Tiled Mode, inherits from all [TileLayer](./tile-layer.md) properties. Tiled terrain
149+
forwards `renderPlaceholder` to its internal `TileLayer`; this can be used to render loading
150+
footprints before terrain meshes are available. Forwards `wireframe` property to
151+
[SimpleMeshLayer](../mesh-layers/simple-mesh-layer.md).
149152

150153

151154

docs/api-reference/geo-layers/tile-layer.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,10 @@ If not supplied, the `maxCacheByteSize` is set to `Infinity`.
302302

303303
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.
304304

305+
`refinementStrategy` only reuses loaded tile content that is already in the cache. To render
306+
synthetic content while a selected tile has no loaded content and no cached ancestor or child is
307+
visible, use [`renderPlaceholder`](#renderplaceholder).
308+
305309
This prop accepts one of the following:
306310

307311
* `'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
358362
- `visible` (toggled based on tile visibility)
359363
- `highlightedObjectIndex` (set based on the parent layer's highlight state)
360364

365+
#### `renderPlaceholder` (Function, optional) {#renderplaceholder}
366+
367+
Renders one or an array of Layer instances for a selected tile while its data is loading.
368+
369+
This prop is disabled by default. When supplied, it is called for selected tiles that have no loaded
370+
content and no generated sublayers. With the default `refinementStrategy: 'best-available'`, cached
371+
ancestor or child content still takes priority, so placeholders only fill cold-start or cache-miss
372+
gaps. With `refinementStrategy: 'no-overlap'`, placeholders are shown instead of cached refinement
373+
content for selected loading tiles.
374+
375+
The callback receives all the `TileLayer` props and the following props:
376+
377+
* `id` (string): A unique id for this placeholder sublayer
378+
* `data` (null): Placeholder tiles do not have loaded tile data
379+
* `bounds` (number[4]): Bounds of the tile in `[left, bottom, right, top]` order
380+
* `tile` ([Tile](#tile))
381+
382+
- Default: `null`
383+
384+
For raster tiles, return a `BitmapLayer` that spans `props.bounds`. Set `pickable: false` if
385+
placeholder layers should not participate in picking.
386+
387+
```ts
388+
renderPlaceholder: props => {
389+
const {data, bounds, ...otherProps} = props;
390+
391+
return new BitmapLayer(otherProps, {
392+
image: 'data:image/png;base64,...',
393+
bounds,
394+
pickable: false,
395+
opacity: 0.35
396+
});
397+
}
398+
```
399+
400+
Placeholder sublayers do not make the tile or layer loaded. `isLoaded`, `onViewportLoad`, tile cache
401+
behavior, and tile error handling continue to depend on the real tile request.
402+
361403
#### `zRange` (number[2], optional) {#zrange}
362404

363405
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.

examples/website/image-tile/app.tsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: MIT
33
// Copyright (c) vis.gl contributors
44

5-
/* global fetch, DOMParser */
5+
/* global fetch, DOMParser, setTimeout */
66
import React, {useState, useEffect} from 'react';
77
import {createRoot} from 'react-dom/client';
88

@@ -24,6 +24,11 @@ const INITIAL_VIEW_STATE: OrthographicViewState = {
2424

2525
const ROOT_URL =
2626
'https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/image-tiles/moon.image';
27+
const PLACEHOLDER_IMAGE =
28+
'data:image/svg+xml;charset=utf-8,' +
29+
'%3Csvg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"%3E' +
30+
'%3Crect width="8" height="8" fill="%23242a2e"/%3E' +
31+
'%3Cpath d="M0 8 8 0" stroke="%23404a50" stroke-width="1"/%3E%3C/svg%3E';
2732

2833
function getTooltip({tile, bitmap}: TileLayerPickingInfo<ImageBitmap, BitmapLayerPickingInfo>) {
2934
if (tile && bitmap) {
@@ -35,11 +40,19 @@ function getTooltip({tile, bitmap}: TileLayerPickingInfo<ImageBitmap, BitmapLaye
3540
return null;
3641
}
3742

43+
function sleep(ms: number): Promise<void> {
44+
return new Promise(resolve => setTimeout(resolve, ms));
45+
}
46+
3847
export default function App({
3948
autoHighlight = true,
49+
showPlaceholders = true,
50+
loadDelay = 0,
4051
onTilesLoad
4152
}: {
4253
autoHighlight?: boolean;
54+
showPlaceholders?: boolean;
55+
loadDelay?: number;
4356
onTilesLoad?: () => void;
4457
}) {
4558
const [dimensions, setDimensions] = useState<{width: number; height: number; tileSize: number}>();
@@ -85,14 +98,37 @@ export default function App({
8598
minZoom: -7,
8699
maxZoom: 0,
87100
extent: [0, 0, dimensions.width, dimensions.height],
88-
getTileData: ({index}) => {
101+
getTileData: async ({index}) => {
89102
const {x, y, z} = index;
103+
if (loadDelay > 0) {
104+
await sleep(loadDelay);
105+
}
90106
return load(
91107
`${ROOT_URL}/moon.image_files/${15 + z}/${x}_${y}.jpeg`
92108
) as Promise<ImageBitmap>;
93109
},
94110
onViewportLoad: onTilesLoad,
95111

112+
renderPlaceholder: showPlaceholders
113+
? props => {
114+
const {width, height} = dimensions;
115+
const {bounds} = props;
116+
const otherProps = {...props} as Partial<typeof props>;
117+
delete otherProps.data;
118+
delete otherProps.bounds;
119+
return new BitmapLayer(otherProps, {
120+
image: PLACEHOLDER_IMAGE,
121+
bounds: [
122+
clamp(bounds[0], 0, width),
123+
clamp(bounds[1], 0, height),
124+
clamp(bounds[2], 0, width),
125+
clamp(bounds[3], 0, height)
126+
],
127+
pickable: false,
128+
opacity: 0.5
129+
});
130+
}
131+
: undefined,
96132
renderSubLayers: props => {
97133
const [[left, bottom], [right, top]] = props.tile.boundingBox;
98134
const {width, height} = dimensions;

examples/website/map-tile/app.tsx

Lines changed: 107 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22
// SPDX-License-Identifier: MIT
33
// Copyright (c) vis.gl contributors
44

5+
/* global setTimeout */
56
import React, {useState, useCallback} from 'react';
67
import {createRoot} from 'react-dom/client';
78

89
import {DeckGL} from '@deck.gl/react';
9-
import {MapView} from '@deck.gl/core';
10+
import {COORDINATE_SYSTEM, MapView, _GlobeView as GlobeView} from '@deck.gl/core';
1011
import {TileLayer} from '@deck.gl/geo-layers';
1112
import {BitmapLayer, PathLayer} from '@deck.gl/layers';
13+
import {load} from '@loaders.gl/core';
1214
import ZoomRangeWidget from './zoom-range-widget';
1315

14-
import type {Position, MapViewState} from '@deck.gl/core';
16+
import type {GlobeViewState, Position, MapViewState, TextureSource} from '@deck.gl/core';
1517
import type {TileLayerPickingInfo} from '@deck.gl/geo-layers';
1618

1719
const INITIAL_VIEW_STATE: MapViewState = {
@@ -23,8 +25,22 @@ const INITIAL_VIEW_STATE: MapViewState = {
2325
bearing: 0
2426
};
2527

28+
const INITIAL_GLOBE_VIEW_STATE: GlobeViewState = {
29+
latitude: 47.65,
30+
longitude: 7,
31+
zoom: 2.25,
32+
maxZoom: 20
33+
};
34+
2635
// Approximate bounding box of France [west, south, east, north]
2736
const FRANCE_EXTENT = [-5.14, 41.33, 9.56, 51.09];
37+
const PLACEHOLDER_GRID_CELLS = 16;
38+
const PLACEHOLDER_GRID_SEGMENTS = 24;
39+
const PLACEHOLDER_BACKGROUND_IMAGE: TextureSource = {
40+
width: 1,
41+
height: 1,
42+
data: new Uint8Array([238, 241, 239, 255])
43+
};
2844

2945
const COPYRIGHT_LICENSE_STYLE: React.CSSProperties = {
3046
position: 'absolute',
@@ -49,17 +65,50 @@ function getTooltip({tile}: TileLayerPickingInfo) {
4965
return null;
5066
}
5167

68+
function sleep(ms: number): Promise<void> {
69+
return new Promise(resolve => setTimeout(resolve, ms));
70+
}
71+
72+
function getPlaceholderGridPaths(bounds: [number, number, number, number]): Position[][] {
73+
const [west, south, east, north] = bounds;
74+
const paths: Position[][] = [];
75+
76+
for (let index = 0; index <= PLACEHOLDER_GRID_CELLS; index++) {
77+
const gridPosition = index / PLACEHOLDER_GRID_CELLS;
78+
const longitude = west + (east - west) * gridPosition;
79+
const latitude = south + (north - south) * gridPosition;
80+
const verticalPath: Position[] = [];
81+
const horizontalPath: Position[] = [];
82+
83+
for (let segment = 0; segment <= PLACEHOLDER_GRID_SEGMENTS; segment++) {
84+
const linePosition = segment / PLACEHOLDER_GRID_SEGMENTS;
85+
verticalPath.push([longitude, south + (north - south) * linePosition]);
86+
horizontalPath.push([west + (east - west) * linePosition, latitude]);
87+
}
88+
89+
paths.push(verticalPath, horizontalPath);
90+
}
91+
92+
return paths;
93+
}
94+
5295
export default function App({
5396
showBorder = false,
97+
globeView = false,
98+
showPlaceholders = true,
99+
loadDelay = 0,
54100
onTilesLoad,
55101
onZoomChange,
56-
minZoom = 4,
57-
maxZoom = 7,
102+
minZoom = globeView ? 0 : 4,
103+
maxZoom = globeView ? 19 : 7,
58104
visibleMinZoom,
59-
visibleMaxZoom = 7,
105+
visibleMaxZoom = globeView ? undefined : 7,
60106
useExtent = false
61107
}: {
62108
showBorder?: boolean;
109+
globeView?: boolean;
110+
showPlaceholders?: boolean;
111+
loadDelay?: number;
63112
onTilesLoad?: () => void;
64113
onZoomChange?: (zoom: number) => void;
65114
minZoom?: number;
@@ -68,7 +117,9 @@ export default function App({
68117
visibleMaxZoom?: number;
69118
useExtent?: boolean;
70119
}) {
71-
const [zoom, setZoom] = useState(INITIAL_VIEW_STATE.zoom);
120+
const [zoom, setZoom] = useState(
121+
globeView ? INITIAL_GLOBE_VIEW_STATE.zoom : INITIAL_VIEW_STATE.zoom
122+
);
72123
const onViewStateChange = useCallback(
73124
({viewState}) => {
74125
setZoom(viewState.zoom);
@@ -84,6 +135,13 @@ export default function App({
84135
// Since these OSM tiles support HTTP/2, we can make many concurrent requests
85136
// and we aren't limited by the browser to a certain number per domain.
86137
maxRequests: 20,
138+
getTileData:
139+
loadDelay > 0
140+
? async ({url}) => {
141+
await sleep(loadDelay);
142+
return load(url as string) as Promise<ImageBitmap>;
143+
}
144+
: undefined,
87145

88146
pickable: true,
89147
onViewportLoad: onTilesLoad,
@@ -93,16 +151,56 @@ export default function App({
93151
minZoom,
94152
maxZoom,
95153
tileSize: 512,
154+
refinementStrategy: 'no-overlap',
96155
visibleMinZoom,
97156
visibleMaxZoom,
98157
extent: useExtent ? FRANCE_EXTENT : undefined,
158+
renderPlaceholder: showPlaceholders
159+
? props => {
160+
const {id, bounds} = props;
161+
const otherProps = {...props} as Partial<typeof props>;
162+
delete otherProps.data;
163+
delete otherProps.id;
164+
delete otherProps.bounds;
165+
166+
return [
167+
new BitmapLayer(
168+
{...otherProps, id: `${id}-fill`},
169+
{
170+
image: PLACEHOLDER_BACKGROUND_IMAGE,
171+
bounds,
172+
_imageCoordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
173+
pickable: false,
174+
opacity: 0.92
175+
}
176+
),
177+
new PathLayer<Position[]>(
178+
{...otherProps, id: `${id}-grid`},
179+
{
180+
data: getPlaceholderGridPaths(bounds),
181+
getPath: d => d,
182+
getColor: [72, 86, 88, 190],
183+
getWidth: 1,
184+
widthUnits: 'pixels',
185+
widthMinPixels: 1,
186+
widthMaxPixels: 1,
187+
pickable: false,
188+
parameters: {
189+
depthTest: false
190+
}
191+
}
192+
)
193+
];
194+
}
195+
: undefined,
99196
renderSubLayers: props => {
100197
const [[west, south], [east, north]] = props.tile.boundingBox;
101198
const {data, ...otherProps} = props;
102199

103200
return [
104201
new BitmapLayer(otherProps, {
105202
image: data,
203+
_imageCoordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
106204
bounds: [west, south, east, north]
107205
}),
108206
showBorder &&
@@ -128,8 +226,8 @@ export default function App({
128226
return (
129227
<DeckGL
130228
layers={[tileLayer]}
131-
views={new MapView({repeat: true})}
132-
initialViewState={INITIAL_VIEW_STATE}
229+
views={globeView ? new GlobeView() : new MapView({repeat: true})}
230+
initialViewState={globeView ? INITIAL_GLOBE_VIEW_STATE : INITIAL_VIEW_STATE}
133231
controller={true}
134232
getTooltip={getTooltip}
135233
onViewStateChange={onViewStateChange}
@@ -152,5 +250,5 @@ export default function App({
152250
}
153251

154252
export function renderToDOM(container: HTMLDivElement) {
155-
createRoot(container).render(<App />);
253+
createRoot(container).render(<App globeView={true} loadDelay={750} />);
156254
}

modules/geo-layers/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ export type {H3ClusterLayerProps} from './h3-layers/h3-cluster-layer';
2727
export type {H3HexagonLayerProps} from './h3-layers/h3-hexagon-layer';
2828
export type {GreatCircleLayerProps} from './great-circle-layer/great-circle-layer';
2929
export type {S2LayerProps} from './s2-layer/s2-layer';
30-
export type {TileLayerProps, TileLayerPickingInfo} from './tile-layer/tile-layer';
30+
export type {
31+
TileLayerProps,
32+
TileLayerPickingInfo,
33+
TileLayerRenderSubLayersProps,
34+
TileLayerRenderPlaceholderProps
35+
} from './tile-layer/tile-layer';
3136
export type {TripsLayerProps} from './trips-layer/trips-layer';
3237
export type {QuadkeyLayerProps} from './quadkey-layer/quadkey-layer';
3338
export type {TerrainLayerProps} from './terrain-layer/terrain-layer';

modules/geo-layers/src/terrain-layer/terrain-layer.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,8 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> extends Composite
355355
onTileError,
356356
maxCacheSize,
357357
maxCacheByteSize,
358-
refinementStrategy
358+
refinementStrategy,
359+
renderPlaceholder
359360
} = this.props;
360361

361362
if (this.state.isTiled) {
@@ -366,6 +367,7 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> extends Composite
366367
{
367368
getTileData: this.getTiledTerrainData.bind(this),
368369
renderSubLayers: this.renderSubLayers.bind(this),
370+
renderPlaceholder,
369371
updateTriggers: {
370372
getTileData: {
371373
elevationData: urlTemplateToUpdateTrigger(elevationData),

0 commit comments

Comments
 (0)