diff --git a/src/geo/transform.js b/src/geo/transform.js index 25fe5c948a8..c92d68cf326 100644 --- a/src/geo/transform.js +++ b/src/geo/transform.js @@ -212,9 +212,11 @@ class Transform { * @returns {number} zoom level */ coveringZoomLevel(options: {roundZoom?: boolean, tileSize: number}) { - return (options.roundZoom ? Math.round : Math.floor)( + const z = (options.roundZoom ? Math.round : Math.floor)( this.zoom + this.scaleZoom(this.tileSize / options.tileSize) ); + // At negative zoom levels load tiles from z0 because negative tile zoom levels don't exist. + return Math.max(0, z); } /** diff --git a/src/source/source_cache.js b/src/source/source_cache.js index d602cec1eb7..5ffd45b84a6 100644 --- a/src/source/source_cache.js +++ b/src/source/source_cache.js @@ -317,13 +317,6 @@ class SourceCache extends Evented { return this._tiles[id]; } - /** - * get the zoom level adjusted for the difference in map and source tilesizes - */ - getZoom(transform: Transform): number { - return transform.zoom + transform.scaleZoom(transform.tileSize / this._source.tileSize); - } - /** * For a given set of tiles, retain children that are loaded and have a zoom * between `zoom` (exclusive) and `maxCoveringZoom` (inclusive) @@ -487,7 +480,7 @@ class SourceCache extends Evented { } // Determine the overzooming/underzooming amounts. - const zoom = (this._source.roundZoom ? Math.round : Math.floor)(this.getZoom(transform)); + const zoom = transform.coveringZoomLevel(this._source); const minCoveringZoom = Math.max(zoom - SourceCache.maxOverzooming, this._source.minzoom); const maxCoveringZoom = Math.max(zoom + SourceCache.maxUnderzooming, this._source.minzoom); diff --git a/src/ui/handler/touch_zoom_rotate.js b/src/ui/handler/touch_zoom_rotate.js index 3f03721869d..c24ae7502d4 100644 --- a/src/ui/handler/touch_zoom_rotate.js +++ b/src/ui/handler/touch_zoom_rotate.js @@ -271,11 +271,7 @@ class TouchZoomRotateHandler { } const duration = Math.abs(speed / (inertiaDeceleration * inertiaLinearity)) * 1000; - let targetScale = lastScale + speed * duration / 2000; - - if (targetScale < 0) { - targetScale = 0; - } + const targetScale = lastScale + speed * duration / 2000; map.easeTo({ zoom: targetScale, diff --git a/src/ui/map.js b/src/ui/map.js index 8bd87bb11da..ef69025f2d5 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -101,7 +101,7 @@ type MapOptions = { locale?: Object }; -const defaultMinZoom = 0; +const defaultMinZoom = -2; const defaultMaxZoom = 22; // the default values, but also the valid range @@ -614,8 +614,13 @@ class Map extends Camera { * If the map's current zoom level is lower than the new minimum, * the map will zoom to the new minimum. * - * @param {number | null | undefined} minZoom The minimum zoom level to set (0-24). - * If `null` or `undefined` is provided, the function removes the current minimum zoom (i.e. sets it to 0). + * It is not always possible to zoom out and reach the set `minZoom`. + * Other factors such as map height may restrict zooming. For example, + * if the map is 512px tall it will not be possible to zoom below zoom 0 + * no matter what the `minZoom` is set to. + * + * @param {number | null | undefined} minZoom The minimum zoom level to set (-2 - 24). + * If `null` or `undefined` is provided, the function removes the current minimum zoom (i.e. sets it to -2). * @returns {Map} `this` * @example * map.setMinZoom(12.25); diff --git a/test/integration/render-tests/zoomed-fill/negative-zoom/expected.png b/test/integration/render-tests/zoomed-fill/negative-zoom/expected.png new file mode 100644 index 00000000000..ffe61831b03 Binary files /dev/null and b/test/integration/render-tests/zoomed-fill/negative-zoom/expected.png differ diff --git a/test/integration/render-tests/zoomed-fill/negative-zoom/style.json b/test/integration/render-tests/zoomed-fill/negative-zoom/style.json new file mode 100644 index 00000000000..0cc394a3ef5 --- /dev/null +++ b/test/integration/render-tests/zoomed-fill/negative-zoom/style.json @@ -0,0 +1,41 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 256, + "height": 256 + } + }, + "center": [ + 0, + 0 + ], + "zoom": -1, + "sources": { + "mapbox": { + "type": "vector", + "maxzoom": 14, + "tiles": [ + "local://tiles/{z}-{x}-{y}.mvt" + ] + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "fill", + "type": "fill", + "source": "mapbox", + "source-layer": "water", + "paint": { + "fill-color": "black" + } + } + ] +} diff --git a/test/unit/ui/handler/touch_zoom_rotate.test.js b/test/unit/ui/handler/touch_zoom_rotate.test.js index 27ac39b2360..92838edec33 100644 --- a/test/unit/ui/handler/touch_zoom_rotate.test.js +++ b/test/unit/ui/handler/touch_zoom_rotate.test.js @@ -40,8 +40,9 @@ test('TouchZoomRotateHandler fires zoomstart, zoom, and zoomend events at approp simulate.touchend(map.getCanvas(), {touches: []}); map._renderTaskQueue.run(); - t.equal(zoomstart.callCount, 1); - t.equal(zoom.callCount, 2); + // incremented because inertia starts a second zoom + t.equal(zoomstart.callCount, 2); + t.equal(zoom.callCount, 3); t.equal(zoomend.callCount, 1); map.remove(); @@ -142,8 +143,9 @@ test('TouchZoomRotateHandler starts zoom immediately when rotation disabled', (t simulate.touchend(map.getCanvas(), {touches: []}); map._renderTaskQueue.run(); - t.equal(zoomstart.callCount, 1); - t.equal(zoom.callCount, 2); + // incremented because inertia starts a second zoom + t.equal(zoomstart.callCount, 2); + t.equal(zoom.callCount, 3); t.equal(zoomend.callCount, 1); map.remove(); diff --git a/test/unit/ui/map.test.js b/test/unit/ui/map.test.js index cbb5c512b43..ff5c36c22d5 100755 --- a/test/unit/ui/map.test.js +++ b/test/unit/ui/map.test.js @@ -754,7 +754,7 @@ test('Map', (t) => { t.test('#getMinZoom', (t) => { const map = createMap(t, {zoom: 0}); - t.equal(map.getMinZoom(), 0, 'returns default value'); + t.equal(map.getMinZoom(), -2, 'returns default value'); map.setMinZoom(10); t.equal(map.getMinZoom(), 10, 'returns custom value'); t.end();