map-gl-offline 0.8.9 → 0.9.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.
Files changed (71) hide show
  1. package/README.md +62 -192
  2. package/dist/idb-offline-sw.js +78 -11
  3. package/dist/index.d.ts +3 -3
  4. package/dist/index.esm.js +2032 -808
  5. package/dist/index.esm.js.map +1 -1
  6. package/dist/index.js +2039 -806
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.umd.js +2367 -933
  9. package/dist/index.umd.js.map +1 -1
  10. package/dist/main.d.ts +3 -0
  11. package/dist/managers/offlineMapManager/analyticsManagement.d.ts +1 -1
  12. package/dist/managers/offlineMapManager/base.d.ts +5 -5
  13. package/dist/managers/offlineMapManager/cleanupManagement.d.ts +6 -1
  14. package/dist/managers/offlineMapManager/importExportManagement.d.ts +1 -1
  15. package/dist/managers/offlineMapManager/maintenanceManagement.d.ts +1 -1
  16. package/dist/managers/offlineMapManager/regionManagement.d.ts +2 -2
  17. package/dist/managers/offlineMapManager/resourceManagement.d.ts +1 -1
  18. package/dist/managers/offlineMapManager/styleManagement.d.ts +1 -1
  19. package/dist/services/analyticsService.d.ts +1 -1
  20. package/dist/services/cleanupService.d.ts +15 -1
  21. package/dist/services/fontService.d.ts +1 -1
  22. package/dist/services/glyphService.d.ts +1 -1
  23. package/dist/services/importExportService.d.ts +1 -1
  24. package/dist/services/maintenanceService.d.ts +2 -2
  25. package/dist/services/modelService.d.ts +3 -1
  26. package/dist/services/regionService.d.ts +21 -11
  27. package/dist/services/resourceService.d.ts +2 -2
  28. package/dist/services/runtimeAssetService.d.ts +12 -0
  29. package/dist/services/spriteService.d.ts +1 -1
  30. package/dist/services/styleService.d.ts +14 -1
  31. package/dist/services/tileService.d.ts +1 -1
  32. package/dist/sheet/collar.d.ts +34 -0
  33. package/dist/storage/indexedDbManager.d.ts +1 -1
  34. package/dist/style.css +2 -2
  35. package/dist/sw/shared.d.ts +31 -3
  36. package/dist/types/import-export.d.ts +5 -0
  37. package/dist/types/maintenance.d.ts +3 -1
  38. package/dist/types/region.d.ts +29 -2
  39. package/dist/types/style.d.ts +4 -0
  40. package/dist/types/tile.d.ts +15 -0
  41. package/dist/ui/components/PanelActions.d.ts +1 -1
  42. package/dist/ui/components/PanelHeader.d.ts +1 -1
  43. package/dist/ui/components/RegionList.d.ts +1 -1
  44. package/dist/ui/components/shared/Button.d.ts +6 -0
  45. package/dist/ui/components/shared/LanguageSelector.d.ts +1 -1
  46. package/dist/ui/components/shared/MapControlButton.d.ts +1 -1
  47. package/dist/ui/components/shared/PanelContent.d.ts +3 -3
  48. package/dist/ui/components/shared/RegionDrawingTool.d.ts +2 -2
  49. package/dist/ui/controls/polygonControl.d.ts +7 -1
  50. package/dist/ui/controls/regionControl.d.ts +3 -3
  51. package/dist/ui/managers/ControlButtonManager.d.ts +1 -1
  52. package/dist/ui/managers/PanelManager.d.ts +16 -3
  53. package/dist/ui/managers/downloadManager.d.ts +7 -3
  54. package/dist/ui/modals/mbtilesModal.d.ts +1 -1
  55. package/dist/ui/modals/regionDetailsModal.d.ts +1 -1
  56. package/dist/ui/modals/regionFormModal.d.ts +11 -1
  57. package/dist/ui/offlineManagerControl.d.ts +4 -1
  58. package/dist/ui/translations/ar.d.ts +15 -1
  59. package/dist/ui/translations/en.d.ts +15 -1
  60. package/dist/ui/translations/index.d.ts +2 -0
  61. package/dist/utils/constants.d.ts +4 -2
  62. package/dist/utils/convertStyleForSW.d.ts +1 -1
  63. package/dist/utils/download.d.ts +1 -1
  64. package/dist/utils/formatting.d.ts +11 -0
  65. package/dist/utils/importResolver.d.ts +1 -1
  66. package/dist/utils/styleConfig.d.ts +39 -0
  67. package/dist/utils/styleProviderUtils.d.ts +21 -8
  68. package/dist/utils/styleUtils.d.ts +61 -10
  69. package/dist/utils/tileRange.d.ts +63 -0
  70. package/dist/utils/validation.d.ts +1 -1
  71. package/package.json +44 -39
package/dist/index.esm.js CHANGED
@@ -1,4 +1,4 @@
1
- import { openDB } from 'idb';
1
+ import { openDB, unwrap } from 'idb';
2
2
  import * as tilebelt from '@mapbox/tilebelt';
3
3
  import i18next from 'i18next';
4
4
  import { area } from '@turf/area';
@@ -163,8 +163,10 @@ const MAPBOX_API = {
163
163
  /**
164
164
  * Mapbox Standard sub-tilesets that are sparse-by-design across the planet.
165
165
  * These only have tiles where the underlying feature (indoor venues, landmark
166
- * POIs, 3D procedural buildings) actually exists, and return 404 for nearly
167
- * every other coordinate. The tile downloader pre-skips any source whose tile
166
+ * POIs, 3D procedural buildings) actually exists, and return 404 for every other
167
+ * coordinate, though they can be dense inside a major city. Unless the style's
168
+ * config has switched on a feature that needs one, the tile downloader pre-skips
169
+ * any source whose tile
168
170
  * URL templates reference one of these tileset IDs, so we never issue probe
169
171
  * or download requests for them — eliminating the 404 noise in devtools.
170
172
  *
@@ -366,7 +368,10 @@ async function openOfflineMapDB() {
366
368
  // Migration: v2 -> v3
367
369
  // Move regions from 'regions' store to styles.regions[]
368
370
  if (oldVersion > 0 && oldVersion < 3) {
369
- migrateRegionsToStyles(transaction);
371
+ // Unwrap to the raw IDBTransaction: on idb's wrapper, getAll()/get()
372
+ // return Promises, so the onsuccess callbacks the migration relies
373
+ // on would never fire and no region would be migrated.
374
+ migrateRegionsToStyles(unwrap(transaction));
370
375
  }
371
376
  // Migration: v3 -> v4
372
377
  // Adds the `models` store for Mapbox Standard 3D model assets.
@@ -1027,6 +1032,20 @@ function formatBytes(bytes) {
1027
1032
  const i = Math.floor(Math.log(bytes) / Math.log(k));
1028
1033
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
1029
1034
  }
1035
+ /**
1036
+ * A stored style's display name: its own `name`, or else the last meaningful
1037
+ * part of its URL ("liberty" for `.../styles/liberty`), or else its key.
1038
+ */
1039
+ function styleDisplayName(entry) {
1040
+ if (entry.style?.name)
1041
+ return entry.style.name;
1042
+ const path = (entry.originalUrl ?? '').split(/[?#]/)[0];
1043
+ const segments = path
1044
+ .split('/')
1045
+ .map(segment => segment.replace(/\.json$/i, ''))
1046
+ .filter(segment => segment && !/^(style|styles)$/i.test(segment) && !segment.includes(':'));
1047
+ return segments.at(-1) ?? entry.key;
1048
+ }
1030
1049
  /**
1031
1050
  * Format date to human readable string
1032
1051
  */
@@ -1254,6 +1273,39 @@ function deriveTileExtension(tiles) {
1254
1273
  * They have different shapes so cannot be shared; the key computation
1255
1274
  * can be and is.
1256
1275
  */
1276
+ /**
1277
+ * `decodeURIComponent` that returns the input unchanged instead of throwing
1278
+ * on a malformed escape (e.g. a literal `%` in a font or source name), so a
1279
+ * bad path becomes a lookup miss / 404 rather than a rejected fetch.
1280
+ */
1281
+ function safeDecodeURIComponent(value) {
1282
+ try {
1283
+ return decodeURIComponent(value);
1284
+ }
1285
+ catch {
1286
+ return value;
1287
+ }
1288
+ }
1289
+ // ---------------------------------------------------------------------------
1290
+ // Mapbox GL runtime assets
1291
+ // ---------------------------------------------------------------------------
1292
+ const MAPBOX_RUNTIME_ORIGIN = 'https://api.mapbox.com';
1293
+ const MAPBOX_RUNTIME_CACHE = 'map-gl-offline-runtime-v1';
1294
+ /**
1295
+ * Code Mapbox GL fetches from api.mapbox.com while rendering, so it has to be
1296
+ * available offline too. Paths are versioned, so a renderer that moves to a new
1297
+ * version misses here; `npm run check:mapbox-standard` compares this list with
1298
+ * the installed renderer. They match Mapbox GL 3.34; an app on a version with
1299
+ * other paths gets no offline copy of those files.
1300
+ */
1301
+ const MAPBOX_RUNTIME_ASSETS = [
1302
+ '/mapbox-gl-js/draco_decoder_gltf_v1.5.6.wasm',
1303
+ '/mapbox-gl-js/meshopt_base_v1.2.wasm',
1304
+ '/mapbox-gl-js/meshopt_simd_v1.2.wasm',
1305
+ '/mapbox-gl-js/rtl_text_v1.0.0.wasm',
1306
+ '/mapbox-gl-js/building-gen/building_gen_v1.2.6.wasm',
1307
+ '/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.3.0/mapbox-gl-rtl-text.js',
1308
+ ];
1257
1309
  /**
1258
1310
  * Extensions to try in order when the requested extension misses. `glb` is
1259
1311
  * last so batched-model sources (Mapbox Standard 3D buildings) resolve when
@@ -1291,9 +1343,10 @@ function findStyleByRegionIdIn(styles, regionId) {
1291
1343
  // Glyph candidate keys
1292
1344
  // ---------------------------------------------------------------------------
1293
1345
  /**
1294
- * Parse `FontA,FontB,FontC/0-255.pbf` into (fontstacks, rangePart). Mapbox
1295
- * requests a comma-joined font-family fallback chain; each glyph is stored
1296
- * individually, so the caller tries each fontstack in order.
1346
+ * Parse `FontA,FontB,FontC/0-255.pbf` into (fontstacks, rangePart). Older Mapbox
1347
+ * GL requests a comma-joined font-family fallback chain (current versions fetch
1348
+ * each font and merge them client-side); each glyph is stored individually, so
1349
+ * the caller tries each fontstack in order.
1297
1350
  */
1298
1351
  function parseGlyphPath(decodedPath) {
1299
1352
  const pathParts = decodedPath.split('/');
@@ -1305,6 +1358,47 @@ function parseGlyphPath(decodedPath) {
1305
1358
  .filter(Boolean);
1306
1359
  return { fontstacks, rangePart };
1307
1360
  }
1361
+ function readVarint(bytes, start) {
1362
+ let value = 0;
1363
+ let multiplier = 1;
1364
+ for (let pos = start; pos < bytes.length && pos < start + 5; pos++) {
1365
+ value += (bytes[pos] & 0x7f) * multiplier;
1366
+ if (bytes[pos] < 0x80)
1367
+ return { value, next: pos + 1 };
1368
+ multiplier *= 128;
1369
+ }
1370
+ return null;
1371
+ }
1372
+ /**
1373
+ * True for a glyph range PBF holding only its font name and range. Servers
1374
+ * return these for blocks a font doesn't cover, e.g. `DIN Pro Medium/1536-1791`.
1375
+ * Serving one in place of a later font in the chain blanks those labels.
1376
+ *
1377
+ * Reads `glyphs { fontstack stacks = 1 { name = 1; range = 2; glyph = 3 } }`.
1378
+ * Anything unexpected counts as not empty, so unknown data is still served.
1379
+ */
1380
+ function isEmptyGlyphRange(data) {
1381
+ const bytes = new Uint8Array(data);
1382
+ if (bytes[0] !== 0x0a)
1383
+ return false;
1384
+ const stack = readVarint(bytes, 1);
1385
+ if (!stack)
1386
+ return false;
1387
+ const end = stack.next + stack.value;
1388
+ if (end !== bytes.length)
1389
+ return false;
1390
+ let pos = stack.next;
1391
+ while (pos < end) {
1392
+ const tag = bytes[pos];
1393
+ if ((tag & 0x07) !== 2 || tag >> 3 === 3)
1394
+ return false;
1395
+ const length = readVarint(bytes, pos + 1);
1396
+ if (!length)
1397
+ return false;
1398
+ pos = length.next + length.value;
1399
+ }
1400
+ return true;
1401
+ }
1308
1402
  /**
1309
1403
  * Build the list of keys to try for a single (fontstack, range) pair.
1310
1404
  * Order: actualStyleId variants first (most common), then downloadId,
@@ -1475,10 +1569,10 @@ function clearAllCaches() {
1475
1569
  tileCache.clear();
1476
1570
  }
1477
1571
  /**
1478
- * Find a style entry that contains the given region ID
1479
- * Since styles are stored by style key but patched with region IDs,
1480
- * we need to search all styles to find which one contains this region
1481
- * Results are cached to avoid repeated DB queries during tile fetching
1572
+ * Resolve the `{id}` segment of an `idb://{id}/...` URL to its style entry.
1573
+ * Styles patched since URLs became style-keyed use the style key itself (one
1574
+ * keyed read); older stored styles use a region id, which needs a scan of all
1575
+ * styles. Results are cached to avoid repeated DB queries during tile fetching.
1482
1576
  */
1483
1577
  async function findStyleByRegionId(db, regionId) {
1484
1578
  // Check cache first
@@ -1487,6 +1581,11 @@ async function findStyleByRegionId(db, regionId) {
1487
1581
  return cached.styleEntry;
1488
1582
  }
1489
1583
  try {
1584
+ const direct = (await db.get('styles', regionId));
1585
+ if (direct) {
1586
+ regionToStyleCache.set(regionId, { styleEntry: direct, timestamp: Date.now() });
1587
+ return direct;
1588
+ }
1490
1589
  const allStyles = await db.getAll('styles');
1491
1590
  const hit = findStyleByRegionIdIn(allStyles, regionId);
1492
1591
  if (hit) {
@@ -1576,9 +1675,11 @@ async function idbFetchHandler(url, init) {
1576
1675
  }
1577
1676
  const db = await dbPromise;
1578
1677
  const parsed = url.replace('idb://', '').split('/');
1579
- const [downloadId, type, ...rest] = parsed;
1678
+ // A Request's URL arrives percent-encoded, so decode the style id as the Service Worker does.
1679
+ const [encodedDownloadId, type, ...rest] = parsed;
1680
+ const downloadId = safeDecodeURIComponent(encodedDownloadId ?? '');
1580
1681
  const resourcePath = rest.join('/');
1581
- const decodedResourcePath = decodeURIComponent(resourcePath);
1682
+ const decodedResourcePath = safeDecodeURIComponent(resourcePath);
1582
1683
  const key = `${downloadId}::${decodedResourcePath}`;
1583
1684
  try {
1584
1685
  switch (type) {
@@ -1597,7 +1698,7 @@ async function idbFetchHandler(url, init) {
1597
1698
  // MapLibre can request fractional zoom levels (e.g., 12.5)
1598
1699
  // but tiles are stored with integer zoom levels, so floor the value
1599
1700
  const z = Math.floor(parseFloat(pathParts[pathParts.length - 3]));
1600
- const sourceKey = pathParts.slice(0, pathParts.length - 3).join('/');
1701
+ const sourceKey = safeDecodeURIComponent(pathParts.slice(0, pathParts.length - 3).join('/'));
1601
1702
  const yMatch = yExt.match(/^(\d+)\.(\w+)$/);
1602
1703
  if (yMatch) {
1603
1704
  const y = parseInt(yMatch[1]);
@@ -1635,7 +1736,7 @@ async function idbFetchHandler(url, init) {
1635
1736
  if (pathParts.length === 1) {
1636
1737
  // Old format without sourceKey - try to extract from the URL
1637
1738
  const encodedTileUrl = pathParts[0];
1638
- const tileUrl = decodeURIComponent(encodedTileUrl);
1739
+ const tileUrl = safeDecodeURIComponent(encodedTileUrl);
1639
1740
  // Try to extract source from URL pattern
1640
1741
  // For example: https://domain.com/service/source/vt/{z}/{x}/{y}.pbf
1641
1742
  const urlParts = tileUrl.split('/');
@@ -1679,21 +1780,30 @@ async function idbFetchHandler(url, init) {
1679
1780
  const actualStyleId = styleEntry?.key || downloadId;
1680
1781
  const { fontstacks, rangePart } = parseGlyphPath(decodedResourcePath);
1681
1782
  idbLogger.debug(`Trying ${fontstacks.length} fonts in fallback order: ${fontstacks.join(', ')}`);
1783
+ const glyphResponse = (data) => new Response(data, {
1784
+ status: 200,
1785
+ headers: { 'Content-Type': 'application/x-protobuf' },
1786
+ });
1787
+ let emptyRange;
1682
1788
  for (const fontstack of fontstacks) {
1683
1789
  const candidateKeys = glyphCandidateKeys(actualStyleId, downloadId, fontstack, rangePart);
1684
1790
  for (const candidateKey of candidateKeys) {
1685
1791
  const resource = await db.get('glyphs', candidateKey);
1686
1792
  if (resource?.data) {
1687
- idbLogger.debug(`Found glyph using key: ${candidateKey} (font: ${fontstack})`);
1688
- return new Response(resource.data, {
1689
- status: 200,
1690
- headers: { 'Content-Type': 'application/x-protobuf' },
1691
- });
1793
+ if (!isEmptyGlyphRange(resource.data)) {
1794
+ idbLogger.debug(`Found glyph using key: ${candidateKey} (font: ${fontstack})`);
1795
+ return glyphResponse(resource.data);
1796
+ }
1797
+ emptyRange ??= resource.data;
1692
1798
  }
1693
1799
  }
1694
1800
  }
1695
- idbLogger.warn(`Glyph not found for any font in: ${fontstacks.join(', ')}`);
1696
- break;
1801
+ // Never a 404: Mapbox GL fails a tile's whole glyph request if one range
1802
+ // errors, so a range outside the downloaded set would blank all its labels.
1803
+ if (!emptyRange) {
1804
+ idbLogger.warn(`Glyph not found for any font in: ${fontstacks.join(', ')}`);
1805
+ }
1806
+ return glyphResponse(emptyRange ?? new ArrayBuffer(0));
1697
1807
  }
1698
1808
  case 'sprite': {
1699
1809
  idbLogger.debug(`Looking for sprite with key: ${key}`);
@@ -1785,7 +1895,8 @@ async function idbFetchHandler(url, init) {
1785
1895
  catch (error) {
1786
1896
  idbLogger.error(`Error fetching resource from IDB: ${url}`, error);
1787
1897
  }
1788
- idbLogger.warn(`Resource not found in IDB: ${url}`);
1898
+ // Normal once the map moves outside a saved region, so not worth a warning per tile.
1899
+ idbLogger.debug(`Resource not found in IDB: ${url}`);
1789
1900
  return new Response('Not found in IDB', { status: 404 });
1790
1901
  }
1791
1902
 
@@ -1846,57 +1957,103 @@ function resolveMapboxUrl(mapboxUrl, accessToken) {
1846
1957
  if (!accessToken) {
1847
1958
  throw new Error(`Mapbox access token is required to resolve mapbox:// URL: ${mapboxUrl}`);
1848
1959
  }
1849
- // Strip the protocol prefix
1850
- const path = mapboxUrl.slice(MAPBOX_API.PROTOCOL.length);
1960
+ // Strip the protocol prefix, and split off any query so path matching and
1961
+ // the resolved URL's suffixes (`.json`, `/sprite`) go before it. Existing
1962
+ // parameters are kept; `access_token` is set exactly once.
1963
+ const withoutProtocol = mapboxUrl.slice(MAPBOX_API.PROTOCOL.length);
1964
+ const queryIndex = withoutProtocol.indexOf('?');
1965
+ const path = queryIndex === -1 ? withoutProtocol : withoutProtocol.slice(0, queryIndex);
1966
+ const params = new URLSearchParams(queryIndex === -1 ? '' : withoutProtocol.slice(queryIndex + 1));
1967
+ params.set('access_token', accessToken);
1968
+ const query = `?${params.toString()}`;
1851
1969
  // mapbox://styles/{user}/{id}
1852
1970
  if (path.startsWith('styles/')) {
1853
1971
  const rest = path.slice('styles/'.length);
1854
- return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}?access_token=${accessToken}`;
1972
+ return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}${query}`;
1855
1973
  }
1856
1974
  // mapbox://sprites/{user}/{id}
1857
1975
  if (path.startsWith('sprites/')) {
1858
1976
  const rest = path.slice('sprites/'.length);
1859
- return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}/sprite?access_token=${accessToken}`;
1977
+ return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}/sprite${query}`;
1860
1978
  }
1861
1979
  // mapbox://fonts/{user}/{fontstack}/{range}.pbf
1862
1980
  if (path.startsWith('fonts/')) {
1863
1981
  const rest = path.slice('fonts/'.length);
1864
- return `${MAPBOX_API.BASE_URL}${MAPBOX_API.FONTS_PATH}/${rest}?access_token=${accessToken}`;
1982
+ return `${MAPBOX_API.BASE_URL}${MAPBOX_API.FONTS_PATH}/${rest}${query}`;
1865
1983
  }
1866
1984
  // mapbox://models/{path}
1867
1985
  if (path.startsWith('models/')) {
1868
1986
  const rest = path.slice('models/'.length);
1869
- return `${MAPBOX_API.BASE_URL}${MAPBOX_API.MODELS_PATH}/${rest}?access_token=${accessToken}`;
1987
+ return `${MAPBOX_API.BASE_URL}${MAPBOX_API.MODELS_PATH}/${rest}${query}`;
1870
1988
  }
1871
1989
  // mapbox://{tileset} (e.g. mapbox://mapbox.mapbox-streets-v8)
1872
- return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${path}.json?access_token=${accessToken}`;
1990
+ return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${path}.json${query}`;
1873
1991
  }
1874
1992
  /**
1875
- * Rewrite Mapbox CDN tile URLs to use the api.mapbox.com/v4 endpoint.
1993
+ * Pick the TileJSON variant Mapbox GL renders from, the way its `mergeVariants`
1994
+ * does: `meshopt-v2+lod`, then `meshopt`, then the base tileset.
1995
+ *
1996
+ * These aren't just different encodings of one dataset. For
1997
+ * `mapbox.mapbox-3dbuildings-v1`, the base tile over central Dubai is empty while
1998
+ * the variants hold its 3D buildings, so downloading the base loses them offline.
1999
+ * Variant tiles are `mapbox://3dtiles/...` URLs, resolved here using the base
2000
+ * URL's query string, which carries the access token.
2001
+ */
2002
+ function selectTileJsonVariant(tileJson) {
2003
+ const { variants, tiles: baseTiles } = tileJson;
2004
+ if (!Array.isArray(variants))
2005
+ return tileJson;
2006
+ const withCapability = (capability) => variants.find(variant => Array.isArray(variant?.capabilities) &&
2007
+ variant.capabilities.length === 1 &&
2008
+ variant.capabilities[0] === capability);
2009
+ const selected = withCapability('meshopt-v2+lod') ?? withCapability('meshopt');
2010
+ if (!selected || !Array.isArray(selected.tiles))
2011
+ return tileJson;
2012
+ const firstBaseTile = Array.isArray(baseTiles) ? String(baseTiles[0] ?? '') : '';
2013
+ const queryIndex = firstBaseTile.indexOf('?');
2014
+ const query = queryIndex === -1 ? '' : firstBaseTile.slice(queryIndex);
2015
+ const tiles = selected.tiles.map(tile => tile.startsWith('mapbox://3dtiles/')
2016
+ ? `${MAPBOX_API.BASE_URL}/3dtiles/v1/${tile.slice('mapbox://3dtiles/'.length)}${query}`
2017
+ : tile);
2018
+ return { ...tileJson, ...selected, tiles };
2019
+ }
2020
+ const SKU_LIFETIME_MS = 12 * 60 * 60 * 1000;
2021
+ const SKU_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
2022
+ let skuSession;
2023
+ /** A Mapbox session token in the format Mapbox GL JS generates: version + SKU id + 10 random. */
2024
+ function getSkuToken() {
2025
+ if (!skuSession || Date.now() > skuSession.expiresAt) {
2026
+ let randomizer = '';
2027
+ for (let i = 0; i < 10; i++) {
2028
+ randomizer += SKU_CHARS[Math.floor(Math.random() * SKU_CHARS.length)];
2029
+ }
2030
+ skuSession = { token: `101${randomizer}`, expiresAt: Date.now() + SKU_LIFETIME_MS };
2031
+ }
2032
+ return skuSession.token;
2033
+ }
2034
+ /**
2035
+ * Add the `sku` session token that Mapbox `/raster/v1/` tile URLs need.
1876
2036
  *
1877
- * TileJSON responses from Mapbox return tile URLs on *.tiles.mapbox.com CDN
1878
- * which may use paths like /raster/v1/ that require an SKU session token
1879
- * generated by the Mapbox GL JS SDK at runtime. Since we fetch tiles directly
1880
- * without the SDK, these 401. The /v4/ API endpoint on api.mapbox.com works
1881
- * with just an access_token, so we rewrite CDN URLs to use it.
2037
+ * TileJSON returns these URLs without one, and without it they 401. Mapbox GL
2038
+ * adds it at request time, but this library fetches tiles itself.
2039
+ *
2040
+ * Don't switch these to the `/v4/` endpoint, which needs no token: it serves a
2041
+ * different tile. For `mapbox.mapbox-terrain-dem-v1`, `/v4/….png` is a 256px
2042
+ * palette PNG with elevations off by up to ~200 m, while the renderer expects
2043
+ * the 514px RGB tile (`tileSize: 514`) that `/raster/v1/` returns.
1882
2044
  *
1883
2045
  * Example:
1884
2046
  * https://a.tiles.mapbox.com/raster/v1/mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png?access_token=...
1885
- * -> https://api.mapbox.com/v4/mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png?access_token=...
2047
+ * -> https://a.tiles.mapbox.com/raster/v1/mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png?access_token=...&sku=101...
1886
2048
  */
1887
2049
  function rewriteMapboxCdnTileUrl(tileUrl) {
1888
- // Only rewrite *.tiles.mapbox.com/raster/v1/ URLs — these require an SKU
1889
- // session token that only the Mapbox GL JS SDK can generate. Other CDN paths
1890
- // like /v4/ (vector tiles) and /3dtiles/v1/ (3D models) work with just an
1891
- // access_token.
1892
- const match = tileUrl.match(/^https?:\/\/[a-z]\.tiles\.mapbox\.com\/raster\/v1\/([^?]+)(\?.*)?$/);
1893
- if (!match) {
2050
+ if (!/^https?:\/\/(?:[a-z]\.tiles\.mapbox\.com|api\.mapbox\.com)\/raster\/v1\//.test(tileUrl)) {
2051
+ return tileUrl;
2052
+ }
2053
+ if (/[?&]sku=/.test(tileUrl)) {
1894
2054
  return tileUrl;
1895
2055
  }
1896
- // match[1] is the path after /raster/v1/: "mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png"
1897
- const tilesetAndPath = match[1];
1898
- const queryString = match[2] || '';
1899
- return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${tilesetAndPath}${queryString}`;
2056
+ return `${tileUrl}${tileUrl.includes('?') ? '&' : '?'}sku=${getSkuToken()}`;
1900
2057
  }
1901
2058
  /**
1902
2059
  * Detect the style provider based on the style URL or content
@@ -2122,24 +2279,100 @@ function validateStyleForProvider(style, provider) {
2122
2279
 
2123
2280
  const styleLogger = logger.scope('StyleUtils');
2124
2281
  /**
2125
- * Patches a MapboxStyle for offline use by replacing URLs with IndexedDB references
2282
+ * Patches a MapboxStyle for offline use by replacing URLs with IndexedDB references.
2283
+ *
2284
+ * Safe to call repeatedly on the same stored style (every `addRegion` does):
2285
+ * the first call stashes each source's upstream `tiles` / `maxzoom` under
2286
+ * `__originalTiles` / `__originalMaxzoom`, and later calls patch from those,
2287
+ * so the download pipeline can still reach the network and `maxzoom` can grow
2288
+ * again when a deeper region is added.
2289
+ *
2126
2290
  * @param style - The style to patch
2127
- * @param downloadId - The download/region ID for tiles and other resources
2128
- * @param maxZoom - Optional max zoom level for sources
2291
+ * @param downloadId - Fallback ID for `idb://` URLs when `styleId` is omitted
2292
+ * @param maxZoom - Max zoom downloaded for this style (the highest across its regions)
2129
2293
  * @param tileExtension - Optional tile extension
2130
- * @param styleId - Optional style ID for sprites (if different from downloadId). Sprites are shared
2131
- * across regions and stored with the style ID, so this parameter allows the sprite
2132
- * URLs to correctly reference the style ID.
2294
+ * @param styleId - Style ID used in all `idb://` URLs. Tiles, glyphs, sprites and
2295
+ * models are stored per style, so URLs keyed by style keep working
2296
+ * when any one region is deleted.
2297
+ */
2298
+ /**
2299
+ * Add a region's extra tile sources to a style, unless one with that id is
2300
+ * already there. Sources must be in the style before it's patched for offline use.
2301
+ */
2302
+ function injectExtraSources(style, extraSources = []) {
2303
+ for (const extra of extraSources) {
2304
+ if (style.sources[extra.id])
2305
+ continue;
2306
+ style.sources[extra.id] = {
2307
+ type: extra.type || 'vector',
2308
+ tiles: extra.tiles,
2309
+ ...(extra.minzoom !== undefined ? { minzoom: extra.minzoom } : {}),
2310
+ ...(extra.maxzoom !== undefined ? { maxzoom: extra.maxzoom } : {}),
2311
+ ...(extra.attribution ? { attribution: extra.attribution } : {}),
2312
+ };
2313
+ }
2314
+ }
2315
+ /**
2316
+ * Keep each model's upstream URL before the style is patched, the first time
2317
+ * only. Patching points every model at `idb://`, so without this a model that
2318
+ * failed to download couldn't be fetched again.
2319
+ */
2320
+ function stashOriginalModels(entry) {
2321
+ const models = entry.style.models;
2322
+ if (!models || entry.originalModels)
2323
+ return;
2324
+ const isPatched = (value) => String(typeof value === 'string' ? value : value?.uri).startsWith('idb://');
2325
+ // A style patched before this stash existed has no upstream URLs left to keep.
2326
+ if (Object.values(models).some(isPatched))
2327
+ return;
2328
+ entry.originalModels = JSON.parse(JSON.stringify(models));
2329
+ }
2330
+ /**
2331
+ * Point models that aren't stored back at their upstream URLs. Run after
2332
+ * patching, which points every model at `idb://`.
2333
+ */
2334
+ function restoreUnstoredModels(entry, storedNames) {
2335
+ const models = entry.style.models;
2336
+ if (!models || !entry.originalModels)
2337
+ return;
2338
+ for (const name of Object.keys(models)) {
2339
+ if (!storedNames.has(name) && entry.originalModels[name] !== undefined) {
2340
+ models[name] = JSON.parse(JSON.stringify(entry.originalModels[name]));
2341
+ }
2342
+ }
2343
+ }
2344
+ /**
2345
+ * Whether a style uses parts of the spec only Mapbox GL JS v3 draws: config
2346
+ * (`schema`), 3D `models`, `lights` or `imports`. MapLibre rejects these.
2133
2347
  */
2348
+ function needsMapboxGl(style) {
2349
+ const parts = style;
2350
+ return Boolean(parts.schema || parts.models || parts.lights || parts.imports);
2351
+ }
2352
+ /** Whether a map instance is Mapbox GL JS v3, the only renderer with style config. */
2353
+ function isMapboxGlMap(map) {
2354
+ return typeof map?.setConfigProperty === 'function';
2355
+ }
2134
2356
  function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId) {
2135
2357
  styleLogger.debug(`Patching style for offline use with downloadId: ${downloadId}, maxZoom: ${maxZoom}, tileExtension: ${tileExtension}`);
2136
2358
  styleLogger.debug(`Original style:`, style);
2359
+ const urlId = styleId || downloadId;
2137
2360
  // Patch sources
2138
2361
  for (const sourceKey in style.sources) {
2139
2362
  const source = style.sources[sourceKey];
2140
2363
  styleLogger.debug(`Patching source: ${sourceKey}`, source);
2364
+ // Stash upstream values once. Already-patched tiles (all idb://) are never
2365
+ // stashed as "original"; `null` records that the source had no maxzoom.
2366
+ if (source.tiles &&
2367
+ source.__originalTiles === undefined &&
2368
+ !source.tiles.every(url => url.startsWith('idb://'))) {
2369
+ source.__originalTiles = [...source.tiles];
2370
+ }
2371
+ if (source.__originalMaxzoom === undefined) {
2372
+ source.__originalMaxzoom = source.maxzoom ?? null;
2373
+ }
2141
2374
  if (source.tiles) {
2142
- const originalTiles = [...source.tiles];
2375
+ const originalTiles = source.__originalTiles ?? [...source.tiles];
2143
2376
  // Patch to idb://{downloadId}/tile/{sourceKey}/{z}/{x}/{y}.ext.
2144
2377
  // Extension extraction goes through the shared extractTileExtensionFromUrl
2145
2378
  // helper so the patched URL's extension matches what tileService used when
@@ -2147,9 +2380,9 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
2147
2380
  // stored key under `.pbf` but a patched URL with `.vector`, forcing
2148
2381
  // idbFetchHandler to fall through its pbf/mvt/png/jpg/webp fallback loop
2149
2382
  // on every tile.
2150
- source.tiles = source.tiles.map((url) => {
2383
+ source.tiles = originalTiles.map((url) => {
2151
2384
  const ext = tileExtension ?? extractTileExtensionFromUrl(url);
2152
- return `idb://${downloadId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`;
2385
+ return `idb://${urlId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`;
2153
2386
  });
2154
2387
  styleLogger.debug(`Patched tiles for ${sourceKey} with extension .${tileExtension || 'pbf'}:`, {
2155
2388
  original: originalTiles,
@@ -2157,10 +2390,12 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
2157
2390
  });
2158
2391
  }
2159
2392
  // Cap maxzoom so the map doesn't request tiles beyond what we downloaded.
2160
- // Use the lower of the region maxZoom and the source's original maxzoom so we
2161
- // don't raise it above the tileset's actual range (e.g. 3dbuildings at z14).
2393
+ // Use the lower of the downloaded maxZoom and the source's *upstream*
2394
+ // maxzoom so we don't raise it above the tileset's actual range (e.g.
2395
+ // 3dbuildings at z14) — and so an earlier, shallower region's cap doesn't
2396
+ // stick once a deeper region is added.
2162
2397
  if (maxZoom !== undefined) {
2163
- const originalMaxzoom = source.maxzoom;
2398
+ const originalMaxzoom = source.__originalMaxzoom ?? undefined;
2164
2399
  source.maxzoom = originalMaxzoom !== undefined ? Math.min(maxZoom, originalMaxzoom) : maxZoom;
2165
2400
  styleLogger.debug(`Set maxzoom for ${sourceKey}: ${originalMaxzoom} → ${source.maxzoom}`);
2166
2401
  }
@@ -2173,7 +2408,7 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
2173
2408
  let ext = tileExtension;
2174
2409
  if (!ext)
2175
2410
  ext = 'pbf';
2176
- source.tiles = [`idb://${downloadId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`];
2411
+ source.tiles = [`idb://${urlId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`];
2177
2412
  styleLogger.debug(`Added tiles array for TileJSON-only source ${sourceKey}:`, {
2178
2413
  tiles: source.tiles,
2179
2414
  });
@@ -2189,7 +2424,7 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
2189
2424
  // Patch glyphs
2190
2425
  if (style.glyphs) {
2191
2426
  const originalGlyphs = style.glyphs;
2192
- style.glyphs = `idb://${downloadId}/glyph/{fontstack}/{range}.pbf`;
2427
+ style.glyphs = `idb://${urlId}/glyph/{fontstack}/{range}.pbf`;
2193
2428
  styleLogger.debug(`Patched glyphs:`, { original: originalGlyphs, patched: style.glyphs });
2194
2429
  }
2195
2430
  // Patch sprite
@@ -2248,13 +2483,31 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
2248
2483
  styleLogger.debug(`Final patched style:`, style);
2249
2484
  return style;
2250
2485
  }
2486
+ /** Resolve one entry of a font stack: a name, `["config", key]` or a `["concat", ...]` of those. */
2487
+ function resolveFontName(value, schema) {
2488
+ if (typeof value === 'string')
2489
+ return value;
2490
+ if (!Array.isArray(value))
2491
+ return undefined;
2492
+ if (value[0] === 'config' && typeof value[1] === 'string') {
2493
+ const configured = schema?.[value[1]]?.default;
2494
+ return typeof configured === 'string' ? configured : undefined;
2495
+ }
2496
+ if (value[0] === 'concat') {
2497
+ const parts = value.slice(1).map(part => resolveFontName(part, schema));
2498
+ return parts.every(part => part !== undefined) ? parts.join('') : undefined;
2499
+ }
2500
+ return undefined;
2501
+ }
2251
2502
  /**
2252
2503
  * Extract individual font names from a `text-font` layout property value.
2253
2504
  *
2254
- * Handles both simple arrays (`["Font A", "Font B"]`) and Mapbox GL expression
2255
- * syntax (e.g. `["step", ["zoom"], ["literal", ["Font A"]], 10, ["literal", ["Font B"]]]`).
2505
+ * Handles simple arrays (`["Font A", "Font B"]`), Mapbox GL expression syntax
2506
+ * (e.g. `["step", ["zoom"], ["literal", ["Font A"]], 10, ["literal", ["Font B"]]]`),
2507
+ * and Mapbox Standard's config-built stacks
2508
+ * (`[["concat", ["config", "font"], " Medium"], "Arial Unicode MS Bold"]`).
2256
2509
  */
2257
- function extractFontNamesFromTextField(textFont) {
2510
+ function extractFontNamesFromTextField(textFont, schema) {
2258
2511
  const fonts = new Set();
2259
2512
  // Known expression operators — anything starting with one of these is NOT a font name list
2260
2513
  const expressionOps = new Set([
@@ -2294,20 +2547,40 @@ function extractFontNamesFromTextField(textFont) {
2294
2547
  }
2295
2548
  return;
2296
2549
  }
2297
- // Simple font stack: every element is a string and the first element is
2298
- // NOT a known expression operator
2299
- if (value.every((v) => typeof v === 'string') && !expressionOps.has(value[0])) {
2300
- for (const f of value) {
2301
- fonts.add(f);
2550
+ // A font stack: only font names, and the first isn't an expression operator
2551
+ const isFontEntry = (item) => typeof item === 'string' ||
2552
+ (Array.isArray(item) && (item[0] === 'config' || item[0] === 'concat'));
2553
+ if (value.every(isFontEntry) && !expressionOps.has(value[0])) {
2554
+ for (const item of value) {
2555
+ const name = resolveFontName(item, schema);
2556
+ if (name !== undefined)
2557
+ fonts.add(name);
2302
2558
  }
2303
2559
  return;
2304
2560
  }
2305
- // Recurse into sub-arrays (covers step/match/case branches)
2306
- for (const item of value) {
2307
- if (Array.isArray(item)) {
2308
- walk(item);
2561
+ // Only the branches that output a font. Conditions and inputs hold strings
2562
+ // like `["zoom"]` or match labels that aren't fonts.
2563
+ const last = value.length - 1;
2564
+ const isOutput = (i) => {
2565
+ switch (value[0]) {
2566
+ case 'step': // ["step", input, out0, stop, out, ...]
2567
+ return i >= 2 && i % 2 === 0;
2568
+ case 'match': // ["match", input, label, out, label, out, ..., fallback]
2569
+ return i === last || (i >= 3 && i % 2 === 1);
2570
+ case 'case': // ["case", cond, out, cond, out, ..., fallback]
2571
+ return i === last || (i >= 2 && i % 2 === 0);
2572
+ case 'coalesce':
2573
+ return i >= 1;
2574
+ case 'let': // ["let", name, value, ..., body]
2575
+ return i === last;
2576
+ default:
2577
+ return false;
2309
2578
  }
2310
- }
2579
+ };
2580
+ value.forEach((item, i) => {
2581
+ if (isOutput(i))
2582
+ walk(item);
2583
+ });
2311
2584
  }
2312
2585
  walk(textFont);
2313
2586
  return Array.from(fonts);
@@ -2335,7 +2608,7 @@ function extractAllFontNames(style) {
2335
2608
  if (style && Array.isArray(style.layers)) {
2336
2609
  for (const layer of style.layers) {
2337
2610
  if (layer.layout && layer.layout['text-font']) {
2338
- for (const name of extractFontNamesFromTextField(layer.layout['text-font'])) {
2611
+ for (const name of extractFontNamesFromTextField(layer.layout['text-font'], style.schema)) {
2339
2612
  fonts.add(name);
2340
2613
  }
2341
2614
  }
@@ -2404,8 +2677,13 @@ var styleUtils = /*#__PURE__*/Object.freeze({
2404
2677
  extractAllFontNames: extractAllFontNames,
2405
2678
  extractFontNamesFromTextField: extractFontNamesFromTextField,
2406
2679
  generateGlyphUrlsFromStyle: generateGlyphUrlsFromStyle,
2680
+ injectExtraSources: injectExtraSources,
2681
+ isMapboxGlMap: isMapboxGlMap,
2682
+ needsMapboxGl: needsMapboxGl,
2407
2683
  normalizeSpriteProperty: normalizeSpriteProperty,
2408
- patchStyleForOffline: patchStyleForOffline
2684
+ patchStyleForOffline: patchStyleForOffline,
2685
+ restoreUnstoredModels: restoreUnstoredModels,
2686
+ stashOriginalModels: stashOriginalModels
2409
2687
  });
2410
2688
 
2411
2689
  const cleanupLogger$1 = logger.scope('CleanupCompressedTiles');
@@ -2962,7 +3240,7 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
2962
3240
  // Collect all flattened sources, layers from imports
2963
3241
  const flattenedSources = {};
2964
3242
  const flattenedLayers = [];
2965
- let importedSprite;
3243
+ const importSprites = [];
2966
3244
  let importedGlyphs;
2967
3245
  let importedModels;
2968
3246
  for (const importEntry of imports) {
@@ -3014,6 +3292,7 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
3014
3292
  // Build config lookup: schema defaults + import config overrides
3015
3293
  const configValues = buildConfigLookup(importedStyle, importEntry.config);
3016
3294
  // Flatten layers: prefix layer IDs, update source references, resolve config expressions
3295
+ const importLayers = [];
3017
3296
  if (importedStyle.layers && Array.isArray(importedStyle.layers)) {
3018
3297
  for (const layer of importedStyle.layers) {
3019
3298
  const layerObj = layer;
@@ -3029,11 +3308,13 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
3029
3308
  // Resolve ["config", "key"] expressions using schema defaults and import overrides.
3030
3309
  resolveConfigExpressions(prefixedLayer, configValues);
3031
3310
  flattenedLayers.push(prefixedLayer);
3311
+ importLayers.push(prefixedLayer);
3032
3312
  }
3033
3313
  }
3034
- // Sprite: collect imported sprite
3035
- if (importedStyle.sprite && !importedSprite) {
3036
- importedSprite = importedStyle.sprite;
3314
+ // Sprite: collect every import's sprite with its layers, so each can be
3315
+ // scoped if the flattened style ends up with more than one sprite.
3316
+ if (importedStyle.sprite) {
3317
+ importSprites.push({ importId, sprite: importedStyle.sprite, layers: importLayers });
3037
3318
  }
3038
3319
  // Glyphs: collect imported glyphs
3039
3320
  if (importedStyle.glyphs && !importedGlyphs) {
@@ -3050,12 +3331,10 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
3050
3331
  // Layers: imported layers go first (bottom), outer layers on top
3051
3332
  const outerLayers = style.layers || [];
3052
3333
  style.layers = [...flattenedLayers, ...outerLayers];
3053
- // Sprite: merge if both exist
3054
- if (style.sprite && importedSprite) {
3055
- style.sprite = mergeSprites(style.sprite, importedSprite);
3056
- }
3057
- else if (!style.sprite && importedSprite) {
3058
- style.sprite = importedSprite;
3334
+ // Sprite: a single sprite stays as-is; several become an array sprite with
3335
+ // each import's image references scoped to its sprite id.
3336
+ if (importSprites.length > 0) {
3337
+ style.sprite = combineSprites(style.sprite, importSprites);
3059
3338
  }
3060
3339
  // Glyphs: outer wins; if absent, use imported
3061
3340
  if (!style.glyphs && importedGlyphs) {
@@ -3242,23 +3521,37 @@ function isExpression(value) {
3242
3521
  return Array.isArray(value) && value.length > 0 && typeof value[0] === 'string';
3243
3522
  }
3244
3523
  /**
3245
- * Merge two sprite values into array format.
3246
- * Handles string + string, string + array, array + string, array + array.
3247
- */
3248
- function mergeSprites(outer, imported) {
3249
- const result = [];
3250
- // Add imported sprites first (bottom)
3251
- if (typeof imported === 'string') {
3252
- result.push({ id: 'imported', url: imported });
3524
+ * Combine the outer style's sprite with its imports' sprites.
3525
+ *
3526
+ * With one sprite in total it is returned unchanged (a plain string keeps
3527
+ * working in Mapbox GL, which only accepts a string `sprite`). With several,
3528
+ * the result is a MapLibre array sprite: imports first, the outer sprite last
3529
+ * as `default`. Array-sprite images from any entry other than `default` are
3530
+ * only reachable as `{id}:{name}`, so each string-sprite import gets its
3531
+ * import id as sprite id and its layers' image references are prefixed with
3532
+ * `{importId}:`. Imports that already use an array sprite keep their entries
3533
+ * and references unchanged.
3534
+ */
3535
+ function combineSprites(outer, imports) {
3536
+ if (!outer && imports.length === 1) {
3537
+ return imports[0].sprite;
3253
3538
  }
3254
- else if (Array.isArray(imported)) {
3255
- for (const entry of imported) {
3256
- if (entry && typeof entry.url === 'string') {
3257
- result.push(entry);
3539
+ const result = [];
3540
+ for (const { importId, sprite, layers } of imports) {
3541
+ if (typeof sprite === 'string') {
3542
+ result.push({ id: importId, url: sprite });
3543
+ for (const layer of layers) {
3544
+ prefixLayerImages(layer, `${importId}:`);
3545
+ }
3546
+ }
3547
+ else if (Array.isArray(sprite)) {
3548
+ for (const entry of sprite) {
3549
+ if (entry && typeof entry.url === 'string') {
3550
+ result.push(entry);
3551
+ }
3258
3552
  }
3259
3553
  }
3260
3554
  }
3261
- // Add outer sprites on top
3262
3555
  if (typeof outer === 'string') {
3263
3556
  result.push({ id: 'default', url: outer });
3264
3557
  }
@@ -3271,8 +3564,74 @@ function mergeSprites(outer, imported) {
3271
3564
  }
3272
3565
  return result;
3273
3566
  }
3567
+ /** Layer properties whose values name sprite images. */
3568
+ const IMAGE_PROPERTIES = [
3569
+ ['layout', 'icon-image'],
3570
+ ['paint', 'fill-pattern'],
3571
+ ['paint', 'line-pattern'],
3572
+ ['paint', 'fill-extrusion-pattern'],
3573
+ ['paint', 'background-pattern'],
3574
+ ];
3575
+ function prefixLayerImages(layer, prefix) {
3576
+ for (const [group, property] of IMAGE_PROPERTIES) {
3577
+ const props = layer[group];
3578
+ if (props && property in props) {
3579
+ props[property] = prefixImageValue(props[property], prefix);
3580
+ }
3581
+ }
3582
+ }
3583
+ /**
3584
+ * Prefix the image name(s) a property value resolves to. Handles plain names
3585
+ * (including `{token}` templates), legacy `stops` functions, and expressions:
3586
+ * `image` / `literal` directly, the output branches of `coalesce` / `case` /
3587
+ * `match` / `step`, and anything else string-valued via `concat`.
3588
+ */
3589
+ function prefixImageValue(value, prefix) {
3590
+ if (typeof value === 'string') {
3591
+ return value === '' ? value : `${prefix}${value}`;
3592
+ }
3593
+ if (Array.isArray(value)) {
3594
+ if (value.length === 0 || typeof value[0] !== 'string')
3595
+ return value;
3596
+ const [op, ...args] = value;
3597
+ const mapAt = (isOutput) => value.map((arg, i) => (i > 0 && isOutput(i) ? prefixImageValue(arg, prefix) : arg));
3598
+ const lastIndex = value.length - 1;
3599
+ switch (op) {
3600
+ case 'image':
3601
+ return ['image', prefixImageValue(args[0], prefix), ...args.slice(1)];
3602
+ case 'literal':
3603
+ return typeof args[0] === 'string' ? ['literal', `${prefix}${args[0]}`] : value;
3604
+ case 'coalesce':
3605
+ return mapAt(() => true);
3606
+ case 'case':
3607
+ // ["case", cond, out, cond, out, ..., fallback]
3608
+ return mapAt(i => i % 2 === 0 || i === lastIndex);
3609
+ case 'match':
3610
+ // ["match", input, label, out, label, out, ..., fallback]
3611
+ return mapAt(i => (i >= 3 && i % 2 === 1) || i === lastIndex);
3612
+ case 'step':
3613
+ // ["step", input, out0, stop, out, ...]
3614
+ return mapAt(i => i >= 2 && i % 2 === 0);
3615
+ default:
3616
+ return ['concat', prefix, value];
3617
+ }
3618
+ }
3619
+ if (value && typeof value === 'object') {
3620
+ const fn = value;
3621
+ if (Array.isArray(fn.stops)) {
3622
+ return {
3623
+ ...fn,
3624
+ stops: fn.stops.map(stop => Array.isArray(stop) ? [stop[0], prefixImageValue(stop[1], prefix)] : stop),
3625
+ ...('default' in fn
3626
+ ? { default: prefixImageValue(fn.default, prefix) }
3627
+ : {}),
3628
+ };
3629
+ }
3630
+ }
3631
+ return value;
3632
+ }
3274
3633
 
3275
- const DEFAULT_WASM_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.14.1/dist/';
3634
+ const DEFAULT_WASM_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.15.2/dist/';
3276
3635
  let currentConfig = {};
3277
3636
  let sqlJsPromise = null;
3278
3637
  /**
@@ -3333,100 +3692,587 @@ async function getSqlJs() {
3333
3692
  return sqlJsPromise;
3334
3693
  }
3335
3694
 
3336
- const fontLogger = logger.scope('FontService');
3337
- class FontService {
3695
+ const modelLogger = logger.scope('ModelService');
3696
+ /**
3697
+ * Build the storage key for a model. Kept consistent with sprite/glyph
3698
+ * conventions: `{styleId}::model::{modelName}`.
3699
+ */
3700
+ function modelKey(styleId, modelName) {
3701
+ return `${styleId}::model::${modelName}`;
3702
+ }
3703
+ /** Names of the models stored for a style. */
3704
+ async function storedModelNames(styleId) {
3705
+ const db = await dbPromise;
3706
+ const prefix = modelKey(styleId, '');
3707
+ const keys = await db.getAllKeys('models', IDBKeyRange.bound(prefix, `${prefix}￿`));
3708
+ return new Set(keys.map(key => String(key).slice(prefix.length)));
3709
+ }
3710
+ /** True when the given key belongs to the given styleId's model store prefix. */
3711
+ function modelKeyBelongsToStyle(key, styleId) {
3712
+ return key.startsWith(`${styleId}::model::`);
3713
+ }
3714
+ /**
3715
+ * Service for downloading, storing, and serving Mapbox 3D model (.glb) files.
3716
+ *
3717
+ * Mapbox Standard declares 32 models at the top of `style.models`:
3718
+ *
3719
+ * ```json
3720
+ * {
3721
+ * "maple1-lod1": "mapbox://models/mapbox/maple1-v4-lod1.glb",
3722
+ * ...
3723
+ * }
3724
+ * ```
3725
+ *
3726
+ * `model` layers (e.g. `trees`, `wind-turbine-towers`) pick one by name at
3727
+ * render time. For offline use each referenced URL is fetched and stored
3728
+ * here, and `patchStyleForOffline` rewrites the dictionary entries to
3729
+ * `idb://{styleId}/model/{name}` URLs.
3730
+ */
3731
+ class ModelService {
3338
3732
  db = dbPromise;
3339
- async downloadFonts(fontUrls, styleName, options = {}) {
3733
+ /**
3734
+ * Download the set of models referenced by `style.models` for one style.
3735
+ *
3736
+ * @param models `{ modelName: resolvedHttpUrl }` — URLs must already be
3737
+ * resolved (mapbox:// URLs should be resolved by the caller).
3738
+ * @param styleId The owning style's key.
3739
+ */
3740
+ async downloadModels(models, styleId, options = {}) {
3340
3741
  const db = await this.db;
3341
- const { batchSize = 10, maxRetries = 3, skipExisting = true, retryDelay = 1000, timeout = 30000, validateFonts = true, storageQuotaCheck = true, quietMode = false, } = options;
3342
- const startTime = Date.now();
3343
- let totalSize = 0;
3344
- let downloadedFonts = 0;
3345
- let skippedFonts = 0;
3346
- let failedFonts = 0;
3347
- const errors = [];
3348
- const fontsByType = {};
3349
- // Create progress tracker
3350
- const progressTracker = createProgressTracker(fontUrls.length);
3351
- // Filter existing fonts if skipExisting is true
3352
- let urlsToDownload = fontUrls;
3742
+ const { onProgress, batchSize = 4, maxRetries = 3, skipExisting = true, timeoutMs = 30000, } = options;
3743
+ const entries = Object.entries(models);
3744
+ const progressTracker = createProgressTracker(entries.length);
3745
+ const result = {
3746
+ totalModels: entries.length,
3747
+ downloadedModels: 0,
3748
+ skippedModels: 0,
3749
+ failedModels: 0,
3750
+ totalSize: 0,
3751
+ errors: [],
3752
+ };
3753
+ const emit = () => onProgress?.(progressTracker.getProgress());
3754
+ emit();
3755
+ if (entries.length === 0)
3756
+ return result;
3757
+ // Pre-compute existing keys for skipExisting
3758
+ const existingKeys = new Set();
3353
3759
  if (skipExisting) {
3354
- const existingFonts = new Set();
3355
- const tx = db.transaction(['fonts'], 'readonly');
3356
- let cursor = await tx.objectStore('fonts').openCursor();
3357
- while (cursor) {
3358
- existingFonts.add(cursor.value.key);
3359
- cursor = await cursor.continue();
3760
+ const tx = db.transaction('models', 'readonly');
3761
+ for await (const cursor of tx.store) {
3762
+ existingKeys.add(cursor.value.key);
3360
3763
  }
3361
- urlsToDownload = fontUrls.filter(url => {
3362
- const key = this.createFontKey(url, styleName);
3363
- return !existingFonts.has(key);
3364
- });
3365
- skippedFonts = fontUrls.length - urlsToDownload.length;
3366
3764
  }
3367
- // Check storage quota if enabled
3368
- if (storageQuotaCheck && 'storage' in navigator && 'estimate' in navigator.storage) {
3369
- const estimate = await navigator.storage.estimate();
3370
- const usedSpace = estimate.usage || 0;
3371
- const availableSpace = (estimate.quota || 0) - usedSpace;
3372
- if (availableSpace < 50 * 1024 * 1024) {
3373
- // Less than 50MB
3374
- throw new Error('Insufficient storage space for font download');
3765
+ await processBatch(entries, async ([modelName, url]) => {
3766
+ const key = modelKey(styleId, modelName);
3767
+ const label = `${styleId}::${modelName}`;
3768
+ if (skipExisting && existingKeys.has(key)) {
3769
+ result.skippedModels++;
3770
+ progressTracker.update(1, label);
3771
+ emit();
3772
+ return;
3375
3773
  }
3376
- }
3377
- // Process fonts in batches with concurrency control
3378
- await processBatch(urlsToDownload, async (fontUrl) => {
3379
3774
  try {
3380
- const fontKey = this.createFontKey(fontUrl, styleName);
3381
- const response = await fetchResourceWithRetry(fontUrl, {
3775
+ const response = await fetchResourceWithRetry(url, {
3382
3776
  retries: maxRetries,
3383
- retryDelay,
3384
- timeout,
3385
- proxyType: 'fonts',
3777
+ timeout: timeoutMs,
3778
+ proxyType: 'tiles',
3386
3779
  });
3387
3780
  if (response.type === 'json') {
3388
- throw new Error(`Unexpected JSON response when fetching font: ${fontUrl}`);
3389
- }
3390
- const fontData = response.data;
3391
- const contentType = response.contentType ||
3392
- (response.type === 'other' ? 'font/opentype' : `font/${response.type}`);
3393
- // Validate font if enabled
3394
- if (validateFonts) {
3395
- await this.validateFont(fontData, contentType);
3781
+ throw new Error('Unexpected JSON response for model');
3396
3782
  }
3397
- // Store font in database
3398
- const fontEntry = {
3399
- key: fontKey,
3400
- url: fontUrl,
3401
- originalUrl: fontUrl,
3402
- data: fontData,
3783
+ const data = response.data;
3784
+ const contentType = ('contentType' in response && response.contentType) || 'model/gltf-binary';
3785
+ const entry = {
3786
+ key,
3787
+ data,
3403
3788
  contentType,
3404
- type: this.detectFontType(contentType, fontUrl),
3405
- size: fontData.byteLength,
3789
+ size: data.byteLength,
3790
+ url,
3791
+ styleId,
3792
+ modelName,
3406
3793
  lastModified: Date.now(),
3407
3794
  downloadedAt: new Date().toISOString(),
3408
- downloadId: styleName,
3409
- metadata: {
3410
- userAgent: navigator.userAgent,
3411
- downloadTimestamp: Date.now(),
3412
- },
3413
3795
  expires: response.expires,
3414
3796
  };
3415
- await db.put('fonts', fontEntry);
3416
- progressTracker.update(1, fontKey);
3417
- totalSize += fontData.byteLength;
3418
- downloadedFonts++;
3419
- // Track font types
3420
- const fontType = this.detectFontType(contentType, fontUrl);
3421
- fontsByType[fontType] = (fontsByType[fontType] || 0) + 1;
3797
+ await db.put('models', entry);
3798
+ result.downloadedModels++;
3799
+ result.totalSize += data.byteLength;
3800
+ progressTracker.update(1, label);
3422
3801
  }
3423
- catch (error) {
3424
- failedFonts++;
3425
- const errorMessage = error instanceof Error ? error.message : String(error);
3426
- errors.push({
3427
- url: fontUrl,
3428
- error: errorMessage,
3802
+ catch (err) {
3803
+ const message = err instanceof Error ? err.message : String(err);
3804
+ result.failedModels++;
3805
+ result.errors.push({ url, error: message });
3806
+ modelLogger.warn(`Failed to download model "${modelName}" from ${url}:`, err);
3807
+ progressTracker.update(1, label, message);
3808
+ }
3809
+ emit();
3810
+ }, { batchSize });
3811
+ modelLogger.info(`Models downloaded for style ${styleId}: ${result.downloadedModels} new, ${result.skippedModels} skipped, ${result.failedModels} failed`);
3812
+ return result;
3813
+ }
3814
+ /** Retrieve a single model by `{styleId, modelName}`. */
3815
+ async getModel(styleId, modelName) {
3816
+ const db = await this.db;
3817
+ return db.get('models', modelKey(styleId, modelName));
3818
+ }
3819
+ /** Aggregate stats across all stored models. */
3820
+ async getModelStats() {
3821
+ const db = await this.db;
3822
+ const stats = {
3823
+ count: 0,
3824
+ totalSize: 0,
3825
+ averageSize: 0,
3826
+ models: [],
3827
+ modelsByStyle: {},
3828
+ };
3829
+ const tx = db.transaction('models', 'readonly');
3830
+ for await (const cursor of tx.store) {
3831
+ const m = cursor.value;
3832
+ stats.count++;
3833
+ stats.totalSize += m.size;
3834
+ stats.models.push({ name: m.modelName, size: m.size, lastModified: m.lastModified });
3835
+ stats.modelsByStyle[m.styleId] = (stats.modelsByStyle[m.styleId] ?? 0) + 1;
3836
+ }
3837
+ stats.averageSize = stats.count > 0 ? stats.totalSize / stats.count : 0;
3838
+ return stats;
3839
+ }
3840
+ /**
3841
+ * Delete models older than `maxAge` days. Defaults to 30.
3842
+ */
3843
+ async cleanupOldModels(maxAge = 30) {
3844
+ const db = await this.db;
3845
+ const cutoff = Date.now() - maxAge * 24 * 60 * 60 * 1000;
3846
+ let deleted = 0;
3847
+ const tx = db.transaction('models', 'readwrite');
3848
+ for await (const cursor of tx.store) {
3849
+ if (cursor.value.lastModified < cutoff) {
3850
+ await cursor.delete();
3851
+ deleted++;
3852
+ }
3853
+ }
3854
+ return deleted;
3855
+ }
3856
+ /**
3857
+ * Basic integrity check: remove entries with empty/missing data.
3858
+ */
3859
+ async verifyAndRepairModels() {
3860
+ const db = await this.db;
3861
+ let verified = 0;
3862
+ let removed = 0;
3863
+ const tx = db.transaction('models', 'readwrite');
3864
+ for await (const cursor of tx.store) {
3865
+ const m = cursor.value;
3866
+ if (!m.data || m.data.byteLength === 0) {
3867
+ await cursor.delete();
3868
+ removed++;
3869
+ }
3870
+ else {
3871
+ verified++;
3872
+ }
3873
+ }
3874
+ return { verified, repaired: 0, removed };
3875
+ }
3876
+ }
3877
+ // Singleton + convenience exports, matching other service modules.
3878
+ const modelService = new ModelService();
3879
+ const downloadModels = (models, styleId, options) => modelService.downloadModels(models, styleId, options);
3880
+ const getModel = (styleId, modelName) => modelService.getModel(styleId, modelName);
3881
+ const getModelStats = () => modelService.getModelStats();
3882
+ const cleanupOldModels = (maxAge) => modelService.cleanupOldModels(maxAge);
3883
+ const verifyAndRepairModels = () => modelService.verifyAndRepairModels();
3884
+
3885
+ var modelService$1 = /*#__PURE__*/Object.freeze({
3886
+ __proto__: null,
3887
+ ModelService: ModelService,
3888
+ cleanupOldModels: cleanupOldModels,
3889
+ downloadModels: downloadModels,
3890
+ getModel: getModel,
3891
+ getModelStats: getModelStats,
3892
+ modelKeyBelongsToStyle: modelKeyBelongsToStyle,
3893
+ modelService: modelService,
3894
+ storedModelNames: storedModelNames,
3895
+ verifyAndRepairModels: verifyAndRepairModels
3896
+ });
3897
+
3898
+ /**
3899
+ * Mapbox GL v3 style-config helpers.
3900
+ *
3901
+ * Mapbox Standard is one style with switchable features: most of its layers set
3902
+ * `layout.visibility` from `["config", key]` expressions backed by `style.schema`
3903
+ * defaults. These helpers work out which sources the style actually needs for
3904
+ * the current config, so features that are switched off aren't downloaded and
3905
+ * features that are switched on aren't skipped.
3906
+ */
3907
+ const configLogger = logger.scope('StyleConfig');
3908
+ /** Sentinel for an expression that can't be decided from config alone. */
3909
+ const UNKNOWN = Symbol('unknown');
3910
+ /**
3911
+ * True for styles whose feature set is config-driven: they declare a `schema`,
3912
+ * or were flattened from `imports` (whose config is already resolved into the
3913
+ * layers). Other styles are never filtered by config.
3914
+ */
3915
+ function isConfigDrivenStyle(style) {
3916
+ const s = style;
3917
+ if (!s)
3918
+ return false;
3919
+ return ((s.schema !== undefined && Object.keys(s.schema).length > 0) ||
3920
+ (Array.isArray(s.imports) && s.imports.length > 0));
3921
+ }
3922
+ function evaluate(expr, config) {
3923
+ if (!Array.isArray(expr))
3924
+ return expr;
3925
+ const [op, ...args] = expr;
3926
+ switch (op) {
3927
+ case 'literal':
3928
+ return args[0];
3929
+ case 'config': {
3930
+ const key = args[0];
3931
+ return typeof key === 'string' && key in config ? config[key] : UNKNOWN;
3932
+ }
3933
+ case '!': {
3934
+ const value = evaluate(args[0], config);
3935
+ return typeof value === 'boolean' ? !value : UNKNOWN;
3936
+ }
3937
+ case 'all':
3938
+ case 'any': {
3939
+ const decisive = op === 'any';
3940
+ let sawUnknown = false;
3941
+ for (const arg of args) {
3942
+ const value = evaluate(arg, config);
3943
+ if (value === decisive)
3944
+ return decisive;
3945
+ if (typeof value !== 'boolean')
3946
+ sawUnknown = true;
3947
+ }
3948
+ return sawUnknown ? UNKNOWN : !decisive;
3949
+ }
3950
+ case '==':
3951
+ case '!=': {
3952
+ const a = evaluate(args[0], config);
3953
+ const b = evaluate(args[1], config);
3954
+ if (a === UNKNOWN || b === UNKNOWN)
3955
+ return UNKNOWN;
3956
+ return op === '==' ? a === b : a !== b;
3957
+ }
3958
+ case 'case': {
3959
+ // ["case", cond, out, cond, out, ..., fallback]
3960
+ for (let i = 0; i < args.length - 1; i += 2) {
3961
+ const condition = evaluate(args[i], config);
3962
+ if (typeof condition !== 'boolean')
3963
+ return UNKNOWN;
3964
+ if (condition)
3965
+ return evaluate(args[i + 1], config);
3966
+ }
3967
+ return evaluate(args[args.length - 1], config);
3968
+ }
3969
+ case 'match': {
3970
+ // ["match", input, label, out, label, out, ..., fallback]
3971
+ const input = evaluate(args[0], config);
3972
+ if (input === UNKNOWN)
3973
+ return UNKNOWN;
3974
+ for (let i = 1; i < args.length - 1; i += 2) {
3975
+ const label = args[i];
3976
+ if (Array.isArray(label) ? label.includes(input) : label === input) {
3977
+ return evaluate(args[i + 1], config);
3978
+ }
3979
+ }
3980
+ return evaluate(args[args.length - 1], config);
3981
+ }
3982
+ default:
3983
+ return UNKNOWN;
3984
+ }
3985
+ }
3986
+ function collectIconsetIds(value, ids) {
3987
+ if (Array.isArray(value)) {
3988
+ for (const item of value)
3989
+ collectIconsetIds(item, ids);
3990
+ }
3991
+ else if (value && typeof value === 'object') {
3992
+ const record = value;
3993
+ const iconset = record.iconset;
3994
+ if (iconset && typeof iconset.id === 'string')
3995
+ ids.add(iconset.id);
3996
+ for (const item of Object.values(record))
3997
+ collectIconsetIds(item, ids);
3998
+ }
3999
+ }
4000
+ /**
4001
+ * Sources a config-driven style uses under its current config (`active`), and
4002
+ * those it references but only from hidden layers or unused iconsets (`inactive`).
4003
+ *
4004
+ * Deliberately errs towards keeping sources: a visibility expression that can't
4005
+ * be decided counts as visible. Sources nothing in the style references
4006
+ * (`extraSources`, ones a caller adds at runtime) are in neither set.
4007
+ */
4008
+ function classifySources(style) {
4009
+ const inactive = new Set();
4010
+ const active = new Set();
4011
+ if (!isConfigDrivenStyle(style))
4012
+ return { active, inactive };
4013
+ const s = style;
4014
+ const config = {};
4015
+ for (const [key, entry] of Object.entries(s.schema ?? {})) {
4016
+ if (entry && typeof entry === 'object' && 'default' in entry)
4017
+ config[key] = entry.default;
4018
+ }
4019
+ const activeIconsets = new Set();
4020
+ for (const layer of (s.layers ?? [])) {
4021
+ const visible = evaluate(layer?.layout?.visibility ?? 'visible', config) !== 'none';
4022
+ if (typeof layer?.source === 'string') {
4023
+ inactive.add(layer.source);
4024
+ if (visible)
4025
+ active.add(layer.source);
4026
+ }
4027
+ if (visible) {
4028
+ // The whole layer, since `appearances` can also name an iconset.
4029
+ collectIconsetIds(layer, activeIconsets);
4030
+ }
4031
+ }
4032
+ for (const [id, iconset] of Object.entries(s.iconsets ?? {})) {
4033
+ if (typeof iconset?.source !== 'string')
4034
+ continue;
4035
+ inactive.add(iconset.source);
4036
+ if (activeIconsets.has(id))
4037
+ active.add(iconset.source);
4038
+ }
4039
+ if (typeof s.terrain?.source === 'string')
4040
+ active.add(s.terrain.source);
4041
+ for (const id of active)
4042
+ inactive.delete(id);
4043
+ return { active, inactive };
4044
+ }
4045
+ /**
4046
+ * Write config values into a style's `schema` defaults. `["config", key]` reads
4047
+ * those defaults at render time, so this one edit drives both what gets
4048
+ * downloaded and what the stored style shows offline. Keys the schema doesn't
4049
+ * declare are ignored with a warning.
4050
+ */
4051
+ function applyStyleConfig(style, config) {
4052
+ const schema = style.schema;
4053
+ for (const [key, value] of Object.entries(config)) {
4054
+ const entry = schema?.[key];
4055
+ if (entry && typeof entry === 'object') {
4056
+ entry.default = value;
4057
+ }
4058
+ else {
4059
+ configLogger.warn(`Ignoring styleConfig "${key}": the style's schema doesn't declare it`);
4060
+ }
4061
+ }
4062
+ }
4063
+
4064
+ /**
4065
+ * XYZ tile range covering `bounds` at zoom `z`. Shared by the download
4066
+ * pipeline (which fetches exactly this range) and region-scoped consumers
4067
+ * like MBTiles export, so both agree on which tiles belong to a region.
4068
+ */
4069
+ function getTileRangeAtZoom(bounds, z) {
4070
+ const minTile = pointToTileClamped(bounds[0][0], bounds[0][1], z);
4071
+ const maxTile = pointToTileClamped(bounds[1][0], bounds[1][1], z);
4072
+ return {
4073
+ minX: Math.min(minTile[0], maxTile[0]),
4074
+ maxX: Math.max(minTile[0], maxTile[0]),
4075
+ minY: Math.min(minTile[1], maxTile[1]),
4076
+ maxY: Math.max(minTile[1], maxTile[1]),
4077
+ };
4078
+ }
4079
+ /** Web Mercator's latitude limit; tiles don't exist beyond it. */
4080
+ const MAX_MERCATOR_LAT = 85.0511287798066;
4081
+ /**
4082
+ * `tilebelt.pointToTile`, kept on the tile grid. tilebelt wraps longitude 180
4083
+ * to x = 0, so a region whose east edge is the antimeridian became the range
4084
+ * 0..maxX — nearly the whole world at every zoom. Latitudes beyond the
4085
+ * Mercator limit likewise fall off the grid.
4086
+ */
4087
+ function pointToTileClamped(lon, lat, z) {
4088
+ const maxIndex = 2 ** z - 1;
4089
+ const clampedLon = Math.max(-180, Math.min(180, lon));
4090
+ const clampedLat = Math.max(-MAX_MERCATOR_LAT, Math.min(MAX_MERCATOR_LAT, lat));
4091
+ const [x, y] = tilebelt.pointToTile(clampedLon, clampedLat, z);
4092
+ return [
4093
+ clampedLon === 180 ? maxIndex : Math.max(0, Math.min(maxIndex, x)),
4094
+ Math.max(0, Math.min(maxIndex, y)),
4095
+ ];
4096
+ }
4097
+ /**
4098
+ * Whether tile `z/x/y` falls inside `bounds` and `[minZoom, maxZoom]`.
4099
+ */
4100
+ function isTileInRegion(z, x, y, region) {
4101
+ if (z < region.minZoom || z > region.maxZoom)
4102
+ return false;
4103
+ const { minX, maxX, minY, maxY } = getTileRangeAtZoom(region.bounds, z);
4104
+ return x >= minX && x <= maxX && y >= minY && y <= maxY;
4105
+ }
4106
+ /**
4107
+ * Zoom levels to download for one source in `region`: the overlap of the
4108
+ * region's and the source's zoom ranges. When they don't overlap, the source
4109
+ * still gets the zooms the map needs from it:
4110
+ * - source ends below the region (`maxzoom` < region min) → its `maxzoom`,
4111
+ * which the renderer overzooms for the region's zooms;
4112
+ * - source starts above the region (`minzoom` > region max) → `minzoom`
4113
+ * through `maxzoom` (or just `minzoom` if unbounded).
4114
+ */
4115
+ function sourceZoomRange(region, sourceMinZoom, sourceMaxZoom) {
4116
+ const srcMin = sourceMinZoom !== undefined ? Math.ceil(sourceMinZoom) : undefined;
4117
+ const srcMax = sourceMaxZoom !== undefined ? Math.floor(sourceMaxZoom) : undefined;
4118
+ if (srcMax !== undefined && srcMax < region.minZoom) {
4119
+ return { min: srcMax, max: srcMax };
4120
+ }
4121
+ if (srcMin !== undefined && srcMin > region.maxZoom) {
4122
+ return { min: srcMin, max: srcMax ?? srcMin };
4123
+ }
4124
+ return {
4125
+ min: Math.max(region.minZoom, srcMin ?? region.minZoom),
4126
+ max: Math.min(region.maxZoom, srcMax ?? region.maxZoom),
4127
+ };
4128
+ }
4129
+ /**
4130
+ * A stored source's upstream `maxzoom`. Patched styles cap `maxzoom` for
4131
+ * rendering and stash the upstream value under `__originalMaxzoom`.
4132
+ */
4133
+ function upstreamMaxzoom(source) {
4134
+ if (source?.__originalMaxzoom !== undefined)
4135
+ return source.__originalMaxzoom ?? undefined;
4136
+ return source?.maxzoom;
4137
+ }
4138
+ function hasValidBounds(region) {
4139
+ const b = region.bounds;
4140
+ return (Array.isArray(b) &&
4141
+ b.length === 2 &&
4142
+ b.every(corner => Array.isArray(corner) && corner.length === 2));
4143
+ }
4144
+ /**
4145
+ * Whether a stored tile was downloaded for `region`: inside its bounds, at a
4146
+ * zoom the download pipeline fetches for the tile's source (which can fall
4147
+ * outside the region's own range — see `sourceZoomRange`). `sources` is the
4148
+ * stored style's `sources`. Regions with malformed bounds match nothing.
4149
+ */
4150
+ function tileBelongsToRegion(tile, region, sources) {
4151
+ if (!hasValidBounds(region))
4152
+ return false;
4153
+ const source = tile.sourceId !== undefined ? sources[tile.sourceId] : undefined;
4154
+ const { min, max } = sourceZoomRange(region, source?.minzoom, upstreamMaxzoom(source));
4155
+ return isTileInRegion(tile.z, tile.x, tile.y, {
4156
+ bounds: region.bounds,
4157
+ minZoom: min,
4158
+ maxZoom: max,
4159
+ });
4160
+ }
4161
+
4162
+ const fontLogger = logger.scope('FontService');
4163
+ class FontService {
4164
+ db = dbPromise;
4165
+ async downloadFonts(fontUrls, styleName, options = {}) {
4166
+ const db = await this.db;
4167
+ const { batchSize = 10, maxRetries = 3, skipExisting = true, retryDelay = 1000, timeout = 30000, validateFonts = true, storageQuotaCheck = true, quietMode = false, onProgress, } = options;
4168
+ const startTime = Date.now();
4169
+ let totalSize = 0;
4170
+ let downloadedFonts = 0;
4171
+ let skippedFonts = 0;
4172
+ let failedFonts = 0;
4173
+ const errors = [];
4174
+ const fontsByType = {};
4175
+ // Create progress tracker
4176
+ const progressTracker = createProgressTracker(fontUrls.length);
4177
+ const reportProgress = (message) => {
4178
+ if (!onProgress)
4179
+ return;
4180
+ const progress = progressTracker.getProgress();
4181
+ onProgress({
4182
+ completed: progress.completed,
4183
+ total: progress.total,
4184
+ percentage: progress.percentage,
4185
+ message,
4186
+ errors: errors.map(e => e.error),
4187
+ });
4188
+ };
4189
+ // Filter existing fonts if skipExisting is true
4190
+ let urlsToDownload = fontUrls;
4191
+ if (skipExisting) {
4192
+ const existingFonts = new Set();
4193
+ const tx = db.transaction(['fonts'], 'readonly');
4194
+ let cursor = await tx.objectStore('fonts').openCursor();
4195
+ while (cursor) {
4196
+ existingFonts.add(cursor.value.key);
4197
+ cursor = await cursor.continue();
4198
+ }
4199
+ urlsToDownload = fontUrls.filter(url => {
4200
+ const key = this.createFontKey(url, styleName);
4201
+ return !existingFonts.has(key);
4202
+ });
4203
+ skippedFonts = fontUrls.length - urlsToDownload.length;
4204
+ // Skipped fonts count toward the total, so mark them done up front.
4205
+ if (skippedFonts > 0) {
4206
+ progressTracker.update(skippedFonts);
4207
+ }
4208
+ }
4209
+ // Check storage quota if enabled
4210
+ if (storageQuotaCheck && 'storage' in navigator && 'estimate' in navigator.storage) {
4211
+ const estimate = await navigator.storage.estimate();
4212
+ const usedSpace = estimate.usage || 0;
4213
+ const availableSpace = (estimate.quota || 0) - usedSpace;
4214
+ if (availableSpace < 50 * 1024 * 1024) {
4215
+ // Less than 50MB
4216
+ throw new Error('Insufficient storage space for font download');
4217
+ }
4218
+ }
4219
+ // Process fonts in batches with concurrency control
4220
+ await processBatch(urlsToDownload, async (fontUrl) => {
4221
+ try {
4222
+ const fontKey = this.createFontKey(fontUrl, styleName);
4223
+ const response = await fetchResourceWithRetry(fontUrl, {
4224
+ retries: maxRetries,
4225
+ retryDelay,
4226
+ timeout,
4227
+ proxyType: 'fonts',
4228
+ });
4229
+ if (response.type === 'json') {
4230
+ throw new Error(`Unexpected JSON response when fetching font: ${fontUrl}`);
4231
+ }
4232
+ const fontData = response.data;
4233
+ const contentType = response.contentType ||
4234
+ (response.type === 'other' ? 'font/opentype' : `font/${response.type}`);
4235
+ // Validate font if enabled
4236
+ if (validateFonts) {
4237
+ await this.validateFont(fontData, contentType);
4238
+ }
4239
+ // Store font in database
4240
+ const fontEntry = {
4241
+ key: fontKey,
4242
+ url: fontUrl,
4243
+ originalUrl: fontUrl,
4244
+ data: fontData,
4245
+ contentType,
4246
+ type: this.detectFontType(contentType, fontUrl),
4247
+ size: fontData.byteLength,
4248
+ lastModified: Date.now(),
4249
+ downloadedAt: new Date().toISOString(),
4250
+ downloadId: styleName,
4251
+ metadata: {
4252
+ userAgent: navigator.userAgent,
4253
+ downloadTimestamp: Date.now(),
4254
+ },
4255
+ expires: response.expires,
4256
+ };
4257
+ await db.put('fonts', fontEntry);
4258
+ progressTracker.update(1, fontKey);
4259
+ reportProgress(`Downloaded ${fontKey}`);
4260
+ totalSize += fontData.byteLength;
4261
+ downloadedFonts++;
4262
+ // Track font types
4263
+ const fontType = this.detectFontType(contentType, fontUrl);
4264
+ fontsByType[fontType] = (fontsByType[fontType] || 0) + 1;
4265
+ }
4266
+ catch (error) {
4267
+ failedFonts++;
4268
+ const errorMessage = error instanceof Error ? error.message : String(error);
4269
+ errors.push({
4270
+ url: fontUrl,
4271
+ error: errorMessage,
3429
4272
  });
4273
+ // A failed font is finished too; don't let the bar stall on it.
4274
+ progressTracker.update(1, fontUrl);
4275
+ reportProgress(`Failed: ${fontUrl}`);
3430
4276
  // Only log detailed errors if not in quiet mode
3431
4277
  if (!quietMode) {
3432
4278
  fontLogger.error(`Failed to download font ${fontUrl}:`, errorMessage);
@@ -3439,6 +4285,7 @@ class FontService {
3439
4285
  }, { batchSize });
3440
4286
  const downloadTime = Date.now() - startTime;
3441
4287
  const averageSpeed = downloadTime > 0 ? (totalSize / downloadTime) * 1000 : 0;
4288
+ reportProgress(`Font download complete: ${downloadedFonts} downloaded, ${skippedFonts} skipped, ${failedFonts} failed`);
3442
4289
  return {
3443
4290
  totalFonts: fontUrls.length,
3444
4291
  downloadedFonts,
@@ -3765,6 +4612,10 @@ class SpriteService {
3765
4612
  return !existingSprites.has(key);
3766
4613
  });
3767
4614
  skippedSprites = spriteUrls.length - urlsToDownload.length;
4615
+ // Skipped sprites count toward the total, so mark them done up front.
4616
+ if (skippedSprites > 0) {
4617
+ progressTracker.update(skippedSprites);
4618
+ }
3768
4619
  }
3769
4620
  // Check storage quota if enabled
3770
4621
  if (storageQuotaCheck && 'storage' in navigator && 'estimate' in navigator.storage) {
@@ -3880,7 +4731,9 @@ class SpriteService {
3880
4731
  error: errorMessage,
3881
4732
  });
3882
4733
  spriteLogger.error(`Failed to download sprite ${spriteUrl}:`, error);
3883
- // Update progress even on failure
4734
+ // Advance progress on failure too — a failed item is finished, and
4735
+ // otherwise the bar stalls, then jumps to 100% at the end.
4736
+ progressTracker.update(1, this.extractSpriteName(spriteUrl));
3884
4737
  if (onProgress) {
3885
4738
  const progress = progressTracker.getProgress();
3886
4739
  onProgress({
@@ -4194,8 +5047,75 @@ var spriteService$1 = /*#__PURE__*/Object.freeze({
4194
5047
  verifyAndRepairSprites: verifyAndRepairSprites
4195
5048
  });
4196
5049
 
4197
- // Helper functions to work with StyleEntry structure
4198
- function createStyleEntry(key, style, provider = 'auto', metadata = {}) {
5050
+ /** A style URL's identity, ignoring the access token (same style, different token). */
5051
+ function styleUrlIdentity(url) {
5052
+ try {
5053
+ const parsed = new URL(url);
5054
+ parsed.searchParams.delete('access_token');
5055
+ return parsed.toString();
5056
+ }
5057
+ catch {
5058
+ return url;
5059
+ }
5060
+ }
5061
+ /**
5062
+ * Pick the storage key for a style downloaded from `styleUrl`. `baseId` comes
5063
+ * from the style's `id`/`name`, which unrelated styles can share ("Streets"),
5064
+ * so a key already held by a style from a different URL is never reused:
5065
+ * fall back to the key this URL was stored under before, else a free
5066
+ * `${baseId}-N`. Entries without `originalUrl` (saved with
5067
+ * `includeMetadata: false`) are assumed to be the same style, so re-downloads
5068
+ * don't pile up duplicates; `carryOverRegions` keeps their regions either way.
5069
+ */
5070
+ async function resolveStyleKey(db, baseId, styleUrl) {
5071
+ const target = styleUrlIdentity(styleUrl);
5072
+ const isSameStyle = (entry) => !!entry.originalUrl && styleUrlIdentity(entry.originalUrl) === target;
5073
+ const existing = await db.get('styles', baseId);
5074
+ if (!existing || !existing.originalUrl || isSameStyle(existing))
5075
+ return baseId;
5076
+ const all = await db.getAll('styles');
5077
+ const previous = all.find(isSameStyle);
5078
+ if (previous)
5079
+ return previous.key;
5080
+ const taken = new Set(all.map(entry => entry.key));
5081
+ let n = 2;
5082
+ while (taken.has(`${baseId}-${n}`))
5083
+ n++;
5084
+ logger.warn(`Style key "${baseId}" is used by a style from another URL; storing ${styleUrl} as "${baseId}-${n}"`);
5085
+ return `${baseId}-${n}`;
5086
+ }
5087
+ /**
5088
+ * Re-downloading a style replaces its stored entry. Carry over the regions
5089
+ * already attached to it and re-patch the fresh style for them — otherwise
5090
+ * every existing region is dropped and their tiles are orphaned. Patches a
5091
+ * copy, so callers can keep using the unpatched style for resource downloads.
5092
+ */
5093
+ async function carryOverRegions(db, entry) {
5094
+ const existing = await db.get('styles', entry.key);
5095
+ const regions = existing?.regions;
5096
+ // Config set through `styleConfig` decides what renders and what's planned,
5097
+ // so it has to survive a fresh copy of the style.
5098
+ if (existing?.styleConfig) {
5099
+ entry.style = JSON.parse(JSON.stringify(entry.style));
5100
+ applyStyleConfig(entry.style, existing.styleConfig);
5101
+ entry.styleConfig = existing.styleConfig;
5102
+ }
5103
+ if (!Array.isArray(regions) || regions.length === 0)
5104
+ return;
5105
+ entry.regions = regions;
5106
+ entry.style = JSON.parse(JSON.stringify(entry.style));
5107
+ // Regions' extra sources were added to the old copy, so add them to this one.
5108
+ for (const region of regions) {
5109
+ injectExtraSources(entry.style, region.extraSources);
5110
+ }
5111
+ stashOriginalModels(entry);
5112
+ patchStyleForOffline(entry.style, entry.key, Math.max(...regions.map(region => region.maxZoom)), undefined, entry.key);
5113
+ const { storedModelNames } = await Promise.resolve().then(function () { return modelService$1; });
5114
+ restoreUnstoredModels(entry, await storedModelNames(entry.key));
5115
+ logger.debug(`Carried ${regions.length} region(s) over to re-downloaded style ${entry.key}`);
5116
+ }
5117
+ // Helper functions to work with StyleEntry structure
5118
+ function createStyleEntry(key, style, provider = 'auto', metadata = {}) {
4199
5119
  return {
4200
5120
  key,
4201
5121
  style,
@@ -4296,8 +5216,11 @@ async function downloadStyles(stylesUrl, options = {}) {
4296
5216
  layerCount: style.layers ? style.layers.length : 0,
4297
5217
  });
4298
5218
  }
5219
+ // Mapbox Standard now arrives flat, so resolveImports may not have run.
5220
+ sanitizeIndoorExpressions(style);
4299
5221
  // Check if style already exists in the database
4300
5222
  const db = await dbPromise;
5223
+ style.id = await resolveStyleKey(db, style.id, stylesUrl);
4301
5224
  if (skipExisting) {
4302
5225
  const existingStyle = await db.get('styles', style.id);
4303
5226
  if (existingStyle && typeof existingStyle === 'object') {
@@ -4361,7 +5284,7 @@ async function downloadStyles(stylesUrl, options = {}) {
4361
5284
  timeout: timeoutMs,
4362
5285
  retryDelay: 1000,
4363
5286
  });
4364
- const sourceData = await sourceResponse.json();
5287
+ const sourceData = selectTileJsonVariant(await sourceResponse.json());
4365
5288
  style.sources[sourceKey] = { ...source, ...sourceData }; // Embed source data
4366
5289
  sourcesEmbedded++;
4367
5290
  logger.debug(`Embedded source data for ${sourceKey}`);
@@ -4428,6 +5351,7 @@ async function downloadStyles(stylesUrl, options = {}) {
4428
5351
  }
4429
5352
  : {});
4430
5353
  // Save the style
5354
+ await carryOverRegions(db, styleStorageItem);
4431
5355
  await db.put('styles', styleStorageItem);
4432
5356
  logger.debug('Style with sources saved successfully');
4433
5357
  onProgress?.({
@@ -4701,18 +5625,26 @@ async function loadStyleById(styleId) {
4701
5625
  return null;
4702
5626
  }
4703
5627
  }
5628
+ /** Delete every style and everything stored for them. */
4704
5629
  async function deleteStyles() {
4705
5630
  try {
4706
5631
  const db = await dbPromise;
4707
- await db.clear('styles');
5632
+ await Promise.all(['tiles', 'fonts', 'glyphs', 'sprites', 'models', 'styles'].map(store => db.clear(store)));
5633
+ clearAllCaches();
4708
5634
  logger.debug('All styles deleted successfully');
4709
5635
  }
4710
5636
  catch (error) {
4711
5637
  logger.error('Error deleting styles:', error);
4712
5638
  }
4713
5639
  }
5640
+ /**
5641
+ * Delete a style, its regions and everything stored for it. The entry goes last,
5642
+ * so a failure part-way leaves the style listed and able to be deleted again.
5643
+ */
4714
5644
  async function deleteStyleById(styleId) {
4715
5645
  try {
5646
+ const { RegionService } = await Promise.resolve().then(function () { return regionService; });
5647
+ await new RegionService().deleteStyleData(styleId);
4716
5648
  const db = await dbPromise;
4717
5649
  await db.delete('styles', styleId);
4718
5650
  logger.debug(`Style with ID ${styleId} deleted successfully`);
@@ -4721,6 +5653,24 @@ async function deleteStyleById(styleId) {
4721
5653
  logger.error(`Error deleting style with ID ${styleId}:`, error);
4722
5654
  }
4723
5655
  }
5656
+ /**
5657
+ * Bytes stored for each style, by style id: its tiles, and its other resources
5658
+ * (glyphs, sprites, models and fonts). Tiles shared by regions count once.
5659
+ */
5660
+ async function getStyleStorageSizes() {
5661
+ const db = await dbPromise;
5662
+ const sizes = {};
5663
+ for (const storeName of ['tiles', 'glyphs', 'sprites', 'models', 'fonts']) {
5664
+ for await (const cursor of db.transaction(storeName).store) {
5665
+ const { styleId, size } = cursor.value;
5666
+ if (!styleId)
5667
+ continue;
5668
+ sizes[styleId] ??= { tiles: 0, resources: 0 };
5669
+ sizes[styleId][storeName === 'tiles' ? 'tiles' : 'resources'] += size ?? 0;
5670
+ }
5671
+ }
5672
+ return sizes;
5673
+ }
4724
5674
  /**
4725
5675
  * Get enhanced style statistics
4726
5676
  */
@@ -4946,9 +5896,10 @@ async function isStyleDownloaded(styleId, styleUrl) {
4946
5896
  * Enhanced style download that supports both Mapbox GL and MapLibre GL
4947
5897
  */
4948
5898
  async function downloadStyleWithProvider(styleUrl, options = {}) {
4949
- const { provider: explicitProvider, accessToken: explicitAccessToken, forceProvider = false, skipExisting = false, enableSourceEmbedding = true, maxRetries = 3, timeoutMs = 30000, } = options;
5899
+ const { provider: explicitProvider, accessToken: explicitAccessToken, forceProvider = false, skipExisting = false, enableSourceEmbedding = true, maxRetries = 3, timeoutMs = 30000, onProgress, } = options;
4950
5900
  const startTime = Date.now();
4951
5901
  let sourcesEmbedded = 0;
5902
+ const emit = (percentage, message) => onProgress?.({ completed: percentage, total: 100, percentage, message, errors: [] });
4952
5903
  logger.debug(`Downloading style from: ${styleUrl}`);
4953
5904
  try {
4954
5905
  // Auto-detect provider if not explicitly set
@@ -4969,6 +5920,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
4969
5920
  fetchUrl = normalizeStyleUrl(styleUrl, extractedToken);
4970
5921
  }
4971
5922
  // Fetch the style
5923
+ emit(0, 'Fetching style');
4972
5924
  const response = await fetchWithRetry(fetchUrl, {
4973
5925
  timeout: timeoutMs,
4974
5926
  retries: maxRetries,
@@ -4977,6 +5929,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
4977
5929
  throw new Error(`Failed to fetch style: ${response.status} ${response.statusText}`);
4978
5930
  }
4979
5931
  const style = (await response.json());
5932
+ emit(20, 'Style fetched');
4980
5933
  // Resolve imports (Mapbox Standard / compositional styles)
4981
5934
  if (hasImports(style)) {
4982
5935
  const importToken = extractedToken || '';
@@ -4990,13 +5943,19 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
4990
5943
  layerCount: style.layers ? style.layers.length : 0,
4991
5944
  });
4992
5945
  }
5946
+ // Mapbox Standard now arrives flat, so resolveImports may not have run.
5947
+ sanitizeIndoorExpressions(style);
5948
+ emit(40, 'Processing style sources');
4993
5949
  // Process style for the detected provider
4994
5950
  const processedStyle = processStyleSources(style, detectedProvider, extractedToken);
4995
5951
  // Embed TileJSON data into sources for reliable offline tile downloads
4996
5952
  // This fetches each source's TileJSON URL and merges the result (including tiles array,
4997
5953
  // minzoom, maxzoom) into the source config, so tile downloads don't need extra fetches
4998
5954
  if (enableSourceEmbedding && processedStyle.sources) {
4999
- for (const [sourceKey, sourceConfig] of Object.entries(processedStyle.sources)) {
5955
+ const sourceEntries = Object.entries(processedStyle.sources);
5956
+ let sourcesVisited = 0;
5957
+ for (const [sourceKey, sourceConfig] of sourceEntries) {
5958
+ emit(40 + Math.round((sourcesVisited++ / Math.max(1, sourceEntries.length)) * 40), `Embedding source ${sourceKey}`);
5000
5959
  const source = sourceConfig;
5001
5960
  if (source.url && typeof source.url === 'string' && !source.tiles) {
5002
5961
  const sourceUrl = source.url;
@@ -5009,7 +5968,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
5009
5968
  timeout: timeoutMs,
5010
5969
  retryDelay: 1000,
5011
5970
  });
5012
- const sourceData = await sourceResponse.json();
5971
+ const sourceData = selectTileJsonVariant(await sourceResponse.json());
5013
5972
  // Only copy valid MapLibre/Mapbox source properties from TileJSON
5014
5973
  // Copying unknown properties (like "name", "tilejson") causes
5015
5974
  // MapLibre to throw "unknown property" errors on setStyle()
@@ -5043,6 +6002,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
5043
6002
  }
5044
6003
  }
5045
6004
  }
6005
+ emit(80, 'Saving style');
5046
6006
  // Set default glyphs URL if not present
5047
6007
  if (!processedStyle.glyphs) {
5048
6008
  processedStyle.glyphs = GLYPH_CONFIG.DEFAULT_URL;
@@ -5054,13 +6014,17 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
5054
6014
  throw new Error(`Style validation failed: ${validation.errors.join(', ')}`);
5055
6015
  }
5056
6016
  // Generate style ID
5057
- const styleId = processedStyle.name?.toLowerCase().replace(/\s+/g, '-') || `style-${Date.now()}`;
5058
- // Check if style already exists
5059
6017
  const db = await dbPromise;
6018
+ const styleId = await resolveStyleKey(db,
6019
+ // The key goes into resource keys ("{key}:...") and idb:// URLs, so ":" or "/"
6020
+ // in a style's name would make its data look like another style's or break its URLs.
6021
+ processedStyle.name?.toLowerCase().replace(/[\s:/\\?#%]+/g, '-') || `style-${Date.now()}`, styleUrl);
6022
+ // Check if style already exists
5060
6023
  if (skipExisting) {
5061
6024
  const existingStyle = await db.get('styles', styleId);
5062
6025
  if (existingStyle) {
5063
6026
  logger.debug(`Style ${styleId} already exists, skipping`);
6027
+ emit(100, 'Style already exists');
5064
6028
  return {
5065
6029
  styleId,
5066
6030
  success: true,
@@ -5091,7 +6055,9 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
5091
6055
  accessToken: extractedToken,
5092
6056
  });
5093
6057
  // Save style
6058
+ await carryOverRegions(db, styleEntry);
5094
6059
  await db.put('styles', styleEntry);
6060
+ emit(100, 'Style saved');
5095
6061
  const downloadTime = Date.now() - startTime;
5096
6062
  logger.info(`Style ${styleId} downloaded in ${downloadTime}ms`);
5097
6063
  return {
@@ -5140,12 +6106,38 @@ var styleService = /*#__PURE__*/Object.freeze({
5140
6106
  downloadStyleWithProvider: downloadStyleWithProvider,
5141
6107
  downloadStyles: downloadStyles,
5142
6108
  getStyleStats: getStyleStats,
6109
+ getStyleStorageSizes: getStyleStorageSizes,
5143
6110
  isStyleDownloaded: isStyleDownloaded,
5144
6111
  loadStyleById: loadStyleById,
5145
6112
  loadStyles: loadStyles
5146
6113
  });
5147
6114
 
5148
6115
  const regionLogger$1 = logger.scope('RegionService');
6116
+ /**
6117
+ * Where each phase sits in a download's overall progress, as [start, end] in
6118
+ * percent. Tiles get the most because they're usually most of the bytes.
6119
+ */
6120
+ const PHASE_PROGRESS_RANGES = {
6121
+ style: [0, 5],
6122
+ sprites: [5, 10],
6123
+ glyphs: [10, 25],
6124
+ models: [25, 30],
6125
+ tiles: [30, 99],
6126
+ metadata: [99, 100],
6127
+ };
6128
+ /** True for a Mapbox-hosted style sprite, which has an `iconset.pbf` beside it. */
6129
+ function hasMapboxIconset(spriteUrl) {
6130
+ try {
6131
+ const { hostname, pathname } = new URL(spriteUrl);
6132
+ return (hostname === 'api.mapbox.com' &&
6133
+ pathname.startsWith('/styles/v1/') &&
6134
+ pathname.endsWith('/sprite'));
6135
+ }
6136
+ catch {
6137
+ // Not an absolute URL, so not a Mapbox-hosted sprite.
6138
+ return false;
6139
+ }
6140
+ }
5149
6141
  /**
5150
6142
  * True when `key` belongs to the given styleId.
5151
6143
  *
@@ -5162,44 +6154,10 @@ function resourceKeyBelongsToStyle(key, styleId) {
5162
6154
  }
5163
6155
  class RegionService {
5164
6156
  /**
5165
- * Check if a tile overlaps with any region bounds
5166
- */
5167
- tileOverlapsWithRegion(tileX, tileY, tileZ, region) {
5168
- // Validate region bounds exist and have correct structure
5169
- if (!region.bounds ||
5170
- !Array.isArray(region.bounds) ||
5171
- region.bounds.length !== 2 ||
5172
- !Array.isArray(region.bounds[0]) ||
5173
- !Array.isArray(region.bounds[1]) ||
5174
- region.bounds[0].length !== 2 ||
5175
- region.bounds[1].length !== 2) {
5176
- regionLogger$1.warn('Invalid region bounds structure:', region.bounds);
5177
- return false;
5178
- }
5179
- // Get tile bounds using tilebelt
5180
- const tileBounds = tilebelt.tileToBBOX([tileX, tileY, tileZ]);
5181
- const [tileWest, tileSouth, tileEast, tileNorth] = tileBounds;
5182
- // Region bounds format: [[west, south], [east, north]]
5183
- const regionWest = region.bounds[0][0];
5184
- const regionSouth = region.bounds[0][1];
5185
- const regionEast = region.bounds[1][0];
5186
- const regionNorth = region.bounds[1][1];
5187
- // Check if bounding boxes overlap
5188
- return !(tileEast < regionWest ||
5189
- tileWest > regionEast ||
5190
- tileNorth < regionSouth ||
5191
- tileSouth > regionNorth);
5192
- }
5193
- /**
5194
- * Check if a tile overlaps with any of the given regions
6157
+ * Delete tiles no remaining region needs — outside every remaining region's
6158
+ * bounds, or at a zoom none of them downloads for that tile's source.
5195
6159
  */
5196
- tileOverlapsWithAnyRegion(tileX, tileY, tileZ, regions) {
5197
- return regions.some(region => this.tileOverlapsWithRegion(tileX, tileY, tileZ, region));
5198
- }
5199
- /**
5200
- * Delete tiles that don't overlap with any remaining regions
5201
- */
5202
- async deleteNonOverlappingTiles(styleId, remainingRegions) {
6160
+ async deleteNonOverlappingTiles(styleId, remainingRegions, sources) {
5203
6161
  const db = await dbPromise;
5204
6162
  let deletedCount = 0;
5205
6163
  regionLogger$1.debug(`Checking tiles for style ${styleId} against ${remainingRegions.length} remaining regions`);
@@ -5216,9 +6174,13 @@ class RegionService {
5216
6174
  regionLogger$1.warn(`Invalid tile key format: ${tileEntry.key}`);
5217
6175
  continue;
5218
6176
  }
5219
- const { x, y, z } = parsed;
5220
- // Check if this tile overlaps with any remaining region
5221
- if (!this.tileOverlapsWithAnyRegion(x, y, z, remainingRegions)) {
6177
+ const tile = {
6178
+ sourceId: tileEntry.sourceId ?? parsed.sourceId,
6179
+ z: parsed.z,
6180
+ x: parsed.x,
6181
+ y: parsed.y,
6182
+ };
6183
+ if (!remainingRegions.some(region => tileBelongsToRegion(tile, region, sources))) {
5222
6184
  regionLogger$1.debug(`Deleting non-overlapping tile: ${tileEntry.key}`);
5223
6185
  await cursor.delete();
5224
6186
  deletedCount++;
@@ -5272,6 +6234,46 @@ class RegionService {
5272
6234
  /**
5273
6235
  * Delete all tiles for a style
5274
6236
  */
6237
+ /**
6238
+ * Delete everything stored for a style except its own entry: tiles, fonts,
6239
+ * glyphs, sprites and models.
6240
+ */
6241
+ /**
6242
+ * Undo what a failed or cancelled download left behind: its partly stored
6243
+ * tiles and its config change. A style this download created, which no other
6244
+ * region uses, goes entirely, and so does a style deleted during the download.
6245
+ */
6246
+ async cleanUpFailedDownload(styleId, createdStyle, configBefore) {
6247
+ try {
6248
+ const db = await dbPromise;
6249
+ const entry = await db.get('styles', styleId);
6250
+ if (!entry) {
6251
+ await this.deleteStyleData(styleId);
6252
+ return;
6253
+ }
6254
+ const regions = Array.isArray(entry.regions) ? entry.regions : [];
6255
+ if (createdStyle && regions.length === 0) {
6256
+ await this.deleteStyleData(styleId);
6257
+ await db.delete('styles', styleId);
6258
+ return;
6259
+ }
6260
+ if (configBefore) {
6261
+ entry.style.schema = configBefore.schema;
6262
+ entry.styleConfig = configBefore.values;
6263
+ await db.put('styles', entry);
6264
+ }
6265
+ await this.deleteNonOverlappingTiles(styleId, regions, (entry.style?.sources ?? {}));
6266
+ clearAllCaches();
6267
+ }
6268
+ catch (error) {
6269
+ regionLogger$1.warn(`Could not clean up after the failed download on ${styleId}:`, error);
6270
+ }
6271
+ }
6272
+ async deleteStyleData(styleId) {
6273
+ await this.deleteAllStyleTiles(styleId);
6274
+ await this.deleteStyleResources(styleId);
6275
+ clearAllCaches();
6276
+ }
5275
6277
  async deleteAllStyleTiles(styleId) {
5276
6278
  const db = await dbPromise;
5277
6279
  let deletedCount = 0;
@@ -5318,23 +6320,15 @@ class RegionService {
5318
6320
  styleEntry.regions = [];
5319
6321
  }
5320
6322
  // Inject extra sources into the style so they get patched for offline use
5321
- if (region.extraSources && region.extraSources.length > 0) {
5322
- for (const extra of region.extraSources) {
5323
- if (!styleEntry.style.sources[extra.id]) {
5324
- styleEntry.style.sources[extra.id] = {
5325
- type: extra.type || 'vector',
5326
- tiles: extra.tiles,
5327
- ...(extra.minzoom !== undefined ? { minzoom: extra.minzoom } : {}),
5328
- ...(extra.maxzoom !== undefined ? { maxzoom: extra.maxzoom } : {}),
5329
- ...(extra.attribution ? { attribution: extra.attribution } : {}),
5330
- };
5331
- regionLogger$1.debug(`Injected extra source into style: ${extra.id}`);
5332
- }
5333
- }
5334
- }
5335
- // Patch style for offline use with the region's maxZoom and tileExtension
5336
- // Pass styleId for sprites since they're stored with the style ID, not region ID
5337
- patchStyleForOffline(styleEntry.style, region.id, region.maxZoom, region.tileExtension, styleId);
6323
+ injectExtraSources(styleEntry.style, region.extraSources);
6324
+ stashOriginalModels(styleEntry);
6325
+ // Patch style for offline use. URLs are keyed by styleId (resources are
6326
+ // stored per style), and maxzoom is capped at the deepest region on the
6327
+ // style — not just this one — so adding a shallow region doesn't hide a
6328
+ // deeper sibling's tiles.
6329
+ const otherRegions = styleEntry.regions.filter((r) => r.id !== region.id && r.regionId !== region.id);
6330
+ patchStyleForOffline(styleEntry.style, region.id, Math.max(region.maxZoom, ...otherRegions.map(r => r.maxZoom)), region.tileExtension, styleId);
6331
+ restoreUnstoredModels(styleEntry, await storedModelNames(styleId));
5338
6332
  // Add or update region metadata on the style entry.
5339
6333
  // `expiry` is stored as an absolute timestamp (per the type doc and all
5340
6334
  // readers in cleanupService/cleanupManagement/RegionList). If the caller
@@ -5362,6 +6356,8 @@ class RegionService {
5362
6356
  styleEntry.regions.push(regionWithMeta);
5363
6357
  }
5364
6358
  await db.put('styles', styleEntry);
6359
+ // Drop in-memory region→style / tile caches so the fetch handler sees the new region.
6360
+ clearAllCaches();
5365
6361
  }
5366
6362
  async loadRegion(region, options = {}) {
5367
6363
  return this.downloadRegion(region, options);
@@ -5375,17 +6371,22 @@ class RegionService {
5375
6371
  if (!region.styleUrl) {
5376
6372
  throw new Error('Region must have a styleUrl');
5377
6373
  }
5378
- const { onProgress, provider = 'auto', accessToken, skipGlyphs = false, skipSprites = false, skipModels = false, glyphRanges, tileOptions, } = options;
6374
+ validateZoomLevels(region.minZoom, region.maxZoom);
6375
+ const { onProgress, provider = 'auto', accessToken, skipGlyphs = false, skipSprites = false, skipModels = false, glyphRanges, tileOptions, styleConfig, signal, } = options;
6376
+ let overallPercentage = 0;
5379
6377
  const emit = (phase, completed, total, message) => {
5380
6378
  if (!onProgress)
5381
6379
  return;
5382
6380
  const safeTotal = total > 0 ? total : 1;
5383
6381
  const clamped = Math.min(completed, safeTotal);
6382
+ const [start, end] = PHASE_PROGRESS_RANGES[phase];
6383
+ overallPercentage = Math.max(overallPercentage, start + (end - start) * (clamped / safeTotal));
5384
6384
  onProgress({
5385
6385
  phase,
5386
6386
  completed: clamped,
5387
- total: safeTotal,
6387
+ total,
5388
6388
  percentage: (clamped / safeTotal) * 100,
6389
+ overallPercentage,
5389
6390
  message,
5390
6391
  });
5391
6392
  };
@@ -5424,6 +6425,18 @@ class RegionService {
5424
6425
  const originalSpriteUrl = (styleEntry.originalSpriteUrl ??
5425
6426
  storedStyle.sprite);
5426
6427
  const originalGlyphsUrl = styleEntry.originalGlyphsUrl ?? storedStyle.glyphs;
6428
+ // Applied before the resource phases, which all plan from the style's config.
6429
+ // Kept so a failed download can put the config back as it was.
6430
+ const configBefore = styleConfig
6431
+ ? JSON.parse(JSON.stringify({ schema: storedStyle.schema, values: styleEntry.styleConfig }))
6432
+ : undefined;
6433
+ if (styleConfig) {
6434
+ applyStyleConfig(storedStyle, styleConfig);
6435
+ styleEntry.styleConfig = { ...styleEntry.styleConfig, ...styleConfig };
6436
+ }
6437
+ // A reused style may predate download-time sanitizing.
6438
+ sanitizeIndoorExpressions(storedStyle);
6439
+ await (await dbPromise).put('styles', styleEntry);
5427
6440
  // 2. Sprites
5428
6441
  const spriteResults = [];
5429
6442
  if (!skipSprites) {
@@ -5434,16 +6447,17 @@ class RegionService {
5434
6447
  // sibling is also served under the same /styles/v1/.../<hash>/ path
5435
6448
  // — we detect that case per-source below and append it to the list.
5436
6449
  const baseSuffixes = ['.json', '.png', '@2x.json', '@2x.png'];
5437
- // Estimate total files assuming iconset is always included (the actual
5438
- // number may be smaller; the emit helper clamps progress to total).
5439
- const totalFiles = spriteSources.length * (baseSuffixes.length + 1);
6450
+ const resolveSpriteBase = (url) => isMapboxProtocol(url) && effectiveAccessToken
6451
+ ? resolveMapboxUrl(url, effectiveAccessToken)
6452
+ : url;
6453
+ const totalFiles = spriteSources.reduce((sum, source) => {
6454
+ const base = resolveSpriteBase(source.url).split('?')[0];
6455
+ return sum + baseSuffixes.length + (hasMapboxIconset(base) ? 1 : 0);
6456
+ }, 0);
5440
6457
  let completed = 0;
5441
6458
  emit('sprites', 0, totalFiles, 'Downloading sprites');
5442
6459
  for (const source of spriteSources) {
5443
- let spriteBase = source.url;
5444
- if (isMapboxProtocol(spriteBase) && effectiveAccessToken) {
5445
- spriteBase = resolveMapboxUrl(spriteBase, effectiveAccessToken);
5446
- }
6460
+ const spriteBase = resolveSpriteBase(source.url);
5447
6461
  const qIndex = spriteBase.indexOf('?');
5448
6462
  const suffixes = [...baseSuffixes];
5449
6463
  // Mapbox Standard serves an iconset.pbf alongside the sprite under
@@ -5451,18 +6465,7 @@ class RegionService {
5451
6465
  // /styles/v1/{owner}/{style}/{hash}/iconset.pbf. The last path
5452
6466
  // segment is `sprite`, so replacing it with `iconset.pbf` works.
5453
6467
  const pathWithoutQuery = qIndex !== -1 ? spriteBase.slice(0, qIndex) : spriteBase;
5454
- let isMapboxStandardSprite = false;
5455
- try {
5456
- const parsed = new URL(pathWithoutQuery);
5457
- isMapboxStandardSprite =
5458
- parsed.hostname === 'api.mapbox.com' &&
5459
- parsed.pathname.startsWith('/styles/v1/') &&
5460
- parsed.pathname.endsWith('/sprite');
5461
- }
5462
- catch {
5463
- // Non-URL sprite base (e.g. relative); not a Mapbox Standard sprite.
5464
- }
5465
- if (isMapboxStandardSprite) {
6468
+ if (hasMapboxIconset(pathWithoutQuery)) {
5466
6469
  // The path-rewrite suffix replaces the trailing `sprite` segment.
5467
6470
  suffixes.push('__ICONSET__');
5468
6471
  }
@@ -5526,14 +6529,16 @@ class RegionService {
5526
6529
  // (plain string or `{ uri }`) — we accept both.
5527
6530
  let modelResult;
5528
6531
  if (!skipModels && storedStyle.models) {
5529
- const rawModels = storedStyle.models;
6532
+ // Upstream URLs, from before patching, so models that failed last time are retried.
6533
+ const rawModels = (styleEntry.originalModels ?? storedStyle.models);
6534
+ const alreadyStored = await storedModelNames(styleId);
5530
6535
  const resolved = {};
5531
6536
  for (const [name, value] of Object.entries(rawModels)) {
5532
6537
  const uri = typeof value === 'string' ? value : value?.uri;
5533
- if (!uri)
6538
+ if (!uri || alreadyStored.has(name))
5534
6539
  continue;
5535
6540
  if (uri.startsWith('idb://'))
5536
- continue; // already patched
6541
+ continue; // patched before upstream URLs were kept
5537
6542
  const httpUrl = isMapboxProtocol(uri) && effectiveAccessToken
5538
6543
  ? resolveMapboxUrl(uri, effectiveAccessToken)
5539
6544
  : uri;
@@ -5556,29 +6561,56 @@ class RegionService {
5556
6561
  }
5557
6562
  }
5558
6563
  }
6564
+ // Renderer code fetched from api.mapbox.com at render time (3D decoders,
6565
+ // RTL text, procedural buildings). Never throws; see cacheMapboxRuntimeAssets.
6566
+ let runtimeAssetsCached;
6567
+ if (/mapbox:\/\/|api\.mapbox\.com/.test(`${region.styleUrl ?? ''} ${originalGlyphsUrl ?? ''}`)) {
6568
+ const { cacheMapboxRuntimeAssets } = await Promise.resolve().then(function () { return runtimeAssetService; });
6569
+ runtimeAssetsCached = await cacheMapboxRuntimeAssets();
6570
+ }
5559
6571
  // 5. Tiles — use the stored (source-embedded) style, which still has HTTP tile URLs
5560
- const { downloadTiles } = await Promise.resolve().then(function () { return tileService$1; });
5561
- const regionForTiles = { ...region, styleId };
5562
- emit('tiles', 0, 100, 'Downloading tiles');
5563
- const tileResult = await downloadTiles(regionForTiles, storedStyle, styleId, {
5564
- skipExisting: false,
5565
- batchSize: 20,
5566
- ...tileOptions,
5567
- onProgress: progress => {
5568
- emit('tiles', progress.completed, progress.total, progress.message ?? 'Downloading tiles');
5569
- tileOptions?.onProgress?.(progress);
5570
- },
5571
- });
5572
- // 6. Metadata — must run last, since addRegion patches style URLs to idb://.
5573
- // Do NOT auto-fill tileExtension from tileResult: that's only the first
5574
- // source's extension, and addRegion feeds it to patchStyleForOffline which
5575
- // would override ALL sources — breaking mixed raster+vector styles. The
5576
- // patcher extracts per-source extensions from tile URL patterns when
5577
- // tileExtension is undefined. Callers who need to force an extension can
5578
- // still pass `region.tileExtension` explicitly.
5579
- emit('metadata', 0, 1, 'Saving region metadata');
5580
- await this.addRegion({ ...region, styleId });
5581
- emit('metadata', 1, 1, 'Region ready');
6572
+ let tileResult;
6573
+ try {
6574
+ signal?.throwIfAborted();
6575
+ const { downloadTiles } = await Promise.resolve().then(function () { return tileService$1; });
6576
+ const regionForTiles = { ...region, styleId };
6577
+ // The tile count isn't known until the plan is made, which can take a while.
6578
+ emit('tiles', 0, 0, 'Planning tiles');
6579
+ tileResult = await downloadTiles(regionForTiles, storedStyle, styleId, {
6580
+ skipExisting: false,
6581
+ batchSize: 20,
6582
+ ...tileOptions,
6583
+ signal,
6584
+ onProgress: progress => {
6585
+ emit('tiles', progress.completed, progress.total, progress.message ?? 'Downloading tiles');
6586
+ tileOptions?.onProgress?.(progress);
6587
+ },
6588
+ });
6589
+ signal?.throwIfAborted();
6590
+ // A region with no tiles at all isn't worth saving as if it had worked, as happens
6591
+ // with an expired token. Partial failures still save: tileResult reports them.
6592
+ const { failedTiles, downloadedTiles, skippedTiles } = tileResult;
6593
+ if (failedTiles > 0 && downloadedTiles === 0 && skippedTiles === 0) {
6594
+ throw new Error(`None of the ${failedTiles} tiles could be downloaded. Check your connection and access token.`);
6595
+ }
6596
+ // 6. Metadata — must run last, since addRegion patches style URLs to idb://.
6597
+ // Do NOT auto-fill tileExtension from tileResult: that's only the first
6598
+ // source's extension, and addRegion feeds it to patchStyleForOffline which
6599
+ // would override ALL sources — breaking mixed raster+vector styles. The
6600
+ // patcher extracts per-source extensions from tile URL patterns when
6601
+ // tileExtension is undefined. Callers who need to force an extension can
6602
+ // still pass `region.tileExtension` explicitly.
6603
+ emit('metadata', 0, 1, 'Saving region metadata');
6604
+ if (!(await (await dbPromise).get('styles', styleId))) {
6605
+ throw new Error('The style was deleted while this region was downloading.');
6606
+ }
6607
+ await this.addRegion({ ...region, styleId });
6608
+ emit('metadata', 1, 1, 'Region ready');
6609
+ }
6610
+ catch (error) {
6611
+ await this.cleanUpFailedDownload(styleId, Boolean(styleResult), configBefore);
6612
+ throw error;
6613
+ }
5582
6614
  return {
5583
6615
  regionId: region.id,
5584
6616
  styleId,
@@ -5586,6 +6618,7 @@ class RegionService {
5586
6618
  spriteResults,
5587
6619
  glyphResult,
5588
6620
  modelResult,
6621
+ runtimeAssetsCached,
5589
6622
  tileResult,
5590
6623
  };
5591
6624
  }
@@ -5610,12 +6643,20 @@ class RegionService {
5610
6643
  }
5611
6644
  return undefined;
5612
6645
  }
5613
- async deleteRegion(regionId) {
6646
+ /**
6647
+ * Delete a region and the tiles/resources only it needed.
6648
+ * @param styleId - The style holding the region. Region ids are only unique
6649
+ * within a style, so pass it whenever known; without it the first style
6650
+ * containing `regionId` is used.
6651
+ */
6652
+ async deleteRegion(regionId, styleId) {
5614
6653
  const db = await dbPromise;
5615
- regionLogger$1.debug(`Deleting region: ${regionId}`);
6654
+ regionLogger$1.debug(`Deleting region: ${regionId}${styleId ? ` from style ${styleId}` : ''}`);
5616
6655
  try {
5617
6656
  // Find which style contains this region
5618
- const allStyles = await db.getAll('styles');
6657
+ const allStyles = styleId
6658
+ ? [await db.get('styles', styleId)].filter((entry) => entry !== undefined)
6659
+ : await db.getAll('styles');
5619
6660
  regionLogger$1.debug(`Found ${allStyles.length} styles to search through`);
5620
6661
  let foundStyle = null;
5621
6662
  let foundRegion = null;
@@ -5634,7 +6675,7 @@ class RegionService {
5634
6675
  }
5635
6676
  }
5636
6677
  if (!foundStyle || !foundRegion) {
5637
- regionLogger$1.warn(`Region ${regionId} not found in any style`);
6678
+ regionLogger$1.warn(`Region ${regionId} not found in ${styleId ? `style ${styleId}` : 'any style'}`);
5638
6679
  return;
5639
6680
  }
5640
6681
  regionLogger$1.debug(`Found region in style: ${foundStyle.key}`, foundRegion);
@@ -5645,11 +6686,7 @@ class RegionService {
5645
6686
  // If this was the last region for the style, delete the entire style and all its resources
5646
6687
  if (remainingRegions.length === 0) {
5647
6688
  regionLogger$1.info(`Style ${foundStyle.key} has no remaining regions, deleting entire style and all resources`);
5648
- // Delete all tiles for this style
5649
- const deletedTileCount = await this.deleteAllStyleTiles(foundStyle.key);
5650
- regionLogger$1.debug(`Deleted ${deletedTileCount} tiles`);
5651
- // Delete all style resources (fonts, glyphs, sprites)
5652
- await this.deleteStyleResources(foundStyle.key);
6689
+ await this.deleteStyleData(foundStyle.key);
5653
6690
  // Delete the style entry itself
5654
6691
  await db.delete('styles', foundStyle.key);
5655
6692
  regionLogger$1.info(`Completely deleted style ${foundStyle.key} and all associated resources`);
@@ -5657,11 +6694,18 @@ class RegionService {
5657
6694
  else {
5658
6695
  regionLogger$1.debug(`Style ${foundStyle.key} has ${remainingRegions.length} remaining regions, cleaning up non-overlapping tiles`);
5659
6696
  // Delete tiles that don't overlap with any remaining regions
5660
- const deletedTileCount = await this.deleteNonOverlappingTiles(foundStyle.key, remainingRegions);
6697
+ const deletedTileCount = await this.deleteNonOverlappingTiles(foundStyle.key, remainingRegions, (foundStyle.style?.sources ?? {}));
6698
+ // Re-patch for the remaining regions: shrink maxzoom to the deepest
6699
+ // survivor, and re-key URLs by styleId. Styles patched before URLs
6700
+ // were style-keyed still point at `idb://{deletedRegionId}/...`,
6701
+ // which no longer resolves once the region is gone.
6702
+ patchStyleForOffline(foundStyle.style, foundStyle.key, Math.max(...remainingRegions.map((r) => r.maxZoom)), undefined, foundStyle.key);
6703
+ restoreUnstoredModels(foundStyle, await storedModelNames(foundStyle.key));
5661
6704
  // Update the style entry with the remaining regions
5662
6705
  await db.put('styles', foundStyle);
5663
6706
  regionLogger$1.info(`Region ${regionId} deleted successfully from style ${foundStyle.key}, cleaned up ${deletedTileCount} non-overlapping tiles`);
5664
6707
  }
6708
+ clearAllCaches();
5665
6709
  regionLogger$1.info(`Region deletion completed successfully for: ${regionId}`);
5666
6710
  }
5667
6711
  catch (error) {
@@ -5712,6 +6756,13 @@ async function loadAllStoredRegions() {
5712
6756
  }
5713
6757
  }
5714
6758
 
6759
+ var regionService = /*#__PURE__*/Object.freeze({
6760
+ __proto__: null,
6761
+ RegionService: RegionService,
6762
+ loadAllStoredRegions: loadAllStoredRegions,
6763
+ resourceKeyBelongsToStyle: resourceKeyBelongsToStyle
6764
+ });
6765
+
5715
6766
  const cleanupLogger = logger.scope('CleanupService');
5716
6767
  class CleanupService {
5717
6768
  db = dbPromise;
@@ -5810,9 +6861,9 @@ class CleanupService {
5810
6861
  let deletedCount = 0;
5811
6862
  for (const region of uniqueRegionsToDelete) {
5812
6863
  try {
5813
- const regionSize = await this.getRegionSize(region.id);
6864
+ const bytesBefore = await this.getStyleStoredBytes(region.styleId);
5814
6865
  await this.deleteRegionCallback(region.id, region.styleId);
5815
- result.freedSpace += regionSize;
6866
+ result.freedSpace += Math.max(0, bytesBefore - (await this.getStyleStoredBytes(region.styleId)));
5816
6867
  deletedCount++;
5817
6868
  onProgress?.({
5818
6869
  phase: 'cleaning',
@@ -5876,7 +6927,7 @@ class CleanupService {
5876
6927
  neverExpiring: 0,
5877
6928
  };
5878
6929
  for (const region of regions) {
5879
- const regionSize = await this.getRegionSize(region.id);
6930
+ const regionSize = await this.getRegionSize(region.id, region.styleId);
5880
6931
  totalSize += regionSize;
5881
6932
  // Track by style
5882
6933
  const styleId = region.styleId || 'unknown';
@@ -5978,8 +7029,8 @@ class CleanupService {
5978
7029
  async getExpiredResourceCount() {
5979
7030
  const db = await this.db;
5980
7031
  const now = Date.now();
5981
- const counts = { tiles: 0, fonts: 0, sprites: 0, glyphs: 0, total: 0 };
5982
- const stores = ['tiles', 'fonts', 'sprites', 'glyphs'];
7032
+ const counts = { tiles: 0, fonts: 0, sprites: 0, glyphs: 0, models: 0, total: 0 };
7033
+ const stores = ['tiles', 'fonts', 'sprites', 'glyphs', 'models'];
5983
7034
  for (const storeName of stores) {
5984
7035
  try {
5985
7036
  const tx = db.transaction([storeName], 'readonly');
@@ -6025,46 +7076,77 @@ class CleanupService {
6025
7076
  async getAllRegions() {
6026
7077
  return loadAllStoredRegions();
6027
7078
  }
7079
+ /**
7080
+ * Bytes of the tiles stored for a region: tiles on its style inside its
7081
+ * bounds, at the zooms the download pipeline fetched for each source. Tiles
7082
+ * are stored per style, so regions that overlap share (and both count)
7083
+ * their common tiles; shared style resources are not included.
7084
+ */
6028
7085
  async getRegionSize(regionId, styleIdParam) {
6029
7086
  const db = await this.db;
6030
- let totalSize = 0;
6031
- let styleId = styleIdParam;
6032
- let regionMinZoom = 0;
6033
- let regionMaxZoom = 22;
6034
- // Find the region's styleId and zoom range from styles.regions[]
6035
- if (!styleId) {
6036
- const styles = await db.getAll('styles');
6037
- for (const style of styles) {
6038
- const styleEntry = style;
6039
- if (styleEntry.regions && Array.isArray(styleEntry.regions)) {
6040
- const found = styleEntry.regions.find(r => r.id === regionId);
6041
- if (found) {
6042
- styleId = styleEntry.key;
6043
- regionMinZoom = found.minZoom ?? 0;
6044
- regionMaxZoom = found.maxZoom ?? 22;
6045
- break;
6046
- }
6047
- }
7087
+ const candidates = styleIdParam
7088
+ ? [await db.get('styles', styleIdParam)]
7089
+ : await db.getAll('styles');
7090
+ let styleEntry;
7091
+ let region;
7092
+ for (const entry of candidates) {
7093
+ region = entry?.regions?.find((r) => r.id === regionId || r.regionId === regionId);
7094
+ if (region) {
7095
+ styleEntry = entry;
7096
+ break;
6048
7097
  }
6049
7098
  }
6050
- if (!styleId) {
7099
+ if (!styleEntry || !region) {
6051
7100
  return 0;
6052
7101
  }
6053
- // Calculate size from tiles that belong to this style within the region's zoom range
7102
+ const styleId = styleEntry.key;
7103
+ const sources = (styleEntry.style?.sources ?? {});
7104
+ let totalSize = 0;
6054
7105
  const tx = db.transaction(['tiles'], 'readonly');
6055
7106
  for await (const cursor of tx.objectStore('tiles')) {
6056
7107
  const tile = cursor.value;
6057
7108
  if (tile.styleId !== styleId)
6058
7109
  continue;
6059
- // Filter by zoom range when possible
6060
7110
  const parsed = parseTileKey(tile.key);
6061
- if (parsed && (parsed.z < regionMinZoom || parsed.z > regionMaxZoom)) {
7111
+ if (!parsed)
6062
7112
  continue;
7113
+ const coords = {
7114
+ sourceId: tile.sourceId ?? parsed.sourceId,
7115
+ z: parsed.z,
7116
+ x: parsed.x,
7117
+ y: parsed.y,
7118
+ };
7119
+ if (tileBelongsToRegion(coords, region, sources)) {
7120
+ totalSize += tile.size || 0;
6063
7121
  }
6064
- totalSize += tile.size || 0;
6065
7122
  }
6066
7123
  return totalSize;
6067
7124
  }
7125
+ /**
7126
+ * Bytes stored for a style: its tiles plus the fonts, glyphs, sprites and
7127
+ * models keyed to it. Measured before and after a deletion to report the
7128
+ * space actually freed (a region's own size over-counts tiles it shares).
7129
+ */
7130
+ async getStyleStoredBytes(styleId) {
7131
+ if (!styleId)
7132
+ return 0;
7133
+ const db = await this.db;
7134
+ let total = 0;
7135
+ const tx = db.transaction(['tiles'], 'readonly');
7136
+ for await (const cursor of tx.objectStore('tiles')) {
7137
+ if (cursor.value.styleId === styleId)
7138
+ total += cursor.value.size || 0;
7139
+ }
7140
+ for (const storeName of ['fonts', 'glyphs', 'sprites', 'models']) {
7141
+ const storeTx = db.transaction([storeName], 'readonly');
7142
+ for await (const cursor of storeTx.objectStore(storeName)) {
7143
+ const entry = cursor.value;
7144
+ if (resourceKeyBelongsToStyle(entry.key, styleId))
7145
+ total += entry.size || 0;
7146
+ }
7147
+ }
7148
+ return total;
7149
+ }
6068
7150
  async calculateTotalStorageSize() {
6069
7151
  if ('storage' in navigator && 'estimate' in navigator.storage) {
6070
7152
  const estimate = await navigator.storage.estimate();
@@ -6078,6 +7160,7 @@ class CleanupService {
6078
7160
  'fonts',
6079
7161
  'sprites',
6080
7162
  'glyphs',
7163
+ 'models',
6081
7164
  'styles',
6082
7165
  ];
6083
7166
  for (const storeName of stores) {
@@ -6119,7 +7202,7 @@ class CleanupService {
6119
7202
  if (currentSize >= targetSize)
6120
7203
  break;
6121
7204
  selected.push(region);
6122
- const regionSize = await this.getRegionSize(region.id);
7205
+ const regionSize = await this.getRegionSize(region.id, region.styleId);
6123
7206
  currentSize += regionSize > 0 ? regionSize : 10 * 1024 * 1024; // Fallback to 10MB if size unknown
6124
7207
  }
6125
7208
  return selected;
@@ -6210,7 +7293,7 @@ class TileService {
6210
7293
  */
6211
7294
  async downloadTiles(region, style, styleId, options = {}) {
6212
7295
  const db = await this.db;
6213
- const { onProgress, batchSize = 10, maxRetries = 3, skipExisting = true, timeout = 10000, retryDelay = 1000, priorityZoomLevels = [], storageQuotaCheck = true, validateTiles = false, compressTiles = false, bandwidthLimit, probeSourcesBeforeDownload = true, skipKnownSparseSources = true, } = options;
7296
+ const { onProgress, batchSize = 10, maxRetries = 3, skipExisting = true, timeout = 10000, retryDelay = 1000, priorityZoomLevels = [], storageQuotaCheck = true, validateTiles = false, compressTiles = false, bandwidthLimit, probeSourcesBeforeDownload = true, skipKnownSparseSources = true, skipInactiveSources = true, signal, } = options;
6214
7297
  const startTime = Date.now();
6215
7298
  let totalSize = 0;
6216
7299
  let downloadedTiles = 0;
@@ -6251,31 +7334,31 @@ class TileService {
6251
7334
  if (tileSources.size === 0) {
6252
7335
  throw new Error('No valid tile sources found in style definition');
6253
7336
  }
6254
- // Some sources have zoom ranges outside the user's region (e.g. procedural-buildings
6255
- // at z15 when the user requested z0-z14). Generate additional tile coordinates so
7337
+ // Features a config-driven style has switched off (Mapbox Standard's HD
7338
+ // roads, indoor, landmark icons...) don't need their tiles.
7339
+ const { active: activeSources, inactive: inactiveSources } = classifySources(style);
7340
+ if (skipInactiveSources) {
7341
+ for (const sourceId of inactiveSources) {
7342
+ if (tileSources.delete(sourceId)) {
7343
+ tileLogger.info(`Skipping source "${sourceId}" — nothing visible uses it under the style's config`);
7344
+ }
7345
+ }
7346
+ }
7347
+ // Some sources have zoom ranges entirely outside the user's region (e.g.
7348
+ // procedural-buildings at z15 when the user requested z0-z14, or a z14-capped
7349
+ // basemap for a z15-z17 region). Generate the extra coordinates they need so
6256
7350
  // these sources aren't silently skipped.
7351
+ const generatedZooms = new Set();
7352
+ for (let z = region.minZoom; z <= region.maxZoom; z++)
7353
+ generatedZooms.add(z);
6257
7354
  for (const [sourceId, sourceConfig] of tileSources) {
6258
- const srcMin = sourceConfig.minzoom;
6259
- const srcMax = sourceConfig.maxzoom;
6260
- if (srcMin === undefined && srcMax === undefined)
6261
- continue;
6262
- const extraMinZ = srcMin !== undefined && srcMin > region.maxZoom ? srcMin : null;
6263
- const extraMaxZ = srcMax !== undefined && srcMax < region.minZoom ? srcMax : null;
6264
- // Only extend upward (higher zoom) — sources that need zooms above region.maxZoom
6265
- if (extraMinZ !== null) {
6266
- const upperBound = srcMax !== undefined ? srcMax : extraMinZ;
6267
- tileLogger.debug(`Source ${sourceId} needs zoom ${extraMinZ}-${upperBound} beyond region max ${region.maxZoom}, generating extra tiles`);
6268
- const extraRegion = { ...region, minZoom: extraMinZ, maxZoom: upperBound };
6269
- const extraCoords = this.generateTileCoordinates(extraRegion);
6270
- tileCoords.push(...extraCoords);
6271
- }
6272
- // Extend downward (lower zoom) — sources that need zooms below region.minZoom
6273
- if (extraMaxZ !== null) {
6274
- const lowerBound = srcMin !== undefined ? srcMin : extraMaxZ;
6275
- tileLogger.debug(`Source ${sourceId} needs zoom ${lowerBound}-${extraMaxZ} below region min ${region.minZoom}, generating extra tiles`);
6276
- const extraRegion = { ...region, minZoom: lowerBound, maxZoom: extraMaxZ };
6277
- const extraCoords = this.generateTileCoordinates(extraRegion);
6278
- tileCoords.push(...extraCoords);
7355
+ const { min, max } = sourceZoomRange(region, sourceConfig.minzoom, sourceConfig.maxzoom);
7356
+ for (let z = min; z <= max; z++) {
7357
+ if (generatedZooms.has(z))
7358
+ continue;
7359
+ generatedZooms.add(z);
7360
+ tileLogger.debug(`Source ${sourceId} needs zoom ${z} outside region ${region.minZoom}-${region.maxZoom}, generating extra tiles`);
7361
+ tileCoords.push(...this.generateTileCoordinates({ ...region, minZoom: z, maxZoom: z }));
6279
7362
  }
6280
7363
  }
6281
7364
  // Sort by priority zoom levels if requested (lower zoom first)
@@ -6318,14 +7401,16 @@ class TileService {
6318
7401
  // issuing any network request. They 404 for nearly every coord in a
6319
7402
  // typical region; the downstream probe step would catch them, but the
6320
7403
  // probe itself logs 404s in devtools. Matching here keeps the console
6321
- // clean.
6322
- if (skipKnownSparseSources &&
6323
- tiles.some(template => urlReferencesKnownSparseTileset(template))) {
7404
+ // clean. A sparse source a switched-on feature needs is kept instead, and
7405
+ // not probed, because its tiles cluster and the three probe tiles could
7406
+ // easily all miss.
7407
+ const knownSparse = tiles.some(template => urlReferencesKnownSparseTileset(template));
7408
+ const neededSparseSource = knownSparse && activeSources.has(sourceId);
7409
+ if (skipKnownSparseSources && knownSparse && !neededSparseSource) {
6324
7410
  tileLogger.info(`Skipping source "${sourceId}" — references a known sparse Mapbox Standard sub-tileset (no probe/download issued)`);
6325
7411
  continue;
6326
7412
  }
6327
- const sourceMinZ = Math.ceil(sourceConfig.minzoom ?? region.minZoom);
6328
- const sourceMaxZ = Math.floor(sourceConfig.maxzoom ?? region.maxZoom);
7413
+ const { min: sourceMinZ, max: sourceMaxZ } = sourceZoomRange(region, sourceConfig.minzoom, sourceConfig.maxzoom);
6329
7414
  let coordsForSource = tileCoords.filter(coord => coord.z >= sourceMinZ && coord.z <= sourceMaxZ);
6330
7415
  tileLogger.debug(`After zoom filter (${sourceMinZ}-${sourceMaxZ}): ${coordsForSource.length} tiles`);
6331
7416
  if (coordsForSource.length === 0) {
@@ -6359,7 +7444,7 @@ class TileService {
6359
7444
  // A single probe on the wrong coord would false-pass and cause a
6360
7445
  // flood of 404s during the download. Probing start/middle/end of
6361
7446
  // the plan catches this case.
6362
- if (probeSourcesBeforeDownload) {
7447
+ if (probeSourcesBeforeDownload && !neededSparseSource) {
6363
7448
  const picks = [
6364
7449
  coordsForSource[0],
6365
7450
  coordsForSource[Math.floor(coordsForSource.length / 2)],
@@ -6418,6 +7503,9 @@ class TileService {
6418
7503
  .map(([z, count]) => `Z${z}: ${count} tiles`)
6419
7504
  .join(', '));
6420
7505
  await processBatch(plan.coords, async (coord) => {
7506
+ // Cancelled: let the remaining tiles go by without fetching them.
7507
+ if (signal?.aborted)
7508
+ return;
6421
7509
  const { x, y, z } = coord;
6422
7510
  const label = `${plan.sourceId}:${z}/${x}/${y}`;
6423
7511
  let tileUrl = '';
@@ -6527,7 +7615,9 @@ class TileService {
6527
7615
  const errorObject = _error;
6528
7616
  errorMessage = errorObject instanceof Error ? errorObject.message : String(errorObject);
6529
7617
  // 404s on sparse tilesets (landmarks, POIs) are expected — don't count as failures
6530
- const is404 = errorMessage.includes('404') || errorMessage.includes('not found');
7618
+ // Match the start only: the message goes on to include the URL, whose
7619
+ // coordinates or token can contain "404".
7620
+ const is404 = /^(Resource not found \(404\)|HTTP 404\b)/.test(errorMessage);
6531
7621
  const isExpected = errorMessage.includes('NonRetryableError');
6532
7622
  if (is404) {
6533
7623
  skippedTiles++;
@@ -6705,13 +7795,7 @@ class TileService {
6705
7795
  tileLogger.debug(`Approximate area: ${areaApproxKm2.toFixed(2)} km² (improved calculation)`);
6706
7796
  tileLogger.debug(`Region dimensions: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km`);
6707
7797
  for (let z = region.minZoom; z <= region.maxZoom; z++) {
6708
- const bounds = region.bounds;
6709
- const minTile = tilebelt.pointToTile(bounds[0][0], bounds[0][1], z);
6710
- const maxTile = tilebelt.pointToTile(bounds[1][0], bounds[1][1], z);
6711
- const minX = Math.min(minTile[0], maxTile[0]);
6712
- const maxX = Math.max(minTile[0], maxTile[0]);
6713
- const minY = Math.min(minTile[1], maxTile[1]);
6714
- const maxY = Math.max(minTile[1], maxTile[1]);
7798
+ const { minX, maxX, minY, maxY } = getTileRangeAtZoom(region.bounds, z);
6715
7799
  const tilesAtZoom = (maxX - minX + 1) * (maxY - minY + 1);
6716
7800
  tilesByZoom[z] = tilesAtZoom;
6717
7801
  tileLogger.debug(`Zoom ${z}: ${tilesAtZoom} tiles (X: ${minX}-${maxX}, Y: ${minY}-${maxY})`);
@@ -6745,7 +7829,10 @@ class TileService {
6745
7829
  }
6746
7830
  tileLogger.debug('Processing sources in extractTileSources:', Object.keys(style.sources));
6747
7831
  for (const [sourceId, sourceConfig] of Object.entries(style.sources)) {
6748
- const config = sourceConfig;
7832
+ // Shallow copy: the maxzoom override below must not mutate the caller's style.
7833
+ const config = {
7834
+ ...sourceConfig,
7835
+ };
6749
7836
  tileLogger.debug(`Source ${sourceId} RAW DATA:`, JSON.stringify(config, null, 2));
6750
7837
  tileLogger.debug(`Processing source ${sourceId}:`, {
6751
7838
  type: config.type,
@@ -6756,18 +7843,26 @@ class TileService {
6756
7843
  });
6757
7844
  // Handle tile-based sources (vector, raster, raster-dem, batched-model,
6758
7845
  // raster-array). `raster-array` is used by Mapbox Standard for layers
6759
- // like `mapbox-landmarks` (mapbox.mapbox-landmark-icons-v1) — the tiles
6760
- // are fetched from the same /v4/ endpoint as other tilesets, so the
6761
- // TileJSON resolution path below handles them uniformly.
7846
+ // like `mapbox-landmarks` (mapbox.mapbox-landmark-icons-v1). Each type has
7847
+ // its own tile endpoint (/v4/, /raster/v1/, /rasterarrays/v1/, /3dtiles/v1/),
7848
+ // but TileJSON gives the URL for all of them, so the resolution path below
7849
+ // handles them uniformly.
6762
7850
  if (config.type === 'vector' ||
6763
7851
  config.type === 'raster' ||
6764
7852
  config.type === 'raster-dem' ||
6765
7853
  config.type === 'raster-array' ||
6766
7854
  config.type === 'batched-model') {
7855
+ // A style already patched for offline (every region after the first)
7856
+ // has idb:// tiles and a maxzoom capped to earlier regions; plan from
7857
+ // the upstream values patchStyleForOffline stashed instead.
7858
+ const sourceTiles = config.__originalTiles ?? config.tiles;
7859
+ if (config.__originalMaxzoom !== undefined) {
7860
+ config.maxzoom = config.__originalMaxzoom ?? undefined;
7861
+ }
6767
7862
  // Handle direct tile URLs in the source config
6768
- if (config.tiles && Array.isArray(config.tiles) && config.tiles.length > 0) {
7863
+ if (sourceTiles && Array.isArray(sourceTiles) && sourceTiles.length > 0) {
6769
7864
  // Resolve mapbox:// tile URLs to HTTPS, then filter for HTTP(S) URLs
6770
- const resolvedTiles = config.tiles.map((tile) => {
7865
+ const resolvedTiles = sourceTiles.map((tile) => {
6771
7866
  if (isMapboxProtocol(tile)) {
6772
7867
  // Try to find an access token from the style or source URL
6773
7868
  const accessToken = this.extractAccessTokenFromStyle(style);
@@ -6839,7 +7934,9 @@ class TileService {
6839
7934
  });
6840
7935
  tileLogger.debug(`TileJSON fetch response type: ${response.type}`);
6841
7936
  if (response.type === 'json') {
6842
- const jsonData = response.data;
7937
+ const jsonData = response.data
7938
+ ? selectTileJsonVariant(response.data)
7939
+ : null;
6843
7940
  tileLogger.debug(`TileJSON data keys: ${Object.keys(jsonData || {}).join(', ')}`);
6844
7941
  if (jsonData &&
6845
7942
  typeof jsonData === 'object' &&
@@ -6943,16 +8040,9 @@ class TileService {
6943
8040
  }
6944
8041
  }
6945
8042
  if (tileSources.size === 0) {
8043
+ // No fabricated fallback: a relative '{z}/{x}/{y}.pbf' template resolves
8044
+ // against the page and stores whatever the app server returns as tiles.
6946
8045
  tileLogger.warn('No valid tile sources found in style', Object.keys(style.sources));
6947
- // As a last resort, try to use a common vector tile source pattern if we can't extract any
6948
- if (style.sources && Object.keys(style.sources).length > 0) {
6949
- const firstSourceId = Object.keys(style.sources)[0];
6950
- tileLogger.debug(`Attempting to create fallback source from ${firstSourceId}`);
6951
- tileSources.set(firstSourceId, {
6952
- type: 'vector',
6953
- tiles: ['{z}/{x}/{y}.pbf'],
6954
- });
6955
- }
6956
8046
  }
6957
8047
  return tileSources;
6958
8048
  }
@@ -7360,341 +8450,146 @@ class GlyphService {
7360
8450
  health: {
7361
8451
  corruptedGlyphs: stats.corruptedGlyphs.length,
7362
8452
  corruptionRate: stats.count > 0 ? (stats.corruptedGlyphs.length / stats.count) * 100 : 0,
7363
- },
7364
- temporal: {
7365
- oldestGlyph: stats.oldestGlyph,
7366
- newestGlyph: stats.newestGlyph,
7367
- ageSpan: stats.oldestGlyph && stats.newestGlyph
7368
- ? stats.newestGlyph.lastModified - stats.oldestGlyph.lastModified
7369
- : 0,
7370
- },
7371
- };
7372
- }
7373
- /**
7374
- * Remove glyphs older than the specified age. When `options.styleId` is
7375
- * provided, only glyphs belonging to that style (per
7376
- * `resourceKeyBelongsToStyle`) are eligible.
7377
- */
7378
- async cleanupOldGlyphs(maxAge = 30, options = {}) {
7379
- const db = await this.db;
7380
- const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
7381
- const { styleId } = options;
7382
- let deletedCount = 0;
7383
- const tx = db.transaction('glyphs', 'readwrite');
7384
- for await (const cursor of tx.store) {
7385
- const glyphEntry = cursor.value;
7386
- const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
7387
- if (belongs && glyphEntry.lastModified < cutoffTime) {
7388
- await cursor.delete();
7389
- deletedCount++;
7390
- }
7391
- }
7392
- return deletedCount;
7393
- }
7394
- async verifyAndRepairGlyphs() {
7395
- const db = await this.db;
7396
- let verified = 0;
7397
- // eslint-disable-next-line prefer-const -- will be incremented when repair logic is added
7398
- let repaired = 0;
7399
- let removed = 0;
7400
- const tx = db.transaction('glyphs', 'readwrite');
7401
- for await (const cursor of tx.store) {
7402
- const glyphEntry = cursor.value;
7403
- try {
7404
- // Basic validation
7405
- if (!glyphEntry.data || glyphEntry.size === 0) {
7406
- await cursor.delete();
7407
- removed++;
7408
- }
7409
- else {
7410
- this.validateGlyphData(glyphEntry.data);
7411
- verified++;
7412
- }
7413
- }
7414
- catch {
7415
- // Remove corrupted glyphs (they can be re-downloaded)
7416
- await cursor.delete();
7417
- removed++;
7418
- }
7419
- }
7420
- return { verified, repaired, removed };
7421
- }
7422
- validateGlyphData(data) {
7423
- if (data.byteLength < 10) {
7424
- throw new Error('Glyph data too short');
7425
- }
7426
- // Basic Protocol Buffer validation
7427
- const view = new Uint8Array(data);
7428
- // Check for valid protobuf structure (basic check)
7429
- if (view[0] === 0x00 && view[1] === 0x00 && view[2] === 0x00 && view[3] === 0x00) {
7430
- throw new Error('Invalid glyph data - appears to be empty');
7431
- }
7432
- }
7433
- normalizeFontstack(fontstack) {
7434
- try {
7435
- return decodeURIComponent(fontstack);
7436
- }
7437
- catch {
7438
- return fontstack;
7439
- }
7440
- }
7441
- buildGlyphPath(fontstack, range) {
7442
- const normalizedFontstack = this.normalizeFontstack(fontstack);
7443
- return `${normalizedFontstack}/${range}.pbf`;
7444
- }
7445
- parseGlyphKey(key) {
7446
- let remaining = key;
7447
- let styleId;
7448
- if (remaining.includes('::')) {
7449
- const [prefix, ...rest] = remaining.split('::');
7450
- styleId = prefix;
7451
- remaining = rest.join('::');
7452
- }
7453
- else if (remaining.includes(':') && !remaining.includes('/')) {
7454
- const [prefix, rest] = remaining.split(':');
7455
- styleId = styleId ?? prefix;
7456
- remaining = rest;
7457
- }
7458
- const trimmed = remaining.endsWith('.pbf') ? remaining.slice(0, -4) : remaining;
7459
- if (trimmed.includes('/')) {
7460
- const [fontstack, range] = trimmed.split('/');
7461
- if (fontstack && range) {
7462
- return { styleId, fontstack, range };
7463
- }
7464
- }
7465
- if (trimmed.includes('_')) {
7466
- const [fontstack, range] = trimmed.split('_');
7467
- if (fontstack && range) {
7468
- return { styleId, fontstack, range };
7469
- }
7470
- }
7471
- return { styleId, fontstack: trimmed, range: '' };
7472
- }
7473
- createGlyphKey(fontstack, range, styleName) {
7474
- // Create a consistent key from the style name, fontstack and range
7475
- // Format: stylename::fontstack/range.pbf (if styleName provided) or fontstack/range.pbf
7476
- const glyphPath = this.buildGlyphPath(fontstack, range);
7477
- if (styleName) {
7478
- return `${styleName}::${glyphPath}`;
7479
- }
7480
- // Fallback to original format for backward compatibility
7481
- return glyphPath;
7482
- }
7483
- }
7484
- // Export singleton instance and functions for backward compatibility
7485
- const glyphService = new GlyphService();
7486
- const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => glyphService.downloadGlyphs(glyphUrl, fontstacks, styleName, ranges, options);
7487
- const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
7488
- const getGlyphStats = () => glyphService.getGlyphStats();
7489
- const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
7490
- const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
7491
- const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
7492
-
7493
- var glyphService$1 = /*#__PURE__*/Object.freeze({
7494
- __proto__: null,
7495
- GlyphService: GlyphService,
7496
- cleanupOldGlyphs: cleanupOldGlyphs,
7497
- downloadGlyphs: downloadGlyphs,
7498
- getGlyphAnalytics: getGlyphAnalytics,
7499
- getGlyphStats: getGlyphStats,
7500
- glyphService: glyphService,
7501
- loadGlyphs: loadGlyphs,
7502
- verifyAndRepairGlyphs: verifyAndRepairGlyphs
7503
- });
7504
-
7505
- const modelLogger = logger.scope('ModelService');
7506
- /**
7507
- * Build the storage key for a model. Kept consistent with sprite/glyph
7508
- * conventions: `{styleId}::model::{modelName}`.
7509
- */
7510
- function modelKey(styleId, modelName) {
7511
- return `${styleId}::model::${modelName}`;
7512
- }
7513
- /** True when the given key belongs to the given styleId's model store prefix. */
7514
- function modelKeyBelongsToStyle(key, styleId) {
7515
- return key.startsWith(`${styleId}::model::`);
7516
- }
7517
- /**
7518
- * Service for downloading, storing, and serving Mapbox 3D model (.glb) files.
7519
- *
7520
- * Mapbox Standard declares 32 models at the top of `style.models`:
7521
- *
7522
- * ```json
7523
- * {
7524
- * "maple1-lod1": "mapbox://models/mapbox/maple1-v4-lod1.glb",
7525
- * ...
7526
- * }
7527
- * ```
7528
- *
7529
- * `model` layers (e.g. `trees`, `wind-turbine-towers`) pick one by name at
7530
- * render time. For offline use each referenced URL is fetched and stored
7531
- * here, and `patchStyleForOffline` rewrites the dictionary entries to
7532
- * `idb://{styleId}/model/{name}` URLs.
7533
- */
7534
- class ModelService {
7535
- db = dbPromise;
7536
- /**
7537
- * Download the set of models referenced by `style.models` for one style.
7538
- *
7539
- * @param models `{ modelName: resolvedHttpUrl }` — URLs must already be
7540
- * resolved (mapbox:// URLs should be resolved by the caller).
7541
- * @param styleId The owning style's key.
7542
- */
7543
- async downloadModels(models, styleId, options = {}) {
7544
- const db = await this.db;
7545
- const { onProgress, batchSize = 4, maxRetries = 3, skipExisting = true, timeoutMs = 30000, } = options;
7546
- const entries = Object.entries(models);
7547
- const progressTracker = createProgressTracker(entries.length);
7548
- const result = {
7549
- totalModels: entries.length,
7550
- downloadedModels: 0,
7551
- skippedModels: 0,
7552
- failedModels: 0,
7553
- totalSize: 0,
7554
- errors: [],
7555
- };
7556
- const emit = () => onProgress?.(progressTracker.getProgress());
7557
- emit();
7558
- if (entries.length === 0)
7559
- return result;
7560
- // Pre-compute existing keys for skipExisting
7561
- const existingKeys = new Set();
7562
- if (skipExisting) {
7563
- const tx = db.transaction('models', 'readonly');
7564
- for await (const cursor of tx.store) {
7565
- existingKeys.add(cursor.value.key);
7566
- }
7567
- }
7568
- await processBatch(entries, async ([modelName, url]) => {
7569
- const key = modelKey(styleId, modelName);
7570
- const label = `${styleId}::${modelName}`;
7571
- if (skipExisting && existingKeys.has(key)) {
7572
- result.skippedModels++;
7573
- progressTracker.update(1, label);
7574
- emit();
7575
- return;
7576
- }
7577
- try {
7578
- const response = await fetchResourceWithRetry(url, {
7579
- retries: maxRetries,
7580
- timeout: timeoutMs,
7581
- proxyType: 'tiles',
7582
- });
7583
- if (response.type === 'json') {
7584
- throw new Error('Unexpected JSON response for model');
7585
- }
7586
- const data = response.data;
7587
- const contentType = ('contentType' in response && response.contentType) || 'model/gltf-binary';
7588
- const entry = {
7589
- key,
7590
- data,
7591
- contentType,
7592
- size: data.byteLength,
7593
- url,
7594
- styleId,
7595
- modelName,
7596
- lastModified: Date.now(),
7597
- downloadedAt: new Date().toISOString(),
7598
- expires: response.expires,
7599
- };
7600
- await db.put('models', entry);
7601
- result.downloadedModels++;
7602
- result.totalSize += data.byteLength;
7603
- progressTracker.update(1, label);
7604
- }
7605
- catch (err) {
7606
- const message = err instanceof Error ? err.message : String(err);
7607
- result.failedModels++;
7608
- result.errors.push({ url, error: message });
7609
- modelLogger.warn(`Failed to download model "${modelName}" from ${url}:`, err);
7610
- progressTracker.update(1, label, message);
7611
- }
7612
- emit();
7613
- }, { batchSize });
7614
- modelLogger.info(`Models downloaded for style ${styleId}: ${result.downloadedModels} new, ${result.skippedModels} skipped, ${result.failedModels} failed`);
7615
- return result;
7616
- }
7617
- /** Retrieve a single model by `{styleId, modelName}`. */
7618
- async getModel(styleId, modelName) {
7619
- const db = await this.db;
7620
- return db.get('models', modelKey(styleId, modelName));
7621
- }
7622
- /** Aggregate stats across all stored models. */
7623
- async getModelStats() {
7624
- const db = await this.db;
7625
- const stats = {
7626
- count: 0,
7627
- totalSize: 0,
7628
- averageSize: 0,
7629
- models: [],
7630
- modelsByStyle: {},
8453
+ },
8454
+ temporal: {
8455
+ oldestGlyph: stats.oldestGlyph,
8456
+ newestGlyph: stats.newestGlyph,
8457
+ ageSpan: stats.oldestGlyph && stats.newestGlyph
8458
+ ? stats.newestGlyph.lastModified - stats.oldestGlyph.lastModified
8459
+ : 0,
8460
+ },
7631
8461
  };
7632
- const tx = db.transaction('models', 'readonly');
7633
- for await (const cursor of tx.store) {
7634
- const m = cursor.value;
7635
- stats.count++;
7636
- stats.totalSize += m.size;
7637
- stats.models.push({ name: m.modelName, size: m.size, lastModified: m.lastModified });
7638
- stats.modelsByStyle[m.styleId] = (stats.modelsByStyle[m.styleId] ?? 0) + 1;
7639
- }
7640
- stats.averageSize = stats.count > 0 ? stats.totalSize / stats.count : 0;
7641
- return stats;
7642
8462
  }
7643
8463
  /**
7644
- * Delete models older than `maxAge` days. Defaults to 30.
8464
+ * Remove glyphs older than the specified age. When `options.styleId` is
8465
+ * provided, only glyphs belonging to that style (per
8466
+ * `resourceKeyBelongsToStyle`) are eligible.
7645
8467
  */
7646
- async cleanupOldModels(maxAge = 30) {
8468
+ async cleanupOldGlyphs(maxAge = 30, options = {}) {
7647
8469
  const db = await this.db;
7648
- const cutoff = Date.now() - maxAge * 24 * 60 * 60 * 1000;
7649
- let deleted = 0;
7650
- const tx = db.transaction('models', 'readwrite');
8470
+ const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
8471
+ const { styleId } = options;
8472
+ let deletedCount = 0;
8473
+ const tx = db.transaction('glyphs', 'readwrite');
7651
8474
  for await (const cursor of tx.store) {
7652
- if (cursor.value.lastModified < cutoff) {
8475
+ const glyphEntry = cursor.value;
8476
+ const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
8477
+ if (belongs && glyphEntry.lastModified < cutoffTime) {
7653
8478
  await cursor.delete();
7654
- deleted++;
8479
+ deletedCount++;
7655
8480
  }
7656
8481
  }
7657
- return deleted;
8482
+ return deletedCount;
7658
8483
  }
7659
- /**
7660
- * Basic integrity check: remove entries with empty/missing data.
7661
- */
7662
- async verifyAndRepairModels() {
8484
+ async verifyAndRepairGlyphs() {
7663
8485
  const db = await this.db;
7664
8486
  let verified = 0;
8487
+ // eslint-disable-next-line prefer-const -- will be incremented when repair logic is added
8488
+ let repaired = 0;
7665
8489
  let removed = 0;
7666
- const tx = db.transaction('models', 'readwrite');
8490
+ const tx = db.transaction('glyphs', 'readwrite');
7667
8491
  for await (const cursor of tx.store) {
7668
- const m = cursor.value;
7669
- if (!m.data || m.data.byteLength === 0) {
8492
+ const glyphEntry = cursor.value;
8493
+ try {
8494
+ // Basic validation
8495
+ if (!glyphEntry.data || glyphEntry.size === 0) {
8496
+ await cursor.delete();
8497
+ removed++;
8498
+ }
8499
+ else {
8500
+ this.validateGlyphData(glyphEntry.data);
8501
+ verified++;
8502
+ }
8503
+ }
8504
+ catch {
8505
+ // Remove corrupted glyphs (they can be re-downloaded)
7670
8506
  await cursor.delete();
7671
8507
  removed++;
7672
8508
  }
7673
- else {
7674
- verified++;
8509
+ }
8510
+ return { verified, repaired, removed };
8511
+ }
8512
+ validateGlyphData(data) {
8513
+ if (data.byteLength < 10) {
8514
+ throw new Error('Glyph data too short');
8515
+ }
8516
+ // Basic Protocol Buffer validation
8517
+ const view = new Uint8Array(data);
8518
+ // Check for valid protobuf structure (basic check)
8519
+ if (view[0] === 0x00 && view[1] === 0x00 && view[2] === 0x00 && view[3] === 0x00) {
8520
+ throw new Error('Invalid glyph data - appears to be empty');
8521
+ }
8522
+ }
8523
+ normalizeFontstack(fontstack) {
8524
+ try {
8525
+ return decodeURIComponent(fontstack);
8526
+ }
8527
+ catch {
8528
+ return fontstack;
8529
+ }
8530
+ }
8531
+ buildGlyphPath(fontstack, range) {
8532
+ const normalizedFontstack = this.normalizeFontstack(fontstack);
8533
+ return `${normalizedFontstack}/${range}.pbf`;
8534
+ }
8535
+ parseGlyphKey(key) {
8536
+ let remaining = key;
8537
+ let styleId;
8538
+ if (remaining.includes('::')) {
8539
+ const [prefix, ...rest] = remaining.split('::');
8540
+ styleId = prefix;
8541
+ remaining = rest.join('::');
8542
+ }
8543
+ else if (remaining.includes(':') && !remaining.includes('/')) {
8544
+ const [prefix, rest] = remaining.split(':');
8545
+ styleId = styleId ?? prefix;
8546
+ remaining = rest;
8547
+ }
8548
+ const trimmed = remaining.endsWith('.pbf') ? remaining.slice(0, -4) : remaining;
8549
+ if (trimmed.includes('/')) {
8550
+ const [fontstack, range] = trimmed.split('/');
8551
+ if (fontstack && range) {
8552
+ return { styleId, fontstack, range };
7675
8553
  }
7676
8554
  }
7677
- return { verified, repaired: 0, removed };
8555
+ if (trimmed.includes('_')) {
8556
+ const [fontstack, range] = trimmed.split('_');
8557
+ if (fontstack && range) {
8558
+ return { styleId, fontstack, range };
8559
+ }
8560
+ }
8561
+ return { styleId, fontstack: trimmed, range: '' };
8562
+ }
8563
+ createGlyphKey(fontstack, range, styleName) {
8564
+ // Create a consistent key from the style name, fontstack and range
8565
+ // Format: stylename::fontstack/range.pbf (if styleName provided) or fontstack/range.pbf
8566
+ const glyphPath = this.buildGlyphPath(fontstack, range);
8567
+ if (styleName) {
8568
+ return `${styleName}::${glyphPath}`;
8569
+ }
8570
+ // Fallback to original format for backward compatibility
8571
+ return glyphPath;
7678
8572
  }
7679
8573
  }
7680
- // Singleton + convenience exports, matching other service modules.
7681
- const modelService = new ModelService();
7682
- const downloadModels = (models, styleId, options) => modelService.downloadModels(models, styleId, options);
7683
- const getModel = (styleId, modelName) => modelService.getModel(styleId, modelName);
7684
- const getModelStats = () => modelService.getModelStats();
7685
- const cleanupOldModels = (maxAge) => modelService.cleanupOldModels(maxAge);
7686
- const verifyAndRepairModels = () => modelService.verifyAndRepairModels();
8574
+ // Export singleton instance and functions for backward compatibility
8575
+ const glyphService = new GlyphService();
8576
+ const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => glyphService.downloadGlyphs(glyphUrl, fontstacks, styleName, ranges, options);
8577
+ const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
8578
+ const getGlyphStats = () => glyphService.getGlyphStats();
8579
+ const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
8580
+ const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
8581
+ const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
7687
8582
 
7688
- var modelService$1 = /*#__PURE__*/Object.freeze({
8583
+ var glyphService$1 = /*#__PURE__*/Object.freeze({
7689
8584
  __proto__: null,
7690
- ModelService: ModelService,
7691
- cleanupOldModels: cleanupOldModels,
7692
- downloadModels: downloadModels,
7693
- getModel: getModel,
7694
- getModelStats: getModelStats,
7695
- modelKeyBelongsToStyle: modelKeyBelongsToStyle,
7696
- modelService: modelService,
7697
- verifyAndRepairModels: verifyAndRepairModels
8585
+ GlyphService: GlyphService,
8586
+ cleanupOldGlyphs: cleanupOldGlyphs,
8587
+ downloadGlyphs: downloadGlyphs,
8588
+ getGlyphAnalytics: getGlyphAnalytics,
8589
+ getGlyphStats: getGlyphStats,
8590
+ glyphService: glyphService,
8591
+ loadGlyphs: loadGlyphs,
8592
+ verifyAndRepairGlyphs: verifyAndRepairGlyphs
7698
8593
  });
7699
8594
 
7700
8595
  class ResourceService {
@@ -7794,23 +8689,26 @@ class AnalyticsService {
7794
8689
  return getGlyphStats();
7795
8690
  }
7796
8691
  async getComprehensiveStorageAnalytics(getRegionAnalytics) {
7797
- const [tileStats, fontStats, spriteStats, glyphStats, regionAnalytics] = await Promise.all([
8692
+ const [tileStats, fontStats, spriteStats, glyphStats, modelStats, styleStats, regionAnalytics] = await Promise.all([
7798
8693
  this.getAllTileStats(),
7799
8694
  this.getAllFontStats(),
7800
8695
  this.getAllSpriteStats(),
7801
8696
  this.getAllGlyphStats(),
8697
+ getModelStats(),
8698
+ getStyleStats(),
7802
8699
  getRegionAnalytics(),
7803
8700
  ]);
7804
- const totalStorageSize = (tileStats.totalSize || 0) +
7805
- (fontStats.totalSize || 0) +
7806
- (spriteStats.totalSize || 0) +
7807
- (glyphStats.totalSize || 0);
7808
8701
  const storageByType = {
8702
+ // The style documents themselves, which the panel's per-style totals include.
8703
+ styles: styleStats.totalSize || 0,
7809
8704
  tiles: tileStats.totalSize || 0,
7810
8705
  fonts: fontStats.totalSize || 0,
7811
8706
  sprites: spriteStats.totalSize || 0,
7812
8707
  glyphs: glyphStats.totalSize || 0,
8708
+ // Mapbox Standard 3D models (.glb) — often several MB each.
8709
+ models: modelStats.totalSize || 0,
7813
8710
  };
8711
+ const totalStorageSize = Object.values(storageByType).reduce((sum, size) => sum + size, 0);
7814
8712
  const recommendations = this.generateStorageRecommendations({
7815
8713
  tiles: tileStats,
7816
8714
  fonts: fontStats,
@@ -7824,6 +8722,7 @@ class AnalyticsService {
7824
8722
  fonts: fontStats,
7825
8723
  sprites: spriteStats,
7826
8724
  glyphs: glyphStats,
8725
+ models: modelStats,
7827
8726
  regions: regionAnalytics,
7828
8727
  totalStorageSize,
7829
8728
  storageByType,
@@ -7955,6 +8854,53 @@ function buildVectorJsonMetadata(style, sourceIds) {
7955
8854
  return JSON.stringify({ vector_layers: merged });
7956
8855
  }
7957
8856
  const serviceLogger = logger.scope('ImportExportService');
8857
+ /** Map stored tile extensions to MBTiles 1.3 `format` values (`pbf`, `jpg`, ...). */
8858
+ function normalizeMBTilesFormat(ext) {
8859
+ const lower = ext.toLowerCase();
8860
+ if (lower === 'mvt')
8861
+ return 'pbf';
8862
+ if (lower === 'jpeg')
8863
+ return 'jpg';
8864
+ return lower;
8865
+ }
8866
+ /**
8867
+ * MBTiles holds one tile per z/x/y, so tiles from several style sources can't
8868
+ * share a file — they'd overwrite each other at every shared coordinate.
8869
+ * Keep only `sourceId` when given, otherwise the source with the most tiles
8870
+ * (the basemap in practice), and warn about what was left out.
8871
+ */
8872
+ function selectSingleSource(tiles, sourceId) {
8873
+ const bySource = new Map();
8874
+ for (const tile of tiles) {
8875
+ const group = bySource.get(tile.sourceId);
8876
+ if (group)
8877
+ group.push(tile);
8878
+ else
8879
+ bySource.set(tile.sourceId, [tile]);
8880
+ }
8881
+ if (sourceId !== undefined) {
8882
+ const selected = bySource.get(sourceId);
8883
+ if (!selected) {
8884
+ const available = [...bySource.keys()].join(', ') || 'none';
8885
+ throw new Error(`Source "${sourceId}" has no tiles in this region (available: ${available})`);
8886
+ }
8887
+ return selected;
8888
+ }
8889
+ if (bySource.size <= 1)
8890
+ return tiles;
8891
+ let chosen = '';
8892
+ let chosenTiles = [];
8893
+ for (const [id, group] of bySource) {
8894
+ if (group.length > chosenTiles.length) {
8895
+ chosen = id;
8896
+ chosenTiles = group;
8897
+ }
8898
+ }
8899
+ const skipped = [...bySource.keys()].filter(id => id !== chosen);
8900
+ serviceLogger.warn(`Region has tiles from ${bySource.size} sources; MBTiles holds one. Exporting "${chosen}", ` +
8901
+ `skipping ${skipped.join(', ')}. Pass { sourceId } to choose another.`);
8902
+ return chosenTiles;
8903
+ }
7958
8904
  class ImportExportService {
7959
8905
  db = dbPromise;
7960
8906
  constructor() {
@@ -7979,10 +8925,11 @@ class ImportExportService {
7979
8925
  if (!region) {
7980
8926
  throw new Error(`Region ${regionId} not found`);
7981
8927
  }
7982
- const tiles = await this.exportTiles(regionId, onProgress);
7983
- // Pick format: caller override → region.tileExtension → default pbf.
7984
- // Drives both the metadata row and whether tile bytes get gzipped.
7985
- const format = String(options.format || region.tileExtension || 'pbf').toLowerCase();
8928
+ const regionTiles = await this.exportTiles(region, onProgress);
8929
+ const tiles = selectSingleSource(regionTiles, options.sourceId);
8930
+ // Pick format: caller override → stored tile extension → region.tileExtension
8931
+ // → default pbf. Drives both the metadata row and whether tile bytes get gzipped.
8932
+ const format = normalizeMBTilesFormat(String(options.format || tiles[0]?.format || region.tileExtension || 'pbf'));
7986
8933
  const isVector = VECTOR_FORMATS.has(format);
7987
8934
  onProgress({
7988
8935
  stage: 'processing',
@@ -8207,11 +9154,19 @@ class ImportExportService {
8207
9154
  /**
8208
9155
  * Export tiles data
8209
9156
  */
8210
- async exportTiles(regionId, onProgress) {
9157
+ async exportTiles(region, onProgress) {
8211
9158
  const db = await this.db;
8212
- // First, find the styleId for this region
8213
- const region = await this.getRegionMetadata(regionId);
8214
- const styleId = region?.styleId || regionId;
9159
+ const styleId = region.styleId || region.id;
9160
+ // Tiles are stored per style, not per region. When the style holds other
9161
+ // regions too, keep only tiles inside this region's bounds and the zooms
9162
+ // the download pipeline fetches for each source (which can fall outside
9163
+ // the region's own range — see sourceZoomRange), or sibling regions' tiles
9164
+ // leak into the export. A sole region owns all of its style's tiles — and
9165
+ // an imported region's bounds come from MBTiles metadata, which can be
9166
+ // tighter than the tiles — so don't filter then.
9167
+ const styleEntry = await db.get('styles', styleId);
9168
+ const sharesStyle = (styleEntry?.regions?.length ?? 0) > 1;
9169
+ const styleSources = (styleEntry?.style?.sources ?? {});
8215
9170
  const transaction = db.transaction(['tiles'], 'readonly');
8216
9171
  const store = transaction.objectStore('tiles');
8217
9172
  const tiles = [];
@@ -8220,22 +9175,31 @@ class ImportExportService {
8220
9175
  let processed = 0;
8221
9176
  while (cursor) {
8222
9177
  const tile = cursor.value;
8223
- // Filter tiles by the region's styleId
8224
- if (tile.styleId === styleId) {
9178
+ const parsed = parseTileKey(String(tile.key));
9179
+ const z = tile.z ?? parsed?.z;
9180
+ const x = tile.x ?? parsed?.x;
9181
+ const y = tile.y ?? parsed?.y;
9182
+ if (tile.styleId === styleId &&
9183
+ z !== undefined &&
9184
+ x !== undefined &&
9185
+ y !== undefined &&
9186
+ (!sharesStyle ||
9187
+ tileBelongsToRegion({ sourceId: tile.sourceId ?? parsed?.sourceId, z, x, y }, region, styleSources))) {
8225
9188
  tiles.push({
8226
- z: tile.z ?? 0, // Handle optional z
8227
- x: tile.x ?? 0, // Handle optional x
8228
- y: tile.y ?? 0, // Handle optional y
9189
+ z,
9190
+ x,
9191
+ y,
8229
9192
  data: tile.data,
8230
- format: 'pbf', // TileEntry doesn't have format, use default
8231
- sourceId: tile.sourceId ?? 'default', // Handle optional sourceId
9193
+ format: (parsed?.ext || 'pbf').toLowerCase(),
9194
+ sourceId: tile.sourceId ?? parsed?.sourceId ?? 'default',
8232
9195
  });
8233
9196
  }
8234
9197
  processed++;
8235
9198
  if (onProgress && processed % 100 === 0) {
8236
9199
  onProgress({
8237
9200
  stage: 'exporting',
8238
- percentage: 30 + (processed / 1000) * 40, // Rough estimation
9201
+ // Total is unknown while walking the cursor; approach 70% asymptotically.
9202
+ percentage: 30 + 40 * (1 - 1 / (1 + processed / 1000)),
8239
9203
  message: `Exported ${processed} tiles...`,
8240
9204
  currentItem: `${tile.z ?? 0}/${tile.x ?? 0}/${tile.y ?? 0}`,
8241
9205
  completedItems: processed,
@@ -8416,7 +9380,7 @@ class ImportExportService {
8416
9380
  sourceId,
8417
9381
  downloadedAt: new Date().toISOString(),
8418
9382
  size: tile.data instanceof ArrayBuffer ? tile.data.byteLength : 0,
8419
- type: 'vector',
9383
+ type: VECTOR_FORMATS.has(ext) ? 'vector' : 'raster',
8420
9384
  url: `tile://${tile.z}/${tile.x}/${tile.y}`,
8421
9385
  lastModified: Date.now(),
8422
9386
  });
@@ -8460,7 +9424,7 @@ const createRegionManagement = (services) => ({
8460
9424
  addRegion: async (region) => services.regionService.addRegion(region),
8461
9425
  downloadRegion: async (region, options) => services.regionService.downloadRegion(region, options),
8462
9426
  loadRegion: async (region, options) => services.regionService.loadRegion(region, options),
8463
- deleteRegion: async (regionId) => services.regionService.deleteRegion(regionId),
9427
+ deleteRegion: async (regionId, styleId) => services.regionService.deleteRegion(regionId, styleId),
8464
9428
  listRegions: async () => services.regionService.listRegions(),
8465
9429
  listStoredRegions: async () => services.regionService.listStoredRegions(),
8466
9430
  getStoredRegion: async (regionId) => {
@@ -8482,7 +9446,7 @@ const createCleanupManagement = (services) => ({
8482
9446
  let deletedCount = 0;
8483
9447
  for (const region of allRegions) {
8484
9448
  if (region.expiry && region.expiry < now) {
8485
- await services.regionService.deleteRegion(region.id);
9449
+ await services.regionService.deleteRegion(region.id, region.styleId);
8486
9450
  deletedCount++;
8487
9451
  }
8488
9452
  }
@@ -8933,7 +9897,7 @@ const en = {
8933
9897
  'language.ar': 'Arabic',
8934
9898
  // Header
8935
9899
  'header.title': 'Offline Manager',
8936
- 'header.subtitle': '{{count}} regions • {{size}} total',
9900
+ 'header.subtitle': '{{regions}} • {{size}} total',
8937
9901
  // Actions
8938
9902
  'actions.addRegion': 'Add Region',
8939
9903
  'actions.refresh': 'Refresh',
@@ -8949,6 +9913,8 @@ const en = {
8949
9913
  'regionForm.namePlaceholder': 'e.g., Downtown District',
8950
9914
  'regionForm.minZoom': 'Min Zoom',
8951
9915
  'regionForm.maxZoom': 'Max Zoom',
9916
+ 'regionForm.buildingsHint': "Mapbox Standard's buildings start at zoom 15. Set Max Zoom to 15 or more to include them.",
9917
+ 'regionForm.zoomRangeError': 'Max Zoom must be at least Min Zoom.',
8952
9918
  'regionForm.styleUrl': 'Style URL',
8953
9919
  'regionForm.provider': 'Style Provider',
8954
9920
  'regionForm.providerAuto': 'Auto-detect',
@@ -8978,7 +9944,10 @@ const en = {
8978
9944
  'regionList.downloaded': 'Downloaded',
8979
9945
  'regionList.size': 'Size',
8980
9946
  'regionList.tiles': 'Tiles',
9947
+ 'regionList.resources': 'Fonts, icons and models',
8981
9948
  'regionList.styleData': 'Style',
9949
+ 'regionList.regionCountOne': '1 region',
9950
+ 'regionList.regionCount': '{{count}} regions',
8982
9951
  'regionList.totalSize': 'Total',
8983
9952
  'regionList.styleId': 'Style ID',
8984
9953
  // Region Details
@@ -9064,6 +10033,13 @@ const en = {
9064
10033
  'mbtiles.importFailed': 'Import failed. Please try again.',
9065
10034
  // Active Downloads
9066
10035
  'download.activeCount': 'Active Downloads ({{count}})',
10036
+ 'download.status.style': 'Preparing style',
10037
+ 'download.status.sprites': 'Sprites: {{completed}} of {{total}}',
10038
+ 'download.status.glyphs': 'Fonts: {{completed}} of {{total}}',
10039
+ 'download.status.models': '3D models: {{completed}} of {{total}}',
10040
+ 'download.status.tiles': 'Tiles: {{completed}} of {{total}}',
10041
+ 'download.status.planning': 'Planning tiles',
10042
+ 'download.status.saving': 'Saving region',
9067
10043
  // Panel Manager additional strings
9068
10044
  'panel.downloadedRegions': 'Downloaded Regions',
9069
10045
  'panel.noExpiry': 'No expiry',
@@ -9085,6 +10061,8 @@ const en = {
9085
10061
  'control.saveSelectedRegion': 'Save Selected Region',
9086
10062
  // Alerts/Messages
9087
10063
  'alert.unknownError': 'Unknown error occurred',
10064
+ 'alert.downloadFailed': 'Download failed',
10065
+ 'alert.needsMapboxGl': 'This style uses Mapbox GL JS features, such as 3D models or style config, so only a Mapbox GL map can show it.',
9088
10066
  'alert.downloadNotImplemented': 'Download feature not implemented in this refactored version',
9089
10067
  'alert.mapNotAvailable': 'Map not available',
9090
10068
  'alert.invalidBounds': 'Invalid bounds for region',
@@ -9141,7 +10119,7 @@ const ar = {
9141
10119
  'language.ar': 'العربية',
9142
10120
  // Header - الرأس
9143
10121
  'header.title': 'مدير الخرائط غير المتصلة',
9144
- 'header.subtitle': '{{count}} منطقة • {{size}} إجمالي',
10122
+ 'header.subtitle': '{{regions}} • {{size}} إجمالي',
9145
10123
  // Actions - الإجراءات
9146
10124
  'actions.addRegion': 'إضافة منطقة',
9147
10125
  'actions.refresh': 'تحديث',
@@ -9157,6 +10135,8 @@ const ar = {
9157
10135
  'regionForm.namePlaceholder': 'مثال: وسط المدينة',
9158
10136
  'regionForm.minZoom': 'أدنى تكبير',
9159
10137
  'regionForm.maxZoom': 'أقصى تكبير',
10138
+ 'regionForm.buildingsHint': 'تبدأ مباني Mapbox Standard عند مستوى التكبير 15. اضبط أقصى تكبير على 15 أو أكثر لتضمينها.',
10139
+ 'regionForm.zoomRangeError': 'يجب ألا يقل أقصى تكبير عن أدنى تكبير.',
9160
10140
  'regionForm.styleUrl': 'رابط النمط',
9161
10141
  'regionForm.provider': 'مزود النمط',
9162
10142
  'regionForm.providerAuto': 'اكتشاف تلقائي',
@@ -9186,7 +10166,10 @@ const ar = {
9186
10166
  'regionList.downloaded': 'تم التحميل',
9187
10167
  'regionList.size': 'الحجم',
9188
10168
  'regionList.tiles': 'البلاطات',
10169
+ 'regionList.resources': 'الخطوط والرموز والنماذج',
9189
10170
  'regionList.styleData': 'النمط',
10171
+ 'regionList.regionCountOne': 'منطقة واحدة',
10172
+ 'regionList.regionCount': '{{count}} منطقة',
9190
10173
  'regionList.totalSize': 'الإجمالي',
9191
10174
  'regionList.styleId': 'معرف النمط',
9192
10175
  // Region Details - تفاصيل المنطقة
@@ -9272,6 +10255,13 @@ const ar = {
9272
10255
  'mbtiles.importFailed': 'فشل الاستيراد. يرجى المحاولة مرة أخرى.',
9273
10256
  // Active Downloads - التحميلات النشطة
9274
10257
  'download.activeCount': 'التحميلات النشطة ({{count}})',
10258
+ 'download.status.style': 'جارٍ تجهيز النمط',
10259
+ 'download.status.sprites': 'الرموز: {{completed}} من {{total}}',
10260
+ 'download.status.glyphs': 'الخطوط: {{completed}} من {{total}}',
10261
+ 'download.status.models': 'النماذج ثلاثية الأبعاد: {{completed}} من {{total}}',
10262
+ 'download.status.tiles': 'البلاطات: {{completed}} من {{total}}',
10263
+ 'download.status.planning': 'جارٍ تخطيط البلاطات',
10264
+ 'download.status.saving': 'جارٍ حفظ المنطقة',
9275
10265
  // Panel Manager additional strings - سلاسل إضافية للوحة
9276
10266
  'panel.downloadedRegions': 'المناطق المحملة',
9277
10267
  'panel.noExpiry': 'بدون انتهاء',
@@ -9293,6 +10283,8 @@ const ar = {
9293
10283
  'control.saveSelectedRegion': 'حفظ المنطقة المحددة',
9294
10284
  // Alerts/Messages - التنبيهات/الرسائل
9295
10285
  'alert.unknownError': 'حدث خطأ غير معروف',
10286
+ 'alert.downloadFailed': 'فشل التحميل',
10287
+ 'alert.needsMapboxGl': 'يستخدم هذا النمط ميزات Mapbox GL JS، مثل النماذج ثلاثية الأبعاد أو إعدادات النمط، لذا لا يمكن عرضه إلا على خريطة Mapbox GL.',
9296
10288
  'alert.downloadNotImplemented': 'ميزة التحميل غير مطبقة في هذه النسخة',
9297
10289
  'alert.mapNotAvailable': 'الخريطة غير متاحة',
9298
10290
  'alert.invalidBounds': 'حدود غير صالحة للمنطقة',
@@ -9427,6 +10419,9 @@ const i18n = new I18nManager();
9427
10419
  const t = (key, replacements) => {
9428
10420
  return i18n.t(key, replacements);
9429
10421
  };
10422
+ // Re-export i18next for advanced usage
10423
+ /** "1 region" or "3 regions", in the current language. */
10424
+ const regionCountLabel = (count) => count === 1 ? t('regionList.regionCountOne') : t('regionList.regionCount', { count });
9430
10425
 
9431
10426
  /**
9432
10427
  * Base Component Class
@@ -9647,7 +10642,7 @@ class Button extends BaseComponent {
9647
10642
  createProgressBadge() {
9648
10643
  this.progressBadge = document.createElement('span');
9649
10644
  this.progressBadge.className =
9650
- 'absolute -top-1 -right-1 bg-blue-500 text-white rounded-full px-2 py-1 text-xs font-bold hidden min-w-4 text-center shadow-md';
10645
+ 'progress-badge absolute top-1/2 right-full mr-1.5 -translate-y-1/2 whitespace-nowrap bg-blue-600 text-white rounded-md px-1.5 py-0.5 text-xs font-semibold shadow-md pointer-events-none hidden';
9651
10646
  // Accessibility: badge ID for aria-describedby
9652
10647
  const badgeId = `btn-badge-${Date.now()}`;
9653
10648
  this.progressBadge.id = badgeId;
@@ -9664,6 +10659,20 @@ class Button extends BaseComponent {
9664
10659
  else {
9665
10660
  this.element.textContent = text;
9666
10661
  }
10662
+ this.restoreProgressBadge();
10663
+ }
10664
+ /**
10665
+ * Replace the button's content, keeping its progress badge.
10666
+ */
10667
+ setContent(html) {
10668
+ this.element.innerHTML = html;
10669
+ this.restoreProgressBadge();
10670
+ }
10671
+ /** Replacing the button's content removes the badge, so put it back. */
10672
+ restoreProgressBadge() {
10673
+ if (this.progressBadge) {
10674
+ this.element.appendChild(this.progressBadge);
10675
+ }
9667
10676
  }
9668
10677
  /**
9669
10678
  * Update button state
@@ -9759,7 +10768,7 @@ class ButtonManager {
9759
10768
  * Reset button to default state
9760
10769
  */
9761
10770
  resetToDefault() {
9762
- this.button.getElement().innerHTML = icons.cloud({ size: 20, color: 'black' });
10771
+ this.button.setContent(icons.cloud({ size: 20, color: 'black' }));
9763
10772
  this.button.setDisabled(false);
9764
10773
  this.button.getElement().title = 'Offline Map Manager';
9765
10774
  this.button.hideProgressBadge();
@@ -9872,8 +10881,7 @@ class Modal extends BaseComponent {
9872
10881
  titleSection.className = 'flex flex-col';
9873
10882
  if (this.config.title) {
9874
10883
  const title = document.createElement('h2');
9875
- title.className =
9876
- 'text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-300';
10884
+ title.className = 'text-xl font-semibold text-gray-900 dark:text-white';
9877
10885
  title.textContent = this.config.title;
9878
10886
  titleSection.appendChild(title);
9879
10887
  }
@@ -10052,14 +11060,14 @@ class RegionDetailsModal {
10052
11060
  }
10053
11061
  content.innerHTML = `
10054
11062
  <div class="glass-input p-4 rounded-xl border-0 bg-gray-50/50 dark:bg-gray-800/50">
10055
- <h3 class="m-0 text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-300">
11063
+ <h3 class="m-0 text-xl font-semibold text-gray-900 dark:text-white">
10056
11064
  ${region.name}
10057
11065
  </h3>
10058
11066
  </div>
10059
11067
 
10060
11068
  <div class="grid grid-cols-2 gap-4">
10061
11069
  <div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
10062
- <label class="block mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
11070
+ <label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
10063
11071
  ${t('regionDetails.bounds')}
10064
11072
  </label>
10065
11073
  <div class="text-sm font-mono text-gray-700 dark:text-gray-300 leading-relaxed">
@@ -10069,7 +11077,7 @@ class RegionDetailsModal {
10069
11077
  </div>
10070
11078
 
10071
11079
  <div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
10072
- <label class="block mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
11080
+ <label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
10073
11081
  ${t('regionDetails.zoomRange')}
10074
11082
  </label>
10075
11083
  <div class="text-lg font-semibold text-gray-900 dark:text-white">
@@ -10081,7 +11089,7 @@ class RegionDetailsModal {
10081
11089
  ${region.created
10082
11090
  ? `
10083
11091
  <div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
10084
- <label class="block mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
11092
+ <label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
10085
11093
  ${t('regionDetails.created')}
10086
11094
  </label>
10087
11095
  <div class="text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -10836,11 +11844,13 @@ class PanelRenderer extends BaseComponent {
10836
11844
  headerContainer;
10837
11845
  actionButtonsContainer;
10838
11846
  regionsList;
11847
+ awaitingProgressRender = false;
10839
11848
  downloadProgressContainer;
10840
11849
  languageSelector;
10841
11850
  // Debounce mechanism
10842
11851
  refreshTimeout;
10843
11852
  isRefreshing = false;
11853
+ refreshQueued = false;
10844
11854
  // Language change unsubscribe function
10845
11855
  unsubscribeLanguage;
10846
11856
  // Stored event handler for cleanup
@@ -10909,7 +11919,7 @@ class PanelRenderer extends BaseComponent {
10909
11919
  titleSection.innerHTML = `
10910
11920
  <h2 class="text-xl font-semibold text-gray-900 dark:text-white">${t('header.title')}</h2>
10911
11921
  <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
10912
- ${t('header.subtitle', { count: regions.length, size: formatBytes(analytics.totalStorageSize) })}
11922
+ ${t('header.subtitle', { regions: regionCountLabel(regions.length), size: formatBytes(analytics.totalStorageSize) })}
10913
11923
  </p>
10914
11924
  `;
10915
11925
  // Actions section
@@ -10983,6 +11993,7 @@ class PanelRenderer extends BaseComponent {
10983
11993
  this.element.removeChild(this.downloadProgressContainer);
10984
11994
  this.downloadProgressContainer = undefined;
10985
11995
  }
11996
+ this.awaitingProgressRender = false;
10986
11997
  // Only show if there are active downloads
10987
11998
  if (this.downloadManager?.hasActiveDownloads()) {
10988
11999
  this.downloadProgressContainer = document.createElement('div');
@@ -11005,9 +12016,10 @@ class PanelRenderer extends BaseComponent {
11005
12016
  }
11006
12017
  try {
11007
12018
  // Load styles from IndexedDB and get stats
11008
- const { loadStyles, getStyleStats } = await Promise.resolve().then(function () { return styleService; });
12019
+ const { loadStyles, getStyleStats, getStyleStorageSizes } = await Promise.resolve().then(function () { return styleService; });
11009
12020
  const styles = await loadStyles();
11010
12021
  const statsResult = await getStyleStats();
12022
+ const storageSizes = await getStyleStorageSizes();
11011
12023
  panelLogger.debug('Loaded styles:', styles);
11012
12024
  panelLogger.debug('Style stats:', statsResult);
11013
12025
  // Build style size map from stats
@@ -11030,12 +12042,14 @@ class PanelRenderer extends BaseComponent {
11030
12042
  for (const style of styles) {
11031
12043
  const styleRegions = regionsByStyle[style.key] || [];
11032
12044
  const styleJsonSize = styleSizeMap[style.key] || 0;
11033
- const regionTilesSize = styleRegions.reduce((sum, r) => sum + (r.tileSize || 0), 0);
11034
- const totalSize = styleJsonSize + regionTilesSize;
12045
+ const regionTilesSize = storageSizes[style.key]?.tiles ?? 0;
12046
+ const resourcesSize = storageSizes[style.key]?.resources ?? 0;
12047
+ const totalSize = styleJsonSize + regionTilesSize + resourcesSize;
11035
12048
  // Add style item with computed sizes
11036
12049
  const styleWithSizes = {
11037
12050
  ...style,
11038
12051
  styleJsonSize,
12052
+ resourcesSize,
11039
12053
  regionTilesSize,
11040
12054
  totalSize,
11041
12055
  };
@@ -11151,7 +12165,7 @@ class PanelRenderer extends BaseComponent {
11151
12165
  <div class="text-xs text-slate-500 dark:text-slate-400 mt-1">
11152
12166
  ${region.bounds ? `<span class="font-medium">SW:</span> ${region.bounds[0][1].toFixed(4)}, ${region.bounds[0][0].toFixed(4)} <span class="mx-1">→</span> <span class="font-medium">NE:</span> ${region.bounds[1][1].toFixed(4)}, ${region.bounds[1][0].toFixed(4)}` : t('panel.noBounds')}
11153
12167
  </div>
11154
- <div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500 mt-2">
12168
+ <div class="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400 mt-2">
11155
12169
  <span>${t('regionList.zoom')}: ${region.minZoom}-${region.maxZoom}</span>
11156
12170
  ${region.downloadedAt ? `<span>${t('regionList.downloaded')}: ${new Date(region.downloadedAt).toLocaleDateString()}</span>` : ''}
11157
12171
  ${region.size ? `<span>${t('regionList.size')}: ${formatBytes(region.size)}</span>` : ''}
@@ -11174,16 +12188,16 @@ class PanelRenderer extends BaseComponent {
11174
12188
  <div class="flex items-center justify-between">
11175
12189
  <div class="flex-1">
11176
12190
  <h3 class="font-semibold text-slate-800 dark:text-slate-100 text-base">
11177
- ${escapeHtml$1(style.style?.name || style.key || t('style.unnamedStyle'))}
12191
+ ${escapeHtml$1(styleDisplayName(style))}
11178
12192
  </h3>
11179
12193
  <p class="text-sm text-slate-600 dark:text-slate-300 mt-1">
11180
12194
  ${t('regionList.styleId')}: ${escapeHtml$1(style.key)}
11181
12195
  </p>
11182
12196
  <p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
11183
- ${regionCount} region${regionCount === 1 ? '' : 's'} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
12197
+ ${regionCountLabel(regionCount)} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
11184
12198
  </p>
11185
- <p class="text-xs text-slate-400 dark:text-slate-500 mt-1">
11186
- ${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
12199
+ <p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
12200
+ ${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.resources')}: ${formatBytes(style.resourcesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
11187
12201
  </p>
11188
12202
  </div>
11189
12203
  <div class="flex items-center gap-2">
@@ -11217,7 +12231,7 @@ class PanelRenderer extends BaseComponent {
11217
12231
  <div class="text-xs text-slate-500 dark:text-slate-400 mt-1">
11218
12232
  ${region.bounds ? `<span class="font-medium">SW:</span> ${region.bounds[0][1].toFixed(4)}, ${region.bounds[0][0].toFixed(4)} <span class="mx-1">→</span> <span class="font-medium">NE:</span> ${region.bounds[1][1].toFixed(4)}, ${region.bounds[1][0].toFixed(4)}` : t('panel.noBounds')}
11219
12233
  </div>
11220
- <div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500 mt-2">
12234
+ <div class="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400 mt-2">
11221
12235
  <span>${t('regionList.zoom')}: ${region.minZoom}-${region.maxZoom}</span>
11222
12236
  ${region.downloadedAt ? `<span>${t('regionList.downloaded')}: ${new Date(region.downloadedAt).toLocaleDateString()}</span>` : ''}
11223
12237
  ${region.tileSize ? `<span>${t('regionList.tiles')}: ${formatBytes(region.tileSize)}</span>` : ''}
@@ -11256,16 +12270,16 @@ class PanelRenderer extends BaseComponent {
11256
12270
  <div class="flex items-center justify-between">
11257
12271
  <div class="flex-1">
11258
12272
  <h3 class="font-semibold text-blue-900 dark:text-blue-100 text-base">
11259
- ${escapeHtml$1(style.style?.name || style.key || t('style.unnamedStyle'))}
12273
+ ${escapeHtml$1(styleDisplayName(style))}
11260
12274
  </h3>
11261
12275
  <p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
11262
12276
  ${t('regionList.styleId')}: ${escapeHtml$1(style.key)}
11263
12277
  </p>
11264
12278
  <p class="text-sm text-blue-600 dark:text-blue-400 mt-1">
11265
- ${regionCount} region${regionCount === 1 ? '' : 's'} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
12279
+ ${regionCountLabel(regionCount)} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
11266
12280
  </p>
11267
12281
  <p class="text-xs text-blue-500 dark:text-blue-500 mt-1">
11268
- ${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
12282
+ ${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.resources')}: ${formatBytes(style.resourcesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
11269
12283
  </p>
11270
12284
  </div>
11271
12285
  <div class="flex items-center gap-2">
@@ -11305,6 +12319,39 @@ class PanelRenderer extends BaseComponent {
11305
12319
  </div>
11306
12320
  `;
11307
12321
  }
12322
+ /**
12323
+ * Show the latest download progress. Called on every update, so it only
12324
+ * redraws the progress section rather than the whole panel.
12325
+ */
12326
+ updateDownloadProgress(downloads) {
12327
+ if (downloads.size === 0)
12328
+ return;
12329
+ const container = this.downloadProgressContainer;
12330
+ if (container?.isConnected) {
12331
+ container.innerHTML = this.createDownloadProgressHTML(downloads);
12332
+ return;
12333
+ }
12334
+ // The download started while the panel was open, so it has no section yet.
12335
+ if (this.element.isConnected && !this.awaitingProgressRender) {
12336
+ this.awaitingProgressRender = true;
12337
+ void this.refresh();
12338
+ }
12339
+ }
12340
+ /** What the current phase is doing, such as "Tiles: 64 of 128". */
12341
+ describeDownloadPhase(download) {
12342
+ const phase = download.phase;
12343
+ if (phase === 'style')
12344
+ return t('download.status.style');
12345
+ if (phase === 'tiles' && !download.total)
12346
+ return t('download.status.planning');
12347
+ if (phase === 'sprites' || phase === 'glyphs' || phase === 'models' || phase === 'tiles') {
12348
+ return t(`download.status.${phase}`, {
12349
+ completed: Number(download.completed) || 0,
12350
+ total: Number(download.total) || 0,
12351
+ });
12352
+ }
12353
+ return t('download.status.saving');
12354
+ }
11308
12355
  /**
11309
12356
  * Create download progress HTML
11310
12357
  */
@@ -11318,11 +12365,14 @@ class PanelRenderer extends BaseComponent {
11318
12365
  <div class="space-y-2">
11319
12366
  ${downloadArray
11320
12367
  .map(download => `
11321
- <div class="flex items-center justify-between text-sm">
11322
- <span class="text-blue-800 dark:text-blue-200">${escapeHtml$1(download.regionName || download.regionId)}</span>
11323
- <div class="flex items-center gap-2">
11324
- <div class="w-24 h-2 bg-blue-200 dark:bg-blue-800 rounded-full overflow-hidden">
11325
- <div class="h-full bg-blue-600 transition-all duration-300" style="width: ${download.percentage || 0}%"></div>
12368
+ <div class="flex items-center justify-between gap-4 text-sm">
12369
+ <div class="min-w-0">
12370
+ <div class="truncate text-blue-800 dark:text-blue-200">${escapeHtml$1(download.regionName || download.regionId)}</div>
12371
+ <div class="text-xs text-gray-600 dark:text-gray-300">${escapeHtml$1(this.describeDownloadPhase(download))}</div>
12372
+ </div>
12373
+ <div class="flex shrink-0 items-center gap-2">
12374
+ <div class="w-24 h-2 bg-blue-200 dark:bg-blue-800 rounded-full overflow-hidden" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(download.percentage || 0)}">
12375
+ <div class="h-full bg-blue-600" style="width: ${download.percentage || 0}%"></div>
11326
12376
  </div>
11327
12377
  <span class="text-blue-700 dark:text-blue-300 min-w-12">${Math.round(download.percentage || 0)}%</span>
11328
12378
  </div>
@@ -11399,7 +12449,15 @@ class PanelRenderer extends BaseComponent {
11399
12449
  cancelText: t('app.cancel'),
11400
12450
  onConfirm: async () => {
11401
12451
  try {
11402
- await this.offlineManager.deleteRegion(regionId);
12452
+ // Deleting a style's last region deletes the style too.
12453
+ const { loadStyleById } = await Promise.resolve().then(function () { return styleService; });
12454
+ const shownEntry = this.isStyleShown(region.styleId)
12455
+ ? await loadStyleById(region.styleId)
12456
+ : null;
12457
+ await this.offlineManager.deleteRegion(regionId, region.styleId);
12458
+ if (shownEntry && !(await loadStyleById(region.styleId))) {
12459
+ this.showOnlineStyle(shownEntry);
12460
+ }
11403
12461
  this.modalManager.close();
11404
12462
  // Refresh the panel to show updated regions
11405
12463
  await this.refresh();
@@ -11452,7 +12510,7 @@ class PanelRenderer extends BaseComponent {
11452
12510
  }
11453
12511
  // Delete the existing region (and its tiles/resources)
11454
12512
  panelLogger.debug('Deleting region before re-download:', regionId);
11455
- await this.offlineManager.deleteRegion(regionId);
12513
+ await this.offlineManager.deleteRegion(regionId, region.styleId);
11456
12514
  // Prepare form data for re-download
11457
12515
  const formData = {
11458
12516
  name: region.name,
@@ -11673,7 +12731,8 @@ class PanelRenderer extends BaseComponent {
11673
12731
  clearTimeout(this.refreshTimeout);
11674
12732
  }
11675
12733
  if (this.isRefreshing) {
11676
- panelLogger.debug('Refresh already in progress, skipping...');
12734
+ // Run again once this one finishes, or the panel misses what changed meanwhile.
12735
+ this.refreshQueued = true;
11677
12736
  return;
11678
12737
  }
11679
12738
  this.refreshTimeout = setTimeout(async () => {
@@ -11717,6 +12776,10 @@ class PanelRenderer extends BaseComponent {
11717
12776
  }
11718
12777
  finally {
11719
12778
  this.isRefreshing = false;
12779
+ if (this.refreshQueued) {
12780
+ this.refreshQueued = false;
12781
+ void this.refresh();
12782
+ }
11720
12783
  }
11721
12784
  }
11722
12785
  /**
@@ -11854,6 +12917,20 @@ class PanelRenderer extends BaseComponent {
11854
12917
  /**
11855
12918
  * Handle loading a style to the map
11856
12919
  */
12920
+ /** Whether the map is drawing this stored style, through `idb://` or the Service Worker. */
12921
+ isStyleShown(styleKey) {
12922
+ const shown = this.map?.getStyle?.();
12923
+ if (!shown)
12924
+ return false;
12925
+ const text = JSON.stringify(shown);
12926
+ return text.includes(`idb://${styleKey}/`) || text.includes(`/__offline__/${styleKey}/`);
12927
+ }
12928
+ /** After its stored copy is deleted, show a style from its original URL, not a blank map. */
12929
+ showOnlineStyle(entry) {
12930
+ if (!entry.originalUrl)
12931
+ return;
12932
+ this.map?.setStyle?.(entry.originalUrl);
12933
+ }
11857
12934
  async handleLoadStyle(styleData) {
11858
12935
  if (!this.map) {
11859
12936
  panelLogger.warn('Map not available for loading style');
@@ -11872,6 +12949,10 @@ class PanelRenderer extends BaseComponent {
11872
12949
  alert(t('error.invalidStyleData'));
11873
12950
  return;
11874
12951
  }
12952
+ if (needsMapboxGl(styleEntry.style) && !isMapboxGlMap(this.map)) {
12953
+ alert(t('alert.needsMapboxGl'));
12954
+ return;
12955
+ }
11875
12956
  panelLogger.debug('Loading style from IndexedDB:', {
11876
12957
  key: styleEntry.key,
11877
12958
  provider: styleEntry.provider,
@@ -12056,13 +13137,16 @@ class PanelRenderer extends BaseComponent {
12056
13137
  const style = styleData;
12057
13138
  const confirmModal = new ConfirmationModal({
12058
13139
  title: t('delete.styleTitle'),
12059
- message: t('delete.styleMessage', { name: style.style?.name || style.key }),
13140
+ message: t('delete.styleMessage', { name: styleDisplayName(style) }),
12060
13141
  confirmText: t('actions.deleteStyle'),
12061
13142
  cancelText: t('app.cancel'),
12062
13143
  onConfirm: async () => {
12063
13144
  try {
12064
13145
  const { deleteStyleById } = await Promise.resolve().then(function () { return styleService; });
13146
+ const wasShown = this.isStyleShown(style.key);
12065
13147
  await deleteStyleById(style.key);
13148
+ if (wasShown)
13149
+ this.showOnlineStyle(styleData);
12066
13150
  this.modalManager.close();
12067
13151
  // Refresh the panel
12068
13152
  await this.refresh();
@@ -12167,7 +13251,7 @@ class PolygonControl {
12167
13251
  this.saveButton = document.createElement('button');
12168
13252
  this.saveButton.type = 'button';
12169
13253
  const prefix = this.options.cssPrefix || 'maplibregl';
12170
- this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-gradient-to-br from-green-600 to-green-700 border border-green-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:from-green-700 hover:to-green-800 transition-all duration-200`;
13254
+ this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 !bg-green-600 border border-green-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:!bg-green-700 transition-all duration-200`;
12171
13255
  this.saveButton.innerHTML = icons.check({ size: 16, color: 'white' });
12172
13256
  this.saveButton.title = 'Save Selected Region';
12173
13257
  this.saveButton.addEventListener('click', () => this.handleSave());
@@ -12234,6 +13318,33 @@ class PolygonControl {
12234
13318
  handleMapMove = () => {
12235
13319
  this.updateVisualization();
12236
13320
  };
13321
+ /**
13322
+ * The area to select: the middle of what's on screen. On a tilted or rotated map
13323
+ * `getBounds()` reaches far past the view, so insetting it would miss the part
13324
+ * nearest the camera. There, measure from the map's centre to the screen's edges.
13325
+ */
13326
+ getSelectionBbox(minLng, minLat, maxLng, maxLat) {
13327
+ if (!this.map.getPitch() && !this.map.getBearing()) {
13328
+ const lngInset = (maxLng - minLng) * 0.2;
13329
+ const latInset = (maxLat - minLat) * 0.2;
13330
+ return [minLng + lngInset, minLat + latInset, maxLng - lngInset, maxLat - latInset];
13331
+ }
13332
+ const center = this.map.getCenter();
13333
+ const { clientWidth, clientHeight } = this.map.getContainer();
13334
+ const lngScale = Math.cos((center.lat * Math.PI) / 180);
13335
+ const degreesFromCenter = (point) => {
13336
+ const { lng, lat } = this.map.unproject(point);
13337
+ return Math.hypot((lng - center.lng) * lngScale, lat - center.lat);
13338
+ };
13339
+ const halfSize = 0.6 *
13340
+ Math.min(degreesFromCenter([0, clientHeight / 2]), degreesFromCenter([clientWidth / 2, clientHeight]));
13341
+ return [
13342
+ center.lng - halfSize / lngScale,
13343
+ center.lat - halfSize,
13344
+ center.lng + halfSize / lngScale,
13345
+ center.lat + halfSize,
13346
+ ];
13347
+ }
12237
13348
  /**
12238
13349
  * Update polygon visualization based on current map bounds
12239
13350
  */
@@ -12243,9 +13354,7 @@ class PolygonControl {
12243
13354
  return;
12244
13355
  const [minLng, minLat] = bboxArray[0];
12245
13356
  const [maxLng, maxLat] = bboxArray[1];
12246
- const lngDiff = (maxLng - minLng) * 0.2;
12247
- const latDiff = (maxLat - minLat) * 0.2;
12248
- const clippedBbox = [minLng + lngDiff, minLat + latDiff, maxLng - lngDiff, maxLat - latDiff];
13357
+ const clippedBbox = this.getSelectionBbox(minLng, minLat, maxLng, maxLat);
12249
13358
  // Store current bounds for later use
12250
13359
  this.currentBounds = clippedBbox;
12251
13360
  const clippedPolygon = bboxPolygon([
@@ -12312,7 +13421,7 @@ class PolygonControl {
12312
13421
  source: 'selectionPolygon',
12313
13422
  layout: {},
12314
13423
  paint: {
12315
- 'line-color': '#8B5CF6', // Primary purple color
13424
+ 'line-color': '#A8116F', // Revision magenta, the UI's accent
12316
13425
  'line-width': 3,
12317
13426
  'line-dasharray': [2, 2],
12318
13427
  },
@@ -12346,6 +13455,30 @@ class PolygonControl {
12346
13455
  */
12347
13456
  // import { MaplibreMap } from 'maplibre-gl';
12348
13457
  const formLogger = logger.scope('RegionFormModal');
13458
+ /** Mapbox Standard's buildings start at this zoom. */
13459
+ const STANDARD_BUILDINGS_MIN_ZOOM = 15;
13460
+ let fieldCount = 0;
13461
+ /**
13462
+ * Point each label at the one field beside it, so screen readers name the field
13463
+ * and clicking the label focuses it. Groups with several fields are left alone.
13464
+ */
13465
+ function linkLabelsToFields(form) {
13466
+ for (const label of Array.from(form.querySelectorAll('label'))) {
13467
+ const fields = label.parentElement?.querySelectorAll('input, select, textarea');
13468
+ if (label.htmlFor || fields?.length !== 1)
13469
+ continue;
13470
+ const field = fields[0];
13471
+ field.id ||= `map-gl-offline-field-${++fieldCount}`;
13472
+ label.htmlFor = field.id;
13473
+ }
13474
+ }
13475
+ /**
13476
+ * True when a region of Mapbox Standard stops below the zoom where its buildings
13477
+ * start. Standard Satellite has no buildings, so it doesn't count.
13478
+ */
13479
+ function regionMissesBuildings(styleUrl, maxZoom) {
13480
+ return /mapbox\/standard(?![\w-])/.test(styleUrl) && maxZoom < STANDARD_BUILDINGS_MIN_ZOOM;
13481
+ }
12349
13482
  class RegionFormModal {
12350
13483
  options;
12351
13484
  modal;
@@ -12353,6 +13486,7 @@ class RegionFormModal {
12353
13486
  minZoomInput;
12354
13487
  maxZoomInput;
12355
13488
  styleUrlInput;
13489
+ buildingsHint;
12356
13490
  // Enhanced Mapbox GL support
12357
13491
  providerSelect;
12358
13492
  accessTokenInput;
@@ -12403,13 +13537,13 @@ class RegionFormModal {
12403
13537
  'grid grid-cols-2 gap-4 p-4 rounded-xl glass-input border border-gray-100/50 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/50';
12404
13538
  infoGroup.innerHTML = `
12405
13539
  <div class="flex flex-col justify-center">
12406
- <strong class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.area')}</strong>
12407
- <div class="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300">
13540
+ <strong class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.area')}</strong>
13541
+ <div class="text-xl font-semibold text-gray-900 dark:text-white">
12408
13542
  ${area.toLocaleString()} <span class="text-sm font-medium text-gray-500">km²</span>
12409
13543
  </div>
12410
13544
  </div>
12411
13545
  <div>
12412
- <strong class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.bounds')}</strong>
13546
+ <strong class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.bounds')}</strong>
12413
13547
  <div class="text-xs font-mono text-gray-600 dark:text-gray-300 leading-relaxed bg-white/50 dark:bg-black/20 p-2 rounded-lg">
12414
13548
  ${west.toFixed(4)}, ${south.toFixed(4)}<br>
12415
13549
  ${east.toFixed(4)}, ${north.toFixed(4)}
@@ -12465,6 +13599,12 @@ class RegionFormModal {
12465
13599
  zoomGroup.appendChild(minZoomDiv);
12466
13600
  zoomGroup.appendChild(maxZoomDiv);
12467
13601
  form.appendChild(zoomGroup);
13602
+ this.buildingsHint = document.createElement('p');
13603
+ this.buildingsHint.className = 'm-0 text-sm text-amber-700 dark:text-amber-400 hidden';
13604
+ this.buildingsHint.setAttribute('role', 'status');
13605
+ this.buildingsHint.textContent = t('regionForm.buildingsHint');
13606
+ form.appendChild(this.buildingsHint);
13607
+ this.maxZoomInput.addEventListener('input', () => this.updateBuildingsHint());
12468
13608
  // Style URL input
12469
13609
  const styleGroup = document.createElement('div');
12470
13610
  styleGroup.innerHTML = `
@@ -12477,9 +13617,13 @@ class RegionFormModal {
12477
13617
  this.styleUrlInput.value = styleUrl;
12478
13618
  this.styleUrlInput.className =
12479
13619
  'w-full px-4 py-3 rounded-xl text-base glass-input text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500/50 transition-all shadow-sm';
12480
- this.styleUrlInput.addEventListener('input', () => this.handleStyleUrlChange());
13620
+ this.styleUrlInput.addEventListener('input', () => {
13621
+ this.handleStyleUrlChange();
13622
+ this.updateBuildingsHint();
13623
+ });
12481
13624
  styleGroup.appendChild(this.styleUrlInput);
12482
13625
  form.appendChild(styleGroup);
13626
+ this.updateBuildingsHint();
12483
13627
  // Provider selection
12484
13628
  const providerGroup = document.createElement('div');
12485
13629
  providerGroup.innerHTML = `
@@ -12537,6 +13681,7 @@ class RegionFormModal {
12537
13681
  }
12538
13682
  // Initialize provider detection
12539
13683
  this.detectProviderFromUrl();
13684
+ linkLabelsToFields(form);
12540
13685
  return form;
12541
13686
  }
12542
13687
  /**
@@ -12651,6 +13796,11 @@ class RegionFormModal {
12651
13796
  /**
12652
13797
  * Handle style URL changes to auto-detect provider
12653
13798
  */
13799
+ /** Warn when a region would be saved without the style's buildings. */
13800
+ updateBuildingsHint() {
13801
+ const missesBuildings = regionMissesBuildings(this.styleUrlInput?.value ?? '', Number(this.maxZoomInput?.value));
13802
+ this.buildingsHint?.classList.toggle('hidden', !missesBuildings);
13803
+ }
12654
13804
  handleStyleUrlChange() {
12655
13805
  this.detectProviderFromUrl();
12656
13806
  }
@@ -12727,10 +13877,21 @@ class RegionFormModal {
12727
13877
  footer.appendChild(saveButton.getElement());
12728
13878
  return footer;
12729
13879
  }
13880
+ /** Check the zoom fields, showing the browser's message on the first bad one. */
13881
+ zoomRangeIsValid() {
13882
+ const { minZoomInput: min, maxZoomInput: max } = this;
13883
+ if (!min || !max)
13884
+ return true;
13885
+ const reversed = min.value !== '' && max.value !== '' && Number(min.value) > Number(max.value);
13886
+ max.setCustomValidity(reversed ? t('regionForm.zoomRangeError') : '');
13887
+ return min.reportValidity() && max.reportValidity();
13888
+ }
12730
13889
  /**
12731
13890
  * Handle form save
12732
13891
  */
12733
13892
  async handleSave() {
13893
+ if (!this.zoomRangeIsValid())
13894
+ return;
12734
13895
  try {
12735
13896
  const selectedSources = this.getSelectedExtraSources();
12736
13897
  const formData = {
@@ -12847,14 +14008,14 @@ class RegionControl {
12847
14008
  this.cancelButton = document.createElement('button');
12848
14009
  this.cancelButton.type = 'button';
12849
14010
  const prefix = this.options.cssPrefix || 'maplibregl';
12850
- this.cancelButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-gradient-to-br from-red-600 to-red-700 border border-red-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:from-red-700 hover:to-red-800 transition-all duration-200`;
14011
+ this.cancelButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 !bg-red-600 border border-red-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:!bg-red-700 transition-all duration-200`;
12851
14012
  this.cancelButton.innerHTML = icons.x({ size: 16, color: 'white' });
12852
14013
  this.cancelButton.title = 'Cancel Selection';
12853
14014
  this.cancelButton.addEventListener('click', () => this.cancelSelection());
12854
14015
  // Create save button (green checkmark)
12855
14016
  this.saveButton = document.createElement('button');
12856
14017
  this.saveButton.type = 'button';
12857
- this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-gradient-to-br from-green-600 to-green-700 border border-green-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:from-green-700 hover:to-green-800 transition-all duration-200`;
14018
+ this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 !bg-green-600 border border-green-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:!bg-green-700 transition-all duration-200`;
12858
14019
  this.saveButton.innerHTML = icons.check({ size: 16, color: 'white' });
12859
14020
  this.saveButton.title = 'Save Selected Region';
12860
14021
  this.saveButton.addEventListener('click', () => this.handleSaveClick());
@@ -13084,6 +14245,8 @@ class DownloadManager {
13084
14245
  offlineManager;
13085
14246
  /** Map of region IDs to their download progress */
13086
14247
  currentDownloads = new Map();
14248
+ /** Aborts each running download, by region id. */
14249
+ controllers = new Map();
13087
14250
  options;
13088
14251
  /**
13089
14252
  * Create a new DownloadManager instance.
@@ -13098,14 +14261,16 @@ class DownloadManager {
13098
14261
  * Calculates percentage and notifies listeners via the onProgressUpdate callback.
13099
14262
  * @internal
13100
14263
  */
13101
- updateProgress(regionId, regionName, phase, completed, total, currentResource) {
13102
- const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
14264
+ updateProgress(regionId, regionName, phase, completed, total, currentResource, overallPercentage) {
14265
+ const percentage = Math.round(overallPercentage);
14266
+ const phasePercentage = total > 0 ? Math.round((completed / total) * 100) : 0;
13103
14267
  this.currentDownloads.set(regionId, {
13104
14268
  regionId,
13105
14269
  regionName,
13106
14270
  completed,
13107
14271
  total,
13108
14272
  percentage,
14273
+ phasePercentage,
13109
14274
  currentResource,
13110
14275
  phase,
13111
14276
  });
@@ -13140,14 +14305,20 @@ class DownloadManager {
13140
14305
  extraSources: formData.extraSources,
13141
14306
  };
13142
14307
  const regionId = regionConfig.id;
14308
+ const controller = new AbortController();
14309
+ this.controllers.set(regionId, controller);
13143
14310
  this.updateUIForDownloadStart();
13144
14311
  try {
13145
14312
  await this.offlineManager.downloadRegion(regionConfig, {
13146
14313
  provider: formData.provider || 'auto',
13147
14314
  accessToken: formData.accessToken,
14315
+ signal: controller.signal,
13148
14316
  onProgress: progress => {
14317
+ // A cancelled download keeps reporting until it stops; don't show it again.
14318
+ if (controller.signal.aborted)
14319
+ return;
13149
14320
  const uiPhase = progress.phase === 'metadata' ? undefined : progress.phase;
13150
- this.updateProgress(regionId, regionConfig.name, uiPhase, progress.completed, progress.total, progress.message ?? `Downloading ${progress.phase}`);
14321
+ this.updateProgress(regionId, regionConfig.name, uiPhase, progress.completed, progress.total, progress.message ?? `Downloading ${progress.phase}`, progress.overallPercentage);
13151
14322
  },
13152
14323
  tileOptions: {
13153
14324
  maxConcurrency: 10,
@@ -13156,8 +14327,16 @@ class DownloadManager {
13156
14327
  this.handleDownloadComplete(regionId);
13157
14328
  }
13158
14329
  catch (error) {
13159
- downloadLogger.error('Error downloading region:', error);
13160
- this.handleDownloadError(regionId, error);
14330
+ if (controller.signal.aborted) {
14331
+ downloadLogger.debug(`Download of ${regionId} was cancelled`);
14332
+ }
14333
+ else {
14334
+ downloadLogger.error('Error downloading region:', error);
14335
+ this.handleDownloadError(regionId, error);
14336
+ }
14337
+ }
14338
+ finally {
14339
+ this.controllers.delete(regionId);
13161
14340
  }
13162
14341
  }
13163
14342
  /**
@@ -13180,6 +14359,8 @@ class DownloadManager {
13180
14359
  * @param regionId - The ID of the region download to cancel
13181
14360
  */
13182
14361
  cancelDownload(regionId) {
14362
+ // Stops the download; downloadRegion then saves nothing and removes its partial tiles.
14363
+ this.controllers.get(regionId)?.abort();
13183
14364
  this.currentDownloads.delete(regionId);
13184
14365
  if (this.currentDownloads.size === 0) {
13185
14366
  this.resetUI();
@@ -13191,6 +14372,8 @@ class DownloadManager {
13191
14372
  * Clears all download tracking and resets the UI.
13192
14373
  */
13193
14374
  cancelAllDownloads() {
14375
+ for (const controller of this.controllers.values())
14376
+ controller.abort();
13194
14377
  this.currentDownloads.clear();
13195
14378
  this.resetUI();
13196
14379
  this.options.onProgressUpdate?.(this.currentDownloads);
@@ -13340,6 +14523,9 @@ class OfflineManagerControl {
13340
14523
  regionControl;
13341
14524
  downloadManager;
13342
14525
  modalManager = new ModalManager();
14526
+ /** Percentages of this batch's downloads, finished ones at 100, for the badge. */
14527
+ badgeBatch = new Map();
14528
+ badgePercentage = 0;
13343
14529
  /** Whether the bounding box visualization layer has been added to the map */
13344
14530
  bboxLayerAdded = false;
13345
14531
  /** Map library module for protocol registration (null if not provided) */
@@ -13394,8 +14580,11 @@ class OfflineManagerControl {
13394
14580
  this.mapLib.addProtocol('idb', async (params) => {
13395
14581
  const response = await idbFetchHandler(params.url);
13396
14582
  if (!response.ok) {
13397
- controlLogger.warn(`addProtocol: IDB fetch failed for ${params.url} (type=${params.type})`);
13398
- throw new Error(`IDB fetch failed: ${response.statusText}`);
14583
+ // MapLibre treats a 404 as "no tile here", which is normal once the map
14584
+ // moves outside a saved region. Any other error gets logged for every tile.
14585
+ throw Object.assign(new Error(`IDB fetch failed (${response.status}): ${params.url}`), {
14586
+ status: response.status,
14587
+ });
13399
14588
  }
13400
14589
  // MapLibre's makeRequest bypasses JSON parsing for protocol handlers,
13401
14590
  // so we must parse JSON responses ourselves (e.g. sprite atlas, TileJSON).
@@ -13651,43 +14840,27 @@ class OfflineManagerControl {
13651
14840
  * Handle progress updates from download manager
13652
14841
  */
13653
14842
  handleProgressUpdate(downloads) {
13654
- // Update the progress badge with the current download percentage
13655
- if (downloads.size > 0) {
13656
- // Get the first download's info (or average percentage if multiple)
13657
- const firstDownload = downloads.values().next().value;
13658
- let displayText = '';
13659
- if (downloads.size === 1 && firstDownload) {
13660
- // Show phase-specific progress for single download
13661
- const phase = firstDownload.phase;
13662
- const percentage = firstDownload.percentage;
13663
- switch (phase) {
13664
- case 'style':
13665
- displayText = 'Style';
13666
- break;
13667
- case 'sprites':
13668
- displayText = `Spr ${percentage}%`;
13669
- break;
13670
- case 'glyphs':
13671
- displayText = `Gly ${percentage}%`;
13672
- break;
13673
- case 'tiles':
13674
- displayText = `${percentage}%`;
13675
- break;
13676
- default:
13677
- displayText = `${percentage}%`;
13678
- }
13679
- }
13680
- else {
13681
- // Multiple downloads - show average
13682
- let totalPercentage = 0;
13683
- for (const download of downloads.values()) {
13684
- totalPercentage += download.percentage;
13685
- }
13686
- const avgPercentage = Math.round(totalPercentage / downloads.size);
13687
- displayText = `${avgPercentage}%`;
13688
- }
13689
- this.updateProgressBadge(displayText, true);
14843
+ // The panel has the detail for each phase; the badge only has room for one
14844
+ // figure. With several downloads it averages all of this batch's, counting
14845
+ // finished ones as 100, so one finishing doesn't drop the figure, and never
14846
+ // goes back: a download added mid-batch holds it until the average catches up.
14847
+ for (const [regionId, download] of downloads) {
14848
+ this.badgeBatch.set(regionId, download.percentage);
14849
+ }
14850
+ for (const regionId of this.badgeBatch.keys()) {
14851
+ if (!downloads.has(regionId))
14852
+ this.badgeBatch.set(regionId, 100);
14853
+ }
14854
+ if (downloads.size === 0) {
14855
+ this.badgeBatch.clear();
14856
+ this.badgePercentage = 0;
14857
+ }
14858
+ else {
14859
+ const total = [...this.badgeBatch.values()].reduce((sum, value) => sum + value, 0);
14860
+ this.badgePercentage = Math.max(this.badgePercentage, Math.round(total / this.badgeBatch.size));
14861
+ this.updateProgressBadge(`${this.badgePercentage}%`, true);
13690
14862
  }
14863
+ this.panelRenderer?.updateDownloadProgress(downloads);
13691
14864
  }
13692
14865
  /**
13693
14866
  * Handle download completion
@@ -13703,6 +14876,7 @@ class OfflineManagerControl {
13703
14876
  */
13704
14877
  handleDownloadError(regionId, error) {
13705
14878
  controlLogger.error(`Download error for region ${regionId}:`, error);
14879
+ alert(`${t('alert.downloadFailed')}: ${error instanceof Error ? error.message : t('alert.unknownError')}`);
13706
14880
  // Use the panel's refresh method instead of renderPanel to avoid conflicts
13707
14881
  if (this.panelRenderer) {
13708
14882
  this.panelRenderer.refresh();
@@ -13846,7 +15020,7 @@ class OfflineManagerControl {
13846
15020
  type: 'line',
13847
15021
  source: sourceId,
13848
15022
  paint: {
13849
- 'line-color': '#3B82F6', // Blue color
15023
+ 'line-color': '#A8116F', // Revision magenta, the UI's accent
13850
15024
  'line-width': 3,
13851
15025
  'line-opacity': 0.8,
13852
15026
  },
@@ -13899,6 +15073,10 @@ class OfflineManagerControl {
13899
15073
  controlLogger.error(`Style ${styleId} not found in IndexedDB`);
13900
15074
  return;
13901
15075
  }
15076
+ if (needsMapboxGl(styleEntry.style) && !isMapboxGlMap(this.map)) {
15077
+ controlLogger.error(`Style ${styleId} needs Mapbox GL JS, so this map can't show it`);
15078
+ return;
15079
+ }
13902
15080
  // The style is already patched with idb:// URLs by addRegion() in regionService.
13903
15081
  // DO NOT call patchStyleForOffline() again — double-patching rewrites the downloadId
13904
15082
  // in URLs and can break tile/glyph lookups.
@@ -13957,9 +15135,9 @@ class OfflineManagerControl {
13957
15135
  ${styles
13958
15136
  .map(style => `
13959
15137
  <button class="w-full p-3 text-left border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" data-style-id="${escapeHtml$1(style.key)}">
13960
- <div class="font-medium text-gray-900 dark:text-white">${escapeHtml$1(style.style.name || style.key)}</div>
15138
+ <div class="font-medium text-gray-900 dark:text-white">${escapeHtml$1(styleDisplayName(style))}</div>
13961
15139
  <div class="text-sm text-gray-500 dark:text-gray-400">
13962
- ${style.regions?.length || 0} regions •
15140
+ ${regionCountLabel(style.regions?.length || 0)} •
13963
15141
  ${Object.keys(style.style.sources || {}).length} sources
13964
15142
  </div>
13965
15143
  </button>
@@ -14057,5 +15235,51 @@ class OfflineManagerControl {
14057
15235
  }
14058
15236
  }
14059
15237
 
14060
- export { AnalyticsService, CONTENT_TYPES, CategorizedError, CleanupService, DB_NAME, DB_VERSION, DOWNLOAD_DEFAULTS, ERROR_MESSAGES, ErrorType, FontService, GLYPH_BLOCK_SIZE, GLYPH_CONFIG, GZIP_MAGIC_BYTES, GlyphService, ImportExportService, LogLevel, MAPBOX_API, MAPBOX_CACHE_TTL, MAPBOX_CLASSIC_STYLES, MAPBOX_STANDARD_SPARSE_TILESETS, MAP_PROVIDERS, MAX_GLYPH_CODEPOINT, MaintenanceService, ModelService, OfflineManagerControl, OfflineMapDBVersionError, OfflineMapManager, RESOURCE_TYPES, RegionService, ResourceService, STORAGE_CONFIG, STORE_NAMES, STYLE_CONFIG, SUCCESS_MESSAGES, ScopedLogger, SpriteService, TILE_CONFIG, TileService, URL_SCHEMES, VALIDATION_PATTERNS, applyProxy, categorizeError, cleanupCompressedTiles, cleanupExpiredTiles, cleanupOldFonts, cleanupOldGlyphs, cleanupOldModels, cleanupOldSprites, cleanupOldStyles, cleanupOldTiles, cleanupService, clearAllCaches, configureLogger, configureProxy, configureSqlJs, convertStyleForServiceWorker, countCompressedTiles, createProgressTracker, createTileKey, dbPromise, OfflineMapManager as default, deleteStyleById, deleteStyles, deriveTileExtension, detectCssPrefix, detectStyleProvider, downloadFonts, downloadGlyphs, downloadModels, downloadSprites, downloadStyleWithProvider, downloadStyles, downloadTiles, escapeHtml$1 as escapeHtml, extractAccessToken, extractAllFontNames, extractFontNamesFromTextField, extractTileExtensionFromUrl, fetchResourceWithRetry, fetchWithRetry, fontService, formatBytes, formatDate, generateGlyphUrlsFromStyle, getExpiredResourceCount, getFontAnalytics, getFontStats, getGlyphAnalytics, getGlyphStats, getIcon, getModel, getModelStats, getRegionAnalytics, getSpriteAnalytics, getSpriteStats, getSqlJs, getStyleStats, getTileAnalytics, getTileStats, getUrlHostname, getUserErrorMessage, glyphService, hasImports, hostMatches, i18n, icons, idbFetchHandler, isMapboxHost, isMapboxProtocol, isStyleDownloaded, loadAllStoredRegions, loadGlyphs, loadStyleById, loadStyles, logger, modelKeyBelongsToStyle, modelService, normalizeSpriteProperty, normalizeStyleUrl, optimizeStorage, parseCacheExpiry, parseTileKey, patchStyleForOffline, performCleanup, processBatch, processStyleSources, registerOfflineServiceWorker, resetOfflineMapDB, resolveImports, resolveMapboxUrl, resourceKeyBelongsToStyle, rewriteMapboxCdnTileUrl, safeExecute, sanitizeIndoorExpressions, setupAutoCleanup, spriteService, stopAutoCleanup, t, tileService, unregisterOfflineServiceWorker, urlReferencesKnownSparseTileset, validateBounds, validateRegionOptions, validateResource, validateStyleForProvider, validateZoomLevels, verifyAndRepairFonts, verifyAndRepairGlyphs, verifyAndRepairModels, verifyAndRepairSprites };
15238
+ const assetLogger = logger.scope('RuntimeAssets');
15239
+ const ASSET_TIMEOUT_MS = 30000;
15240
+ /**
15241
+ * Cache the code Mapbox GL fetches from api.mapbox.com while rendering: the
15242
+ * Draco and meshopt decoders 3D models need, plus the RTL text and procedural
15243
+ * building modules. The Service Worker serves them back from this cache. They
15244
+ * live in the Cache API rather than IndexedDB because they belong to the
15245
+ * renderer, not to any style or region.
15246
+ *
15247
+ * Failures only log: the rest of a region is still usable without these.
15248
+ *
15249
+ * @returns how many assets are now cached
15250
+ */
15251
+ async function cacheMapboxRuntimeAssets() {
15252
+ if (typeof caches === 'undefined')
15253
+ return 0;
15254
+ let cache;
15255
+ try {
15256
+ cache = await caches.open(MAPBOX_RUNTIME_CACHE);
15257
+ }
15258
+ catch (error) {
15259
+ assetLogger.warn('Cache API unavailable, skipping runtime assets:', error);
15260
+ return 0;
15261
+ }
15262
+ const results = await Promise.all(MAPBOX_RUNTIME_ASSETS.map(async (path) => {
15263
+ const url = `${MAPBOX_RUNTIME_ORIGIN}${path}`;
15264
+ try {
15265
+ if (await cache.match(url))
15266
+ return true;
15267
+ const response = await fetchWithRetry(url, { retries: 0, timeout: ASSET_TIMEOUT_MS });
15268
+ await cache.put(url, response);
15269
+ return true;
15270
+ }
15271
+ catch (error) {
15272
+ assetLogger.warn(`Failed to cache ${url}:`, error);
15273
+ return false;
15274
+ }
15275
+ }));
15276
+ return results.filter(Boolean).length;
15277
+ }
15278
+
15279
+ var runtimeAssetService = /*#__PURE__*/Object.freeze({
15280
+ __proto__: null,
15281
+ cacheMapboxRuntimeAssets: cacheMapboxRuntimeAssets
15282
+ });
15283
+
15284
+ export { AnalyticsService, CONTENT_TYPES, CategorizedError, CleanupService, DB_NAME, DB_VERSION, DOWNLOAD_DEFAULTS, ERROR_MESSAGES, ErrorType, FontService, GLYPH_BLOCK_SIZE, GLYPH_CONFIG, GZIP_MAGIC_BYTES, GlyphService, ImportExportService, LogLevel, MAPBOX_API, MAPBOX_CACHE_TTL, MAPBOX_CLASSIC_STYLES, MAPBOX_STANDARD_SPARSE_TILESETS, MAP_PROVIDERS, MAX_GLYPH_CODEPOINT, MaintenanceService, ModelService, OfflineManagerControl, OfflineMapDBVersionError, OfflineMapManager, RESOURCE_TYPES, RegionService, ResourceService, STORAGE_CONFIG, STORE_NAMES, STYLE_CONFIG, SUCCESS_MESSAGES, ScopedLogger, SpriteService, TILE_CONFIG, TileService, URL_SCHEMES, VALIDATION_PATTERNS, applyProxy, categorizeError, cleanupCompressedTiles, cleanupExpiredTiles, cleanupOldFonts, cleanupOldGlyphs, cleanupOldModels, cleanupOldSprites, cleanupOldStyles, cleanupOldTiles, cleanupService, clearAllCaches, configureLogger, configureProxy, configureSqlJs, convertStyleForServiceWorker, countCompressedTiles, createProgressTracker, createTileKey, dbPromise, OfflineMapManager as default, deleteStyleById, deleteStyles, deriveTileExtension, detectCssPrefix, detectStyleProvider, downloadFonts, downloadGlyphs, downloadModels, downloadSprites, downloadStyleWithProvider, downloadStyles, downloadTiles, escapeHtml$1 as escapeHtml, extractAccessToken, extractAllFontNames, extractFontNamesFromTextField, extractTileExtensionFromUrl, fetchResourceWithRetry, fetchWithRetry, fontService, formatBytes, formatDate, generateGlyphUrlsFromStyle, getExpiredResourceCount, getFontAnalytics, getFontStats, getGlyphAnalytics, getGlyphStats, getIcon, getModel, getModelStats, getRegionAnalytics, getSpriteAnalytics, getSpriteStats, getSqlJs, getStyleStats, getStyleStorageSizes, getTileAnalytics, getTileStats, getUrlHostname, getUserErrorMessage, glyphService, hasImports, hostMatches, i18n, icons, idbFetchHandler, injectExtraSources, isMapboxGlMap, isMapboxHost, isMapboxProtocol, isStyleDownloaded, loadAllStoredRegions, loadGlyphs, loadStyleById, loadStyles, logger, modelKeyBelongsToStyle, modelService, needsMapboxGl, normalizeSpriteProperty, normalizeStyleUrl, optimizeStorage, parseCacheExpiry, parseTileKey, patchStyleForOffline, performCleanup, processBatch, processStyleSources, registerOfflineServiceWorker, resetOfflineMapDB, resolveImports, resolveMapboxUrl, resourceKeyBelongsToStyle, restoreUnstoredModels, rewriteMapboxCdnTileUrl, safeExecute, sanitizeIndoorExpressions, selectTileJsonVariant, setupAutoCleanup, spriteService, stashOriginalModels, stopAutoCleanup, storedModelNames, styleDisplayName, t, tileService, unregisterOfflineServiceWorker, urlReferencesKnownSparseTileset, validateBounds, validateRegionOptions, validateResource, validateStyleForProvider, validateZoomLevels, verifyAndRepairFonts, verifyAndRepairGlyphs, verifyAndRepairModels, verifyAndRepairSprites };
14061
15285
  //# sourceMappingURL=index.esm.js.map