From 271f88f44d382544843a626f8a983c08afe147f7 Mon Sep 17 00:00:00 2001 From: John Laxson Date: Sun, 27 Nov 2016 20:51:37 -0800 Subject: [PATCH 1/4] Implement TileJSON bounds property --- js/source/tile_bounds.js | 40 ++++++++++++++++++++++++++++++++ src/source/raster_tile_source.js | 19 +++++++++++++-- src/source/source_cache.js | 4 ++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 js/source/tile_bounds.js diff --git a/js/source/tile_bounds.js b/js/source/tile_bounds.js new file mode 100644 index 00000000000..f90e4ba23ea --- /dev/null +++ b/js/source/tile_bounds.js @@ -0,0 +1,40 @@ +const LngLatBounds = require('../geo/lng_lat_bounds'); +const clamp = require('../util/util').clamp; + +class TileBounds { + constructor(bounds, minzoom, maxzoom) { + this.bounds = LngLatBounds.convert(bounds); + this.minzoom = minzoom || 0; + this.maxzoom = maxzoom || 24; + this.cache = {}; + this.updateCache(); + } + + contains(coord) { + const level = this.cache[coord.z]; + let hit = coord.x >= level[0][0] && coord.x < level[1][0] && coord.y >= level[0][1] && coord.y < level[1][1]; + return hit; + } + + updateCache() { + for (let i = this.minzoom; i <= this.maxzoom; i++) { + this.cache[i] = [ + [Math.floor(this.lngX(this.bounds.getWest(), i)), Math.floor(this.latY(this.bounds.getNorth(), i))], + [Math.ceil(this.lngX(this.bounds.getEast(), i)), Math.ceil(this.latY(this.bounds.getSouth(), i))] + ] + } + } + + lngX(lng, zoom) { + return (lng + 180) * (Math.pow(2, zoom) / 360); + } + + latY(lat, zoom) { + let f = clamp(Math.sin(Math.PI / 180 * lat), -0.9999, 0.9999); + let scale = Math.pow(2, zoom) / (2 * Math.PI); + return Math.pow(2, zoom-1) + 0.5 * Math.log((1 + f) / (1 - f)) * -scale; + return y; + } +} + +module.exports = TileBounds; diff --git a/src/source/raster_tile_source.js b/src/source/raster_tile_source.js index f8b82079c77..ddc48b4d7ca 100644 --- a/src/source/raster_tile_source.js +++ b/src/source/raster_tile_source.js @@ -5,6 +5,7 @@ const ajax = require('../util/ajax'); const Evented = require('../util/evented'); const loadTileJSON = require('./load_tilejson'); const normalizeURL = require('../util/mapbox').normalizeTileURL; +const TileBounds = require('./tile_bounds'); class RasterTileSource extends Evented { @@ -25,7 +26,9 @@ class RasterTileSource extends Evented { util.extend(this, util.pick(options, ['url', 'scheme', 'tileSize'])); } - load() { + this.setBounds(options.bounds); + + this.setEventedParent(eventedParent); this.fire('dataloading', {dataType: 'source'}); loadTileJSON(this.options, (err, tileJSON) => { if (err) { @@ -47,15 +50,27 @@ class RasterTileSource extends Evented { this.map = map; } + setBounds(bounds) { + this.bounds = bounds; + if (bounds) { + this.tileBounds = new TileBounds(bounds, this.minzoom, this.maxzoom); + } + } + serialize() { return { type: 'raster', url: this.url, tileSize: this.tileSize, - tiles: this.tiles + tiles: this.tiles, + bounds: this.bounds, }; } + hasTile(coord) { + return !this.tileBounds || this.tileBounds.contains(coord); + } + loadTile(tile, callback) { const url = normalizeURL(tile.coord.url(this.tiles, null, this.scheme), this.url, this.tileSize); diff --git a/src/source/source_cache.js b/src/source/source_cache.js index cd94a9a04e5..a4d9db64ce8 100644 --- a/src/source/source_cache.js +++ b/src/source/source_cache.js @@ -336,6 +336,10 @@ class SourceCache extends Evented { roundZoom: this._source.roundZoom, reparseOverscaled: this._source.reparseOverscaled }); + + if (this._source.hasTile) { + visibleCoords = visibleCoords.filter((coord) => this._source.hasTile(coord)); + } } for (i = 0; i < visibleCoords.length; i++) { From af3187747d7ba44bbed4a85759fae7b06ba32f86 Mon Sep 17 00:00:00 2001 From: Molly Lloyd Date: Thu, 6 Apr 2017 20:03:37 -0400 Subject: [PATCH 2/4] finish TileJSON.bounds implementation + testing --- js/source/tile_bounds.js | 40 --------------------- src/source/raster_tile_source.js | 13 ++++--- src/source/tile_bounds.js | 33 +++++++++++++++++ src/source/vector_tile_source.js | 15 ++++++++ test/unit/source/vector_tile_source.test.js | 13 +++++++ 5 files changed, 67 insertions(+), 47 deletions(-) delete mode 100644 js/source/tile_bounds.js create mode 100644 src/source/tile_bounds.js diff --git a/js/source/tile_bounds.js b/js/source/tile_bounds.js deleted file mode 100644 index f90e4ba23ea..00000000000 --- a/js/source/tile_bounds.js +++ /dev/null @@ -1,40 +0,0 @@ -const LngLatBounds = require('../geo/lng_lat_bounds'); -const clamp = require('../util/util').clamp; - -class TileBounds { - constructor(bounds, minzoom, maxzoom) { - this.bounds = LngLatBounds.convert(bounds); - this.minzoom = minzoom || 0; - this.maxzoom = maxzoom || 24; - this.cache = {}; - this.updateCache(); - } - - contains(coord) { - const level = this.cache[coord.z]; - let hit = coord.x >= level[0][0] && coord.x < level[1][0] && coord.y >= level[0][1] && coord.y < level[1][1]; - return hit; - } - - updateCache() { - for (let i = this.minzoom; i <= this.maxzoom; i++) { - this.cache[i] = [ - [Math.floor(this.lngX(this.bounds.getWest(), i)), Math.floor(this.latY(this.bounds.getNorth(), i))], - [Math.ceil(this.lngX(this.bounds.getEast(), i)), Math.ceil(this.latY(this.bounds.getSouth(), i))] - ] - } - } - - lngX(lng, zoom) { - return (lng + 180) * (Math.pow(2, zoom) / 360); - } - - latY(lat, zoom) { - let f = clamp(Math.sin(Math.PI / 180 * lat), -0.9999, 0.9999); - let scale = Math.pow(2, zoom) / (2 * Math.PI); - return Math.pow(2, zoom-1) + 0.5 * Math.log((1 + f) / (1 - f)) * -scale; - return y; - } -} - -module.exports = TileBounds; diff --git a/src/source/raster_tile_source.js b/src/source/raster_tile_source.js index ddc48b4d7ca..d372d117a4a 100644 --- a/src/source/raster_tile_source.js +++ b/src/source/raster_tile_source.js @@ -26,15 +26,14 @@ class RasterTileSource extends Evented { util.extend(this, util.pick(options, ['url', 'scheme', 'tileSize'])); } - this.setBounds(options.bounds); - - this.setEventedParent(eventedParent); + load() { this.fire('dataloading', {dataType: 'source'}); loadTileJSON(this.options, (err, tileJSON) => { if (err) { return this.fire('error', err); } util.extend(this, tileJSON); + this.setBounds(tileJSON.bounds); // `content` is included here to prevent a race condition where `Style#_updateSources` is called // before the TileJSON arrives. this makes sure the tiles needed are loaded once TileJSON arrives @@ -51,10 +50,10 @@ class RasterTileSource extends Evented { } setBounds(bounds) { - this.bounds = bounds; - if (bounds) { - this.tileBounds = new TileBounds(bounds, this.minzoom, this.maxzoom); - } + this.bounds = bounds; + if (bounds) { + this.tileBounds = new TileBounds(bounds, this.minzoom, this.maxzoom); + } } serialize() { diff --git a/src/source/tile_bounds.js b/src/source/tile_bounds.js new file mode 100644 index 00000000000..38fc30a4487 --- /dev/null +++ b/src/source/tile_bounds.js @@ -0,0 +1,33 @@ +'use strict'; + +const LngLatBounds = require('../geo/lng_lat_bounds'); +const clamp = require('../util/util').clamp; + +class TileBounds { + constructor(bounds, minzoom, maxzoom) { + this.bounds = LngLatBounds.convert(bounds); + this.minzoom = minzoom || 0; + this.maxzoom = maxzoom || 24; + } + + contains(coord) { + const level = [ + [Math.floor(this.lngX(this.bounds.getWest(), coord.z)), Math.floor(this.latY(this.bounds.getNorth(), coord.z))], + [Math.ceil(this.lngX(this.bounds.getEast(), coord.z)), Math.ceil(this.latY(this.bounds.getSouth(), coord.z))] + ]; + const hit = coord.x >= level[0][0] && coord.x < level[1][0] && coord.y >= level[0][1] && coord.y < level[1][1]; + return hit; + } + + lngX(lng, zoom) { + return (lng + 180) * (Math.pow(2, zoom) / 360); + } + + latY(lat, zoom) { + const f = clamp(Math.sin(Math.PI / 180 * lat), -0.9999, 0.9999); + const scale = Math.pow(2, zoom) / (2 * Math.PI); + return Math.pow(2, zoom - 1) + 0.5 * Math.log((1 + f) / (1 - f)) * -scale; + } +} + +module.exports = TileBounds; diff --git a/src/source/vector_tile_source.js b/src/source/vector_tile_source.js index 3df7dc95900..d9a347c68d9 100644 --- a/src/source/vector_tile_source.js +++ b/src/source/vector_tile_source.js @@ -4,6 +4,7 @@ const Evented = require('../util/evented'); const util = require('../util/util'); const loadTileJSON = require('./load_tilejson'); const normalizeURL = require('../util/mapbox').normalizeTileURL; +const TileBounds = require('./tile_bounds'); class VectorTileSource extends Evented { @@ -39,6 +40,8 @@ class VectorTileSource extends Evented { return; } util.extend(this, tileJSON); + this.setBounds(tileJSON.bounds); + // `content` is included here to prevent a race condition where `Style#_updateSources` is called // before the TileJSON arrives. this makes sure the tiles needed are loaded once TileJSON arrives // ref: https://github.com/mapbox/mapbox-gl-js/pull/4347#discussion_r104418088 @@ -48,6 +51,18 @@ class VectorTileSource extends Evented { }); } + setBounds(bounds) { + this.bounds = bounds; + if (bounds) { + this.tileBounds = new TileBounds(bounds, this.minzoom, this.maxzoom); + } + } + + hasTile(coord) { + console.log('has tile'); + return !this.tileBounds || this.tileBounds.contains(coord); + } + onAdd(map) { this.load(); this.map = map; diff --git a/test/unit/source/vector_tile_source.test.js b/test/unit/source/vector_tile_source.test.js index 3144550797a..1a2cceea6ca 100644 --- a/test/unit/source/vector_tile_source.test.js +++ b/test/unit/source/vector_tile_source.test.js @@ -178,5 +178,18 @@ test('VectorTileSource', (t) => { }); }); + t.test('respects TileJSON.bounds', (t)=>{ + const source = createSource({ + minzoom: 0, + maxzoom: 22, + attribution: "Mapbox", + tiles: ["http://example.com/{z}/{x}/{y}.png"], + }); + source.setBounds([[-47, -7], [-45, -5]]); + t.false(source.hasTile({z: 8, x:96, y: 132}), 'returns false for tiles outside bounds'); + t.true(source.hasTile({z: 8, x:95, y: 132}), 'returns true for tiles inside bounds'); + t.end(); + }); + t.end(); }); From db4ba9e9a938deb29f483e61b7aec3d4a564d125 Mon Sep 17 00:00:00 2001 From: Molly Lloyd Date: Fri, 7 Apr 2017 12:51:19 -0400 Subject: [PATCH 3/4] :white_check_mark: tests --- src/source/load_tilejson.js | 2 +- src/source/raster_tile_source.js | 1 + src/source/tile_bounds.js | 12 ++-- test/unit/source/raster_tile_source.test.js | 65 +++++++++++++++++++++ test/unit/source/vector_tile_source.test.js | 22 +++++++ 5 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 test/unit/source/raster_tile_source.test.js diff --git a/src/source/load_tilejson.js b/src/source/load_tilejson.js index 370087ef99f..cd16a89c7ba 100644 --- a/src/source/load_tilejson.js +++ b/src/source/load_tilejson.js @@ -10,7 +10,7 @@ module.exports = function(options, callback) { return callback(err); } - const result = util.pick(tileJSON, ['tiles', 'minzoom', 'maxzoom', 'attribution', 'mapbox_logo']); + const result = util.pick(tileJSON, ['tiles', 'minzoom', 'maxzoom', 'attribution', 'mapbox_logo', 'bounds']); if (tileJSON.vector_layers) { result.vectorLayers = tileJSON.vector_layers; diff --git a/src/source/raster_tile_source.js b/src/source/raster_tile_source.js index d372d117a4a..7f52a7d9494 100644 --- a/src/source/raster_tile_source.js +++ b/src/source/raster_tile_source.js @@ -35,6 +35,7 @@ class RasterTileSource extends Evented { util.extend(this, tileJSON); this.setBounds(tileJSON.bounds); + // `content` is included here to prevent a race condition where `Style#_updateSources` is called // before the TileJSON arrives. this makes sure the tiles needed are loaded once TileJSON arrives // ref: https://github.com/mapbox/mapbox-gl-js/pull/4347#discussion_r104418088 diff --git a/src/source/tile_bounds.js b/src/source/tile_bounds.js index 38fc30a4487..259136318e5 100644 --- a/src/source/tile_bounds.js +++ b/src/source/tile_bounds.js @@ -11,11 +11,13 @@ class TileBounds { } contains(coord) { - const level = [ - [Math.floor(this.lngX(this.bounds.getWest(), coord.z)), Math.floor(this.latY(this.bounds.getNorth(), coord.z))], - [Math.ceil(this.lngX(this.bounds.getEast(), coord.z)), Math.ceil(this.latY(this.bounds.getSouth(), coord.z))] - ]; - const hit = coord.x >= level[0][0] && coord.x < level[1][0] && coord.y >= level[0][1] && coord.y < level[1][1]; + const level = { + minX: Math.floor(this.lngX(this.bounds.getWest(), coord.z)), + minY: Math.floor(this.latY(this.bounds.getNorth(), coord.z)), + maxX: Math.ceil(this.lngX(this.bounds.getEast(), coord.z)), + maxY: Math.ceil(this.latY(this.bounds.getSouth(), coord.z)) + }; + const hit = coord.x >= level.minX && coord.x < level.maxX && coord.y >= level.minY && coord.y < level.maxY; return hit; } diff --git a/test/unit/source/raster_tile_source.test.js b/test/unit/source/raster_tile_source.test.js new file mode 100644 index 00000000000..99415459f82 --- /dev/null +++ b/test/unit/source/raster_tile_source.test.js @@ -0,0 +1,65 @@ +'use strict'; +const test = require('mapbox-gl-js-test').test; +const RasterTileSource = require('../../../src/source/raster_tile_source'); +const window = require('../../../src/util/window'); + + +function createSource(options) { + const source = new RasterTileSource('id', options, { send: function() {} }, options.eventedParent); + source.onAdd({ + transform: { angle: 0, pitch: 0, showCollisionBoxes: false } + }); + + source.on('error', (e) => { + throw e.error; + }); + + return source; +} + +test('RasterTileSource', (t) => { + t.beforeEach((callback) => { + window.useFakeXMLHttpRequest(); + callback(); + }); + + t.afterEach((callback) => { + window.restore(); + callback(); + }); + + t.test('respects TileJSON.bounds', (t)=>{ + const source = createSource({ + minzoom: 0, + maxzoom: 22, + attribution: "Mapbox", + tiles: ["http://example.com/{z}/{x}/{y}.png"], + }); + source.setBounds([[-47, -7], [-45, -5]]); + t.false(source.hasTile({z: 8, x:96, y: 132}), 'returns false for tiles outside bounds'); + t.true(source.hasTile({z: 8, x:95, y: 132}), 'returns true for tiles inside bounds'); + t.end(); + }); + + t.test('respects TileJSON.bounds when loaded from TileJSON', (t)=>{ + window.server.respondWith('/source.json', JSON.stringify({ + minzoom: 0, + maxzoom: 22, + attribution: "Mapbox", + tiles: ["http://example.com/{z}/{x}/{y}.png"], + bounds: [[-47, -7], [-45, -5]] + })); + const source = createSource({ url: "/source.json" }); + + source.on('data', (e) => { + if (e.sourceDataType === 'metadata') { + t.false(source.hasTile({z: 8, x:96, y: 132}), 'returns false for tiles outside bounds'); + t.true(source.hasTile({z: 8, x:95, y: 132}), 'returns true for tiles inside bounds'); + t.end(); + } + }); + window.server.respond(); + }); + t.end(); + +}); diff --git a/test/unit/source/vector_tile_source.test.js b/test/unit/source/vector_tile_source.test.js index 1a2cceea6ca..c2d8951b5ea 100644 --- a/test/unit/source/vector_tile_source.test.js +++ b/test/unit/source/vector_tile_source.test.js @@ -16,6 +16,7 @@ function createSource(options) { throw e.error; }); + return source; } @@ -191,5 +192,26 @@ test('VectorTileSource', (t) => { t.end(); }); + + t.test('respects TileJSON.bounds when loaded from TileJSON', (t)=>{ + window.server.respondWith('/source.json', JSON.stringify({ + minzoom: 0, + maxzoom: 22, + attribution: "Mapbox", + tiles: ["http://example.com/{z}/{x}/{y}.png"], + bounds: [[-47, -7], [-45, -5]] + })); + const source = createSource({ url: "/source.json" }); + + source.on('data', (e) => { + if (e.sourceDataType === 'metadata') { + t.false(source.hasTile({z: 8, x:96, y: 132}), 'returns false for tiles outside bounds'); + t.true(source.hasTile({z: 8, x:95, y: 132}), 'returns true for tiles inside bounds'); + t.end(); + } + }); + window.server.respond(); + }); + t.end(); }); From 8aabe53c86f99a6a2b0581560f47a4b8fc348ff6 Mon Sep 17 00:00:00 2001 From: Anand Thakker Date: Fri, 7 Apr 2017 14:46:06 -0400 Subject: [PATCH 4/4] Add unit test checking that SourceCache respects Source#hasTile --- test/unit/source/source_cache.test.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/unit/source/source_cache.test.js b/test/unit/source/source_cache.test.js index 6d558038035..5214a818d00 100644 --- a/test/unit/source/source_cache.test.js +++ b/test/unit/source/source_cache.test.js @@ -24,6 +24,9 @@ function MockSourceType(id, sourceOptions, _dispatcher, eventedParent) { this.maxzoom = 22; util.extend(this, sourceOptions); this.setEventedParent(eventedParent); + if (sourceOptions.hasTile) { + this.hasTile = sourceOptions.hasTile; + } } loadTile(tile, callback) { if (sourceOptions.expires) { @@ -383,6 +386,27 @@ test('SourceCache#update', (t) => { sourceCache.onAdd(); }); + t.test('respects Source#hasTile method if it is present', (t) => { + const transform = new Transform(); + transform.resize(511, 511); + transform.zoom = 1; + + const sourceCache = createSourceCache({ + hasTile: (coord) => (coord.x !== 0) + }); + sourceCache.on('data', (e) => { + if (e.sourceDataType === 'metadata') { + sourceCache.update(transform); + t.deepEqual(sourceCache.getIds().sort(), [ + new TileCoord(1, 1, 0).id, + new TileCoord(1, 1, 1).id + ].sort()); + t.end(); + } + }); + sourceCache.onAdd(); + }); + t.test('removes unused tiles', (t) => { const transform = new Transform(); transform.resize(511, 511);