maplibre-tilepackage-protocol 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ // rollup.config.mjs
2
+ // node resolves makes npm packages be included in the bundle
3
+ import { nodeResolve } from "@rollup/plugin-node-resolve";
4
+ import commonjs from "@rollup/plugin-commonjs";
5
+
6
+ const externals = [
7
+ "@mapbox/vector-tile",
8
+ "pbf",
9
+ "@mapbox/point-geometry",
10
+ "@maplibre/vt-pbf",
11
+ "maplibre-local-glyphs",
12
+ ];
13
+
14
+ function onwarn(warning) {
15
+ if (warning.code === "CIRCULAR_DEPENDENCY") return;
16
+ console.warn(warning.message);
17
+ }
18
+
19
+ export default [
20
+ // ESM library build (dependencies external)
21
+ {
22
+ input: "src/index.js",
23
+ output: {
24
+ file: "dist/index.esm.js",
25
+ format: "esm",
26
+ sourcemap: true,
27
+ },
28
+ // Keep selected externals; bundle fflate (omit from externals)
29
+ external: externals,
30
+ plugins: [nodeResolve({ browser: true }), commonjs()],
31
+ onwarn,
32
+ },
33
+ // IIFE demo build (fully bundled)
34
+ {
35
+ input: "src/index.js",
36
+ output: {
37
+ file: "dist/bundle.js",
38
+ format: "iife",
39
+ name: "TilePackageProtocol",
40
+ sourcemap: true,
41
+ },
42
+ plugins: [nodeResolve({ browser: true }), commonjs()],
43
+ onwarn,
44
+ },
45
+ ];
@@ -59,6 +59,7 @@ export function jsonToHeader(json, files, etag) {
59
59
  maxLon: Number(json.extent.xmax),
60
60
  maxLat: Number(json.extent.ymax),
61
61
  files: files,
62
+ coverageMap: json.coverageMap,
62
63
  etag: etag,
63
64
  };
64
65
  }
@@ -239,11 +240,49 @@ export default async function getHeaderAndFileList(source) {
239
240
  root.metadataOffset = metadataOffset;
240
241
  root.metadataLength = tilePackageFiles["p12/metadata.json"].size;
241
242
  }
243
+
244
+ async function calculateCoverage(z, x, y, tilemap, coverageMap) {
245
+ z = z + 1;
246
+ x = x * 2;
247
+ y = y * 2;
248
+ if (coverageMap[z] == undefined) coverageMap[z] = {};
249
+ if (coverageMap[z][x] == undefined) coverageMap[z][x] = {};
250
+ if (coverageMap[z][x + 1] == undefined) coverageMap[z][x + 1] = {};
251
+ coverageMap[z][x][y] = tilemap[0];
252
+ if (isNaN(tilemap[0])) {
253
+ calculateCoverage(z, x, y, tilemap[0], coverageMap);
254
+ }
255
+ coverageMap[z][x + 1][y] = tilemap[1];
256
+ if (isNaN(tilemap[1])) {
257
+ calculateCoverage(z, x + 1, y, tilemap[1], coverageMap);
258
+ }
259
+ coverageMap[z][x][y + 1] = tilemap[2];
260
+ if (isNaN(tilemap[2])) {
261
+ calculateCoverage(z, x, y + 1, tilemap[2], coverageMap);
262
+ }
263
+ coverageMap[z][x + 1][y + 1] = tilemap[3];
264
+ if (isNaN(tilemap[3])) {
265
+ calculateCoverage(z, x + 1, y + 1, tilemap[3], coverageMap);
266
+ }
267
+ }
268
+ // Get and calculate the tilemap
269
+ if (source.coverageCheck > 0 && tilePackageFiles["p12/tilemap/root.json"]) {
270
+ const tilemap = await getJsonFromFile(
271
+ "p12/tilemap/root.json",
272
+ tilePackageFiles,
273
+ source,
274
+ );
275
+ const coverageMap = {};
276
+ coverageMap[0] = {};
277
+ coverageMap[0][0] = {};
278
+ coverageMap[0][0][0] = "blob";
279
+ calculateCoverage(0, 0, 0, tilemap.index, coverageMap);
280
+ root.coverageMap = coverageMap;
281
+ } else {
282
+ // Coverage map unavailable (either disabled or missing tilemap/root.json)
283
+ }
242
284
  }
243
- //TODO read iteminfo.xml....
244
285
 
245
286
  const header = jsonToHeader(root, tilePackageFiles, resp.etag);
246
-
247
- //return [header, [dirKey, rootDir.length, rootDir]];
248
287
  return [header, ""];
249
288
  }
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./maplibre-gl-js-protocol.js";
2
2
  export * from "./source.js";
3
3
  import { FetchSource } from "./source.js";
4
+ import subdivideVectorTile from "./tilecutter/subdivide.js";
4
5
  import defaultDecompress from "./default-decompress.js";
5
6
  import getJsonFromFile from "./get-json-from-file.js";
6
7
  import SharedPromiseCache from "./shared-promise-cache.js";
@@ -11,12 +12,7 @@ function calculateFilename(z, x, y, header) {
11
12
  const baseCol = Math.floor(x / 128) * 128;
12
13
  const rowHex = baseRow.toString(16).padStart(4, "0");
13
14
  const colHex = baseCol.toString(16).padStart(4, "0");
14
- let basePath;
15
- if (header.type === "tpkx") {
16
- basePath = `tile`;
17
- } else {
18
- basePath = `p12/tile`;
19
- }
15
+ let basePath = header.type === "tpkx" ? "tile" : "p12/tile";
20
16
  return `${basePath}/L${zoom}/R${rowHex}C${colHex}.bundle`;
21
17
  }
22
18
 
@@ -26,10 +22,6 @@ function getTileInfoFromTileIndex(tileIndex, z, x, y) {
26
22
  return tileIndex[row][col];
27
23
  }
28
24
 
29
- /**
30
- * Error thrown when a response for TilePackage over HTTP does not match previous, cached parts of the archive.
31
- * The default TilePackage implementation will catch this error once internally and retry a request.
32
- */
33
25
  class EtagMismatch extends Error {
34
26
  constructor(message) {
35
27
  super(message);
@@ -38,24 +30,29 @@ class EtagMismatch extends Error {
38
30
  }
39
31
 
40
32
  export class TilePackage {
41
- constructor(source, cache, decompress) {
33
+ constructor(source, options) {
34
+ // Default coverageCheck enabled; disable with coverageCheck:false
35
+ this.coverageCheck = 1;
36
+ if (options && options.coverageCheck === false) this.coverageCheck = 0;
37
+ // Max dz guard (skip subdivision beyond this): default 8
38
+ this.maxDz =
39
+ options && typeof options.maxDz === "number" ? options.maxDz : 8;
42
40
  if (typeof source === "string") {
43
- this.source = new FetchSource(source);
41
+ const opts = this.coverageCheck
42
+ ? { coverageCheck: this.coverageCheck }
43
+ : undefined;
44
+ this.source = new FetchSource(source, opts);
44
45
  } else {
45
46
  this.source = source;
47
+ // Propagate default coverageCheck to existing Source instances if undefined
48
+ if (this.source && this.source.coverageCheck === undefined) {
49
+ this.source.coverageCheck = this.coverageCheck;
50
+ }
46
51
  }
47
-
48
- if (decompress) {
49
- this.decompress = decompress;
50
- } else {
51
- this.decompress = defaultDecompress;
52
- }
53
-
54
- if (cache) {
55
- this.cache = cache;
56
- } else {
57
- this.cache = new SharedPromiseCache();
58
- }
52
+ this.decompress =
53
+ options && options.decompress ? options.decompress : defaultDecompress;
54
+ this.cache =
55
+ options && options.cache ? options.cache : new SharedPromiseCache();
59
56
  }
60
57
 
61
58
  async getHeader() {
@@ -63,42 +60,99 @@ export class TilePackage {
63
60
  }
64
61
 
65
62
  async getZxyAttempt(z, x, y, signal) {
66
- //y = (1 << z) - 1 - y; //TMS vs XYZ
67
63
  const header = await this.cache.getHeader(this.source);
68
- if (z < header.minZoom || z > header.maxZoom) {
69
- return undefined;
70
- }
64
+ if (z < header.minZoom || z > header.maxZoom) return undefined;
71
65
  const file = calculateFilename(z, x, y, header);
72
- if (!header.files[file]) {
73
- return undefined;
74
- }
75
- const tileIndex = await this.cache.getTileIndex(
76
- this.source,
77
- file,
78
- header,
79
- signal,
80
- );
81
- const tileInfo = getTileInfoFromTileIndex(tileIndex, z, x, y);
82
- if (tileInfo && tileInfo.tileSize > 0) {
83
- const tileOffset =
84
- header.files[file].absoluteOffset + tileInfo.tileOffset;
85
- const resp = await this.source.getBytes(
86
- tileOffset,
87
- tileInfo.tileSize,
66
+ let tileInfo = null;
67
+ if (header.files[file]) {
68
+ const tileIndex = await this.cache.getTileIndex(
69
+ this.source,
70
+ file,
71
+ header,
88
72
  signal,
89
- header.etag,
90
73
  );
91
- const data = await this.decompress(resp.data, header.tileCompression);
92
- return {
93
- data: data,
94
- cacheControl: resp.cacheControl,
95
- expires: resp.expires,
96
- };
97
- } else {
98
- //return await this.getZxyAttempt(z - 1, x / 2, y / 2, signal);
99
- // Return undefined if the tile is not found
100
- return undefined;
74
+ tileInfo = getTileInfoFromTileIndex(tileIndex, z, x, y);
75
+ if (tileInfo && tileInfo.tileSize > 0) {
76
+ const tileOffset =
77
+ header.files[file].absoluteOffset + tileInfo.tileOffset;
78
+ const resp = await this.source.getBytes(
79
+ tileOffset,
80
+ tileInfo.tileSize,
81
+ signal,
82
+ header.etag,
83
+ );
84
+ const data = await this.decompress(resp.data, header.tileCompression);
85
+ return { data, cacheControl: resp.cacheControl, expires: resp.expires };
86
+ }
87
+ }
88
+ if (header.packageType === "vtpk" && header.coverageMap) {
89
+ // Ascend to find parent with value 1
90
+ let pz = z,
91
+ px = x,
92
+ py = y,
93
+ foundParent = null;
94
+ while (pz > header.minZoom) {
95
+ pz -= 1;
96
+ px = Math.floor(px / 2);
97
+ py = Math.floor(py / 2);
98
+ const level = header.coverageMap[pz];
99
+ const val = level && level[px] ? level[px][py] : undefined;
100
+ if (val === 1) {
101
+ foundParent = { pz, px, py };
102
+ break;
103
+ }
104
+ }
105
+ if (foundParent) {
106
+ const parent = await this.getZxyAttempt(
107
+ foundParent.pz,
108
+ foundParent.px,
109
+ foundParent.py,
110
+ signal,
111
+ );
112
+ if (parent && parent.data) {
113
+ const cached = this.cache.getSubdivided(this.source, z, x, y);
114
+ if (cached) {
115
+ return {
116
+ data: cached,
117
+ cacheControl: parent.cacheControl,
118
+ expires: parent.expires,
119
+ };
120
+ }
121
+ const dz = z - foundParent.pz;
122
+ if (dz > this.maxDz) {
123
+ return undefined; // exceed hard cap
124
+ }
125
+ try {
126
+ const { data: overzoomed } = subdivideVectorTile(
127
+ parent.data,
128
+ foundParent.pz,
129
+ foundParent.px,
130
+ foundParent.py,
131
+ z,
132
+ x,
133
+ y,
134
+ { buffer: 128, maxDzWarn: this.maxDz - 1 },
135
+ );
136
+ this.cache.setSubdivided(this.source, z, x, y, overzoomed);
137
+ return {
138
+ data: overzoomed,
139
+ cacheControl: parent.cacheControl,
140
+ expires: parent.expires,
141
+ };
142
+ } catch (e) {
143
+ console.warn(
144
+ `[tilepackage subdivide] failed for z=${z} x=${x} y=${y}:`,
145
+ e,
146
+ );
147
+ }
148
+ } else {
149
+ console.warn(
150
+ `[tilepackage subdivide] parent tile missing at z=${foundParent.pz} x=${foundParent.px} y=${foundParent.py}`,
151
+ );
152
+ }
153
+ }
101
154
  }
155
+ return undefined;
102
156
  }
103
157
 
104
158
  async getZxy(z, x, y, signal) {
@@ -115,7 +169,6 @@ export class TilePackage {
115
169
 
116
170
  async getMetadataAttempt() {
117
171
  const header = await this.cache.getHeader(this.source);
118
-
119
172
  let metadata = {};
120
173
  if (header.packageType === "vtpk") {
121
174
  const resp = await this.source.getBytes(
@@ -124,12 +177,10 @@ export class TilePackage {
124
177
  undefined,
125
178
  header.etag,
126
179
  );
127
-
128
180
  const decoder = new TextDecoder("utf-8");
129
181
  metadata = JSON.parse(decoder.decode(resp.data));
130
182
  }
131
183
  metadata.name = header.name;
132
-
133
184
  return metadata;
134
185
  }
135
186
 
@@ -147,10 +198,7 @@ export class TilePackage {
147
198
 
148
199
  async getResourceAttempt(file, signal) {
149
200
  const header = await this.cache.getHeader(this.source);
150
- if (!header.files[file]) {
151
- return undefined;
152
- }
153
-
201
+ if (!header.files[file]) return undefined;
154
202
  const resource = await this.cache.getResource(
155
203
  this.source,
156
204
  file,
@@ -180,37 +228,59 @@ export class TilePackage {
180
228
  const header = await this.cache.getHeader(this.source);
181
229
  const sourceKey = this.source.getKey();
182
230
  if (header.packageType === "vtpk") {
231
+ const metadata = await getJsonFromFile(
232
+ "p12/root.json",
233
+ header.files,
234
+ this.source,
235
+ );
183
236
  const style = await getJsonFromFile(
184
237
  "p12/resources/styles/root.json",
185
238
  header.files,
186
239
  this.source,
187
240
  );
188
- style.sources.esri.url = `"tilepackage://${sourceKey}`;
189
- style.sources.esri.maxzoom = header.maxZoom || 22;
241
+ if (style.sources && style.sources.esri) {
242
+ delete style.sources.esri.url;
243
+ style.sources.esri.tiles = [`tilepackage://${sourceKey}/{z}/{x}/{y}`];
244
+ style.sources.esri.type = style.sources.esri.type || "vector";
245
+ style.sources.esri.minzoom = header.minZoom || 0;
246
+ style.sources.esri.maxzoom = header.maxZoom || 22;
247
+ }
248
+ for (const k in style.sources) {
249
+ const src = style.sources[k];
250
+ if (
251
+ src &&
252
+ typeof src.url === "string" &&
253
+ src.url.startsWith("tilepackage://")
254
+ ) {
255
+ delete src.url;
256
+ if (!src.tiles)
257
+ src.tiles = [`tilepackage://${sourceKey}/{z}/{x}/{y}`];
258
+ }
259
+ }
260
+ if (metadata.copyrightText)
261
+ style.sources.esri.attribution = metadata.copyrightText;
190
262
  style.glyphs = `tilepackage://${sourceKey}/{fontstack}/{range}`;
191
263
  style.sprite = `tilepackage://${sourceKey}/sprite`;
192
-
264
+ if (this.debug)
265
+ console.debug(
266
+ "[tilepackage style] rewritten esri source",
267
+ style.sources.esri,
268
+ );
193
269
  return style;
194
- } else {
195
- return {
196
- version: 8,
197
- sources: {
198
- esri: {
199
- type: "raster",
200
- tileSize: header.tileSize || 256,
201
- url: `"tilepackage://${sourceKey}`,
202
- maxzoom: header.maxZoom || 22,
203
- },
204
- },
205
- layers: [
206
- {
207
- id: "tilepackageraster",
208
- type: "raster",
209
- source: "esri",
210
- },
211
- ],
212
- };
213
270
  }
271
+ return {
272
+ version: 8,
273
+ sources: {
274
+ esri: {
275
+ type: "raster",
276
+ tileSize: header.tileSize || 256,
277
+ tiles: [`tilepackage://${sourceKey}/{z}/{x}/{y}`],
278
+ maxzoom: header.maxZoom || 22,
279
+ minzoom: header.minZoom || 0,
280
+ },
281
+ },
282
+ layers: [{ id: "tilepackageraster", type: "raster", source: "esri" }],
283
+ };
214
284
  }
215
285
 
216
286
  async getStyle() {
@@ -229,7 +299,6 @@ export class TilePackage {
229
299
  const header = await this.getHeader();
230
300
  const metadata = await this.getMetadata();
231
301
  const ext = header.tileType;
232
-
233
302
  const tileJson = {
234
303
  tilejson: "3.0.0",
235
304
  scheme: "xyz",
@@ -238,21 +307,11 @@ export class TilePackage {
238
307
  version: header.version,
239
308
  bounds: [header.minLon, header.minLat, header.maxLon, header.maxLat],
240
309
  };
241
- if (metadata.vector_layers) {
242
- tileJson.vector_layers = metadata.vector_layers;
243
- }
244
- if (metadata.attribution) {
245
- tileJson.attribution = metadata.attribution;
246
- }
247
- if (metadata.description) {
248
- tileJson.description = metadata.description;
249
- }
250
- if (header.minZoom) {
251
- tileJson.minzoom = header.minZoom;
252
- }
253
- if (header.maxZoom) {
254
- tileJson.maxzoom = header.maxZoom;
255
- }
310
+ if (metadata.vector_layers) tileJson.vector_layers = metadata.vector_layers;
311
+ if (metadata.attribution) tileJson.attribution = metadata.attribution;
312
+ if (metadata.description) tileJson.description = metadata.description;
313
+ if (header.minZoom) tileJson.minzoom = header.minZoom;
314
+ if (header.maxZoom) tileJson.maxzoom = header.maxZoom;
256
315
  return tileJson;
257
316
  }
258
317
  }
@@ -52,6 +52,7 @@ export class Protocol {
52
52
  this.errorOnMissingTile = options
53
53
  ? options.errorOnMissingTile || false
54
54
  : false;
55
+ this.debug = options ? options.debug || false : false;
55
56
  this.getData = async (params, abortController) => {
56
57
  if (params.type === "json") {
57
58
  let tilePackageUrl = params.url.substr(14); // TODO fix this to be more robust
@@ -91,9 +92,10 @@ export class Protocol {
91
92
  }
92
93
  // This is a request for a tile json
93
94
  if (this.metadata) {
94
- return {
95
- data: await instance.getTileJson(params.url),
96
- };
95
+ const tj = await instance.getTileJson(params.url);
96
+ if (this.debug)
97
+ console.debug("[tilepackage] tilejson (metadata)", tj);
98
+ return { data: tj };
97
99
  }
98
100
  const h = await instance.getHeader();
99
101
  if (h.minLon >= h.maxLon || h.minLat >= h.maxLat) {
@@ -102,14 +104,15 @@ export class Protocol {
102
104
  );
103
105
  }
104
106
 
105
- return {
106
- data: {
107
- tiles: [`${params.url}/{z}/{x}/{y}`],
108
- minzoom: h.minZoom,
109
- maxzoom: h.maxZoom,
110
- bounds: [h.minLon, h.minLat, h.maxLon, h.maxLat],
111
- },
107
+ const synthesized = {
108
+ tiles: [`${params.url}/{z}/{x}/{y}`],
109
+ minzoom: h.minZoom,
110
+ maxzoom: h.maxZoom,
111
+ bounds: [h.minLon, h.minLat, h.maxLon, h.maxLat],
112
112
  };
113
+ if (this.debug)
114
+ console.debug("[tilepackage] tilejson (synthesized)", synthesized);
115
+ return { data: synthesized };
113
116
  //}
114
117
  }
115
118
  // TODO handle other paths ???
@@ -197,6 +200,13 @@ export class Protocol {
197
200
  const y = result[4];
198
201
  const header = await instance.getHeader();
199
202
  const resp = await instance.getZxy(+z, +x, +y, abortController.signal);
203
+ if (this.debug)
204
+ console.debug("[tilepackage] tile fetch", {
205
+ z: +z,
206
+ x: +x,
207
+ y: +y,
208
+ found: !!resp,
209
+ });
200
210
  if (resp) {
201
211
  return {
202
212
  data: new Uint8Array(resp.data),
@@ -206,10 +216,13 @@ export class Protocol {
206
216
  }
207
217
  if (header.tileType === "pbf") {
208
218
  if (this.errorOnMissingTile) {
219
+ //*
209
220
  const e = new Error(
210
- "Tile not found in Tile Package, this is normal for VTPK files with variable tile depth.",
211
- ); // e.name is 'Error'
221
+ `Tile [${z},${x},${y}] not found in Tile Package, normal for VTPK with variable depth.`,
222
+ );
212
223
  e.name = "TileError";
224
+ if (this.debug)
225
+ console.debug("[tilepackage] missing tile", { z, x, y });
213
226
  throw e;
214
227
  }
215
228
  return { data: new Uint8Array() };
@@ -229,12 +242,15 @@ export class Protocol {
229
242
  */
230
243
  add(p) {
231
244
  this.tiles.set(p.source.getKey(), p);
245
+ if (this.debug)
246
+ console.debug("[tilepackage] add instance", p.source.getKey());
232
247
  }
233
248
 
234
249
  /**
235
250
  * Fetch a {@link TilePackage} instance by URL, for remote TilePackage instances.
236
251
  */
237
252
  get(url) {
253
+ if (this.debug) console.debug("[tilepackage] get instance", url);
238
254
  return this.tiles.get(url);
239
255
  }
240
256
  }
@@ -80,6 +80,7 @@ export default class SharedPromiseCache {
80
80
  this.maxCacheEntries = maxCacheEntries;
81
81
  this.counter = 1;
82
82
  //this.decompress = decompress;
83
+ this.subdivided = new Map(); // key: sourceKey|z|x|y -> Uint8Array
83
84
  }
84
85
 
85
86
  async getHeader(source) {
@@ -174,6 +175,28 @@ export default class SharedPromiseCache {
174
175
  }
175
176
  }
176
177
 
178
+ getSubdivided(source, z, x, y) {
179
+ const key = `${source.getKey()}|${z}|${x}|${y}|Subdivided`;
180
+ return this.subdivided.get(key);
181
+ }
182
+
183
+ setSubdivided(source, z, x, y, bytes) {
184
+ const key = `${source.getKey()}|${z}|${x}|${y}|Subdivided`;
185
+ if (!this.subdivided.has(key)) {
186
+ this.subdivided.set(key, bytes);
187
+ // Optional lightweight prune if huge:
188
+ if (this.subdivided.size > this.maxCacheEntries * 2) {
189
+ // Remove oldest half (no usage tracking kept; iterate arbitrary)
190
+ let toRemove = Math.floor(this.subdivided.size / 2);
191
+ for (const k of this.subdivided.keys()) {
192
+ this.subdivided.delete(k);
193
+ toRemove--;
194
+ if (toRemove <= 0) break;
195
+ }
196
+ }
197
+ }
198
+ }
199
+
177
200
  async invalidate(source) {
178
201
  const key = source.getKey();
179
202
  if (this.invalidations.get(key)) {
package/src/source.js CHANGED
@@ -18,9 +18,12 @@ export class Source {
18
18
  * see https://developer.mozilla.org/en-US/docs/Web/API/File_API
19
19
  */
20
20
  export class FileSource extends Source {
21
- constructor(file) {
21
+ constructor(file, options) {
22
22
  super();
23
23
  this.file = file;
24
+ if (options && options.coverageCheck) {
25
+ this.coverageCheck = options.coverageCheck;
26
+ }
24
27
  }
25
28
 
26
29
  getKey() {
@@ -48,7 +51,7 @@ export class FileSource extends Source {
48
51
  * This also works around browser and storage-specific edge cases.
49
52
  */
50
53
  export class FetchSource extends Source {
51
- constructor(url, customHeaders = new Headers()) {
54
+ constructor(url, options, customHeaders = new Headers()) {
52
55
  super();
53
56
  this.url = url;
54
57
  this.customHeaders = customHeaders;
@@ -63,6 +66,9 @@ export class FetchSource extends Source {
63
66
  if (isWindows && isChromiumBased) {
64
67
  this.chromeWindowsNoCache = true;
65
68
  }
69
+ if (options && options.coverageCheck) {
70
+ this.coverageCheck = options.coverageCheck;
71
+ }
66
72
  }
67
73
 
68
74
  getKey() {