diff --git a/examples/website/map-tile/app.js b/examples/website/map-tile/app.js index f45e3ef3aa1..0dd854cef24 100644 --- a/examples/website/map-tile/app.js +++ b/examples/website/map-tile/app.js @@ -4,13 +4,14 @@ import {render} from 'react-dom'; import DeckGL from '@deck.gl/react'; import {MapView} from '@deck.gl/core'; import {TileLayer} from '@deck.gl/geo-layers'; -import {BitmapLayer} from '@deck.gl/layers'; +import {BitmapLayer, PathLayer} from '@deck.gl/layers'; const INITIAL_VIEW_STATE = { latitude: 47.65, longitude: 7, zoom: 4.5, maxZoom: 20, + maxPitch: 89, bearing: 0 }; @@ -40,7 +41,7 @@ export default class App extends PureComponent { } _renderLayers() { - const {autoHighlight = true, highlightColor = [60, 60, 60, 40]} = this.props; + const {showBorder = false, onTilesLoad = null} = this.props; return [ new TileLayer({ @@ -49,8 +50,9 @@ export default class App extends PureComponent { pickable: true, onHover: this._onHover, - autoHighlight, - highlightColor, + onViewportLoad: onTilesLoad, + autoHighlight: showBorder, + highlightColor: [60, 60, 60, 40], // https://wiki.openstreetmap.org/wiki/Zoom_levels minZoom: 0, maxZoom: 19, @@ -60,11 +62,21 @@ export default class App extends PureComponent { bbox: {west, south, east, north} } = props.tile; - return new BitmapLayer(props, { - data: null, - image: props.data, - bounds: [west, south, east, north] - }); + return [ + new BitmapLayer(props, { + data: null, + image: props.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 + }) + ]; } }) ]; diff --git a/modules/geo-layers/src/tile-layer/tile-2d-traversal.js b/modules/geo-layers/src/tile-layer/tile-2d-traversal.js new file mode 100644 index 00000000000..409676867a7 --- /dev/null +++ b/modules/geo-layers/src/tile-layer/tile-2d-traversal.js @@ -0,0 +1,137 @@ +/* eslint-disable complexity */ +import {CullingVolume, Plane, AxisAlignedBoundingBox} from '@math.gl/culling'; + +const TILE_SIZE = 512; +// number of world copies to check +const MAX_MAPS = 3; + +class OSMNode { + constructor(x, y, z) { + this.x = x; + this.y = y; + this.z = z; + } + + get children() { + if (!this._children) { + const x = this.x * 2; + const y = this.y * 2; + const z = this.z + 1; + this._children = [ + new OSMNode(x, y, z), + new OSMNode(x, y + 1, z), + new OSMNode(x + 1, y, z), + new OSMNode(x + 1, y + 1, z) + ]; + } + return this._children; + } + + update(params) { + const {viewport, cullingVolume, elevationBounds, minZ, maxZ, offset} = params; + const boundingVolume = this.getBoundingVolume(elevationBounds, offset); + + // First, check if this tile is visible + const isInside = cullingVolume.computeVisibility(boundingVolume); + if (isInside < 0) { + return false; + } + + // Avoid loading overlapping tiles - if a descendant is requested, do not request the ancester + if (!this.childVisible) { + let {z} = this; + if (z < maxZ && z >= minZ) { + // Adjust LOD + // If the tile is far enough from the camera, accept a lower zoom level + const distance = + (boundingVolume.distanceTo(viewport.cameraPosition) * viewport.scale) / viewport.height; + z += Math.floor(Math.log2(distance)); + } + if (z >= maxZ) { + // LOD is acceptable + this.selected = true; + return true; + } + } + + // LOD is not enough, recursively test child tiles + this.selected = false; + this.childVisible = true; + for (const child of this.children) { + child.update(params); + } + return true; + } + + getSelected(result = []) { + if (this.selected) { + result.push(this); + } + if (this._children) { + for (const node of this._children) { + node.getSelected(result); + } + } + return result; + } + + getBoundingVolume(zRange, worldOffset) { + const scale = Math.pow(2, this.z); + const extent = TILE_SIZE / scale; + const originX = this.x * extent + worldOffset * TILE_SIZE; + // deck's common space is y-flipped + const originY = TILE_SIZE - (this.y + 1) * extent; + + return new AxisAlignedBoundingBox( + [originX, originY, zRange[0]], + [originX + extent, originY + extent, zRange[1]] + ); + } +} + +export function getOSMTileIndices(viewport, maxZ, zRange) { + // Get the culling volume of the current camera + const planes = Object.values(viewport.getFrustumPlanes()).map( + ({normal, distance}) => new Plane(normal.clone().negate(), distance) + ); + const cullingVolume = new CullingVolume(planes); + + // Project zRange from meters to common space + const unitsPerMeter = viewport.distanceScales.unitsPerMeter[2]; + const elevationMin = (zRange && zRange[0] * unitsPerMeter) || 0; + const elevationMax = (zRange && zRange[1] * unitsPerMeter) || 0; + + // Always load at the current zoom level if pitch is small + const minZ = viewport.pitch <= 60 ? maxZ : 0; + + const root = new OSMNode(0, 0, 0); + const traversalParams = { + viewport, + cullingVolume, + elevationBounds: [elevationMin, elevationMax], + minZ, + maxZ, + // num. of worlds from the center. For repeated maps + offset: 0 + }; + + root.update(traversalParams); + + if (viewport.subViewports && viewport.subViewports.length > 1) { + // Check worlds in repeated maps + traversalParams.offset = -1; + while (root.update(traversalParams)) { + if (--traversalParams.offset < -MAX_MAPS) { + break; + } + } + traversalParams.offset = 1; + while (root.update(traversalParams)) { + if (++traversalParams.offset > MAX_MAPS) { + break; + } + } + } + + return root.getSelected(); +} diff --git a/modules/geo-layers/src/tile-layer/tileset-2d.js b/modules/geo-layers/src/tile-layer/tileset-2d.js index 664e5599b00..992643d1100 100644 --- a/modules/geo-layers/src/tile-layer/tileset-2d.js +++ b/modules/geo-layers/src/tile-layer/tileset-2d.js @@ -98,7 +98,7 @@ export default class Tileset2D { * @param {*} onUpdate */ update(viewport, {zRange} = {}) { - if (viewport !== this._viewport) { + if (!viewport.equals(this._viewport)) { this._viewport = viewport; const tileIndices = this.getTileIndices({ viewport, diff --git a/modules/geo-layers/src/tile-layer/utils.js b/modules/geo-layers/src/tile-layer/utils.js index 2757e316a40..6b0c3cbbf67 100644 --- a/modules/geo-layers/src/tile-layer/utils.js +++ b/modules/geo-layers/src/tile-layer/utils.js @@ -1,4 +1,4 @@ -import {lngLatToWorld} from '@math.gl/web-mercator'; +import {getOSMTileIndices} from './tile-2d-traversal'; const TILE_SIZE = 512; const DEFAULT_EXTENT = [-Infinity, -Infinity, Infinity, Infinity]; @@ -66,17 +66,6 @@ function getBoundingBox(viewport, zRange, extent) { ]; } -/* - * get the OSM tile index at the given location - * https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames - */ -function getOSMTileIndex(lngLat, scale) { - let [x, y] = lngLatToWorld(lngLat); - x *= scale / TILE_SIZE; - y = (1 - y / TILE_SIZE) * scale; - return [x, y]; -} - function getTileIndex([x, y], scale) { return [(x * scale) / TILE_SIZE, (y * scale) / TILE_SIZE]; } @@ -130,39 +119,6 @@ function getIdentityTileIndices(viewport, z, tileSize, extent) { return indices; } -function getOSMTileIndices(viewport, z, zRange, extent) { - const bbox = getBoundingBox(viewport, zRange, extent); - const scale = getScale(z); - /* - minX, maxX could be out of bounds if longitude is near the 180 meridian or multiple worlds - are shown: - | | - actual -2 -1 0 1 2 3 - expected 2 3 0 1 2 3 - */ - let [minX, minY] = getOSMTileIndex([bbox[0], bbox[3]], scale); - let [maxX, maxY] = getOSMTileIndex([bbox[2], bbox[1]], scale); - const indices = []; - - /* - | TILE | TILE | TILE | - |(minX) |(maxX) - */ - minX = Math.floor(minX); - maxX = Math.min(minX + scale, maxX); // Avoid creating duplicates - minY = Math.max(0, Math.floor(minY)); - maxY = Math.min(scale, maxY); - for (let x = minX; x < maxX; x++) { - for (let y = minY; y < maxY; y++) { - // Cast to valid x between [0, scale] - const normalizedX = x - Math.floor(x / scale) * scale; - indices.push({x: normalizedX, y, z}); - } - } - - return indices; -} - /** * Returns all tile indices in the current viewport. If the current zoom level is smaller * than minZoom, return an empty array. If the current zoom level is greater than maxZoom, diff --git a/test/modules/geo-layers/tile-layer/utils.spec.js b/test/modules/geo-layers/tile-layer/utils.spec.js index 2ce8e996fcf..6aab606e061 100644 --- a/test/modules/geo-layers/tile-layer/utils.spec.js +++ b/test/modules/geo-layers/tile-layer/utils.spec.js @@ -34,34 +34,43 @@ const TEST_CASES = [ }), minZoom: undefined, maxZoom: undefined, - output: [ - '0,1,3', - '0,2,3', - '0,3,3', - '1,1,3', - '1,2,3', - '1,3,3', - '2,1,3', - '2,2,3', - '2,3,3', - '3,1,3', - '3,2,3', - '3,3,3' - ] + output: ['0,2,3', '0,3,3', '1,2,3', '1,3,3', '2,1,3', '2,2,3', '2,3,3', '3,2,3', '3,3,3'] }, { - title: 'flat viewport (exact)', + title: 'extreme pitch', viewport: new WebMercatorViewport({ - width: 1024, - height: 1024, + width: 800, + height: 400, + pitch: 75, + bearing: 0, longitude: 0, latitude: 0, - orthographic: true, - zoom: 2 + zoom: 4 }), minZoom: undefined, maxZoom: undefined, - output: ['1,1,2', '1,2,2', '2,1,2', '2,2,2'] + output: [ + '0,0,2', + '1,0,2', + '2,0,2', + '2,2,3', + '2,3,3', + '3,0,2', + '3,2,3', + '4,2,3', + '5,2,3', + '5,3,3', + '6,6,4', + '6,7,4', + '7,6,4', + '7,7,4', + '7,8,4', + '8,6,4', + '8,7,4', + '8,8,4', + '9,6,4', + '9,7,4' + ] }, { title: 'under zoom', @@ -104,7 +113,8 @@ const TEST_CASES = [ height: 200, longitude: -152, latitude: 0, - zoom: 3 + zoom: 3, + repeat: true }), maxZoom: 2, output: ['0,1,2', '0,2,2', '3,1,2', '3,2,2'] diff --git a/website/src/actions/app-actions.js b/website/src/actions/app-actions.js index 7911f5cd547..ac2e3045f75 100644 --- a/website/src/actions/app-actions.js +++ b/website/src/actions/app-actions.js @@ -59,10 +59,14 @@ const loadDataSuccess = (context, index, data, meta) => { */ export const loadData = (owner, source) => { return (dispatch, getState) => { - if (getState().vis.owner === owner || !source) { + if (getState().vis.owner === owner) { // already loading / loaded return; } + dispatch(loadDataStart(owner)); + if (!source) { + return; + } const isArray = Array.isArray(source); diff --git a/website/src/components/demos/map-tile-demo.js b/website/src/components/demos/map-tile-demo.js index 1a3900be1a2..e36887275e0 100644 --- a/website/src/components/demos/map-tile-demo.js +++ b/website/src/components/demos/map-tile-demo.js @@ -1,11 +1,12 @@ import React, {Component} from 'react'; +import autobind from 'autobind-decorator'; import {MAPBOX_STYLES} from '../../constants/defaults'; import App from 'website-examples/map-tile/app'; export default class MapTileDemo extends Component { static get parameters() { return { - autoHighlight: {displayName: 'Highlight on hover', type: 'checkbox', value: true} + showBorder: {displayName: 'Show tile borders', type: 'checkbox', value: false} }; } @@ -13,7 +14,7 @@ export default class MapTileDemo extends Component { return MAPBOX_STYLES.BLANK; } - static renderInfo() { + static renderInfo(meta) { return (

Raster Map Tiles

@@ -22,13 +23,21 @@ export default class MapTileDemo extends Component { Wiki and Tile Servers

+
+ No. of Tiles Loaded{meta.tileCount || 0} +
); } + @autobind + _onTilesLoad(tiles) { + this.props.onStateChange({tileCount: tiles.length}); + } + render() { // eslint-disable-next-line no-unused-vars const {params, data, ...otherProps} = this.props; - return ; + return ; } } diff --git a/website/src/components/info-panel.js b/website/src/components/info-panel.js index eb24ebca56f..59428d02484 100644 --- a/website/src/components/info-panel.js +++ b/website/src/components/info-panel.js @@ -35,7 +35,6 @@ class InfoPanel extends Component { const {demo, params, owner, meta} = this.props; const {hasFocus} = this.state; const DemoComponent = Demos[demo]; - const metaLoaded = owner === demo ? meta : {}; return (
- {DemoComponent.renderInfo(metaLoaded)} + {DemoComponent.renderInfo(meta)} {Object.keys(params).length > 0 &&
} @@ -61,7 +60,7 @@ class InfoPanel extends Component { {this.props.children} - +
); } diff --git a/website/webpack.config.js b/website/webpack.config.js index 84af74d8fae..4b917abd411 100644 --- a/website/webpack.config.js +++ b/website/webpack.config.js @@ -74,7 +74,8 @@ const COMMON_CONFIG = { modules: [resolve('./node_modules'), resolve('../node_modules')], alias: Object.assign({}, ALIASES, { 'website-examples': resolve('../examples/website'), - 'viewport-mercator-project': resolve('../node_modules/viewport-mercator-project'), + '@math.gl/web-mercator': resolve('../node_modules/@math.gl/web-mercator'), + '@math.gl/culling': resolve('../node_modules/@math.gl/culling'), supercluster: resolve('../node_modules/supercluster/dist/supercluster.js') }) },