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.js CHANGED
@@ -186,8 +186,10 @@ const MAPBOX_API = {
186
186
  /**
187
187
  * Mapbox Standard sub-tilesets that are sparse-by-design across the planet.
188
188
  * These only have tiles where the underlying feature (indoor venues, landmark
189
- * POIs, 3D procedural buildings) actually exists, and return 404 for nearly
190
- * every other coordinate. The tile downloader pre-skips any source whose tile
189
+ * POIs, 3D procedural buildings) actually exists, and return 404 for every other
190
+ * coordinate, though they can be dense inside a major city. Unless the style's
191
+ * config has switched on a feature that needs one, the tile downloader pre-skips
192
+ * any source whose tile
191
193
  * URL templates reference one of these tileset IDs, so we never issue probe
192
194
  * or download requests for them — eliminating the 404 noise in devtools.
193
195
  *
@@ -389,7 +391,10 @@ async function openOfflineMapDB() {
389
391
  // Migration: v2 -> v3
390
392
  // Move regions from 'regions' store to styles.regions[]
391
393
  if (oldVersion > 0 && oldVersion < 3) {
392
- migrateRegionsToStyles(transaction);
394
+ // Unwrap to the raw IDBTransaction: on idb's wrapper, getAll()/get()
395
+ // return Promises, so the onsuccess callbacks the migration relies
396
+ // on would never fire and no region would be migrated.
397
+ migrateRegionsToStyles(idb.unwrap(transaction));
393
398
  }
394
399
  // Migration: v3 -> v4
395
400
  // Adds the `models` store for Mapbox Standard 3D model assets.
@@ -1050,6 +1055,20 @@ function formatBytes(bytes) {
1050
1055
  const i = Math.floor(Math.log(bytes) / Math.log(k));
1051
1056
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
1052
1057
  }
1058
+ /**
1059
+ * A stored style's display name: its own `name`, or else the last meaningful
1060
+ * part of its URL ("liberty" for `.../styles/liberty`), or else its key.
1061
+ */
1062
+ function styleDisplayName(entry) {
1063
+ if (entry.style?.name)
1064
+ return entry.style.name;
1065
+ const path = (entry.originalUrl ?? '').split(/[?#]/)[0];
1066
+ const segments = path
1067
+ .split('/')
1068
+ .map(segment => segment.replace(/\.json$/i, ''))
1069
+ .filter(segment => segment && !/^(style|styles)$/i.test(segment) && !segment.includes(':'));
1070
+ return segments.at(-1) ?? entry.key;
1071
+ }
1053
1072
  /**
1054
1073
  * Format date to human readable string
1055
1074
  */
@@ -1277,6 +1296,39 @@ function deriveTileExtension(tiles) {
1277
1296
  * They have different shapes so cannot be shared; the key computation
1278
1297
  * can be and is.
1279
1298
  */
1299
+ /**
1300
+ * `decodeURIComponent` that returns the input unchanged instead of throwing
1301
+ * on a malformed escape (e.g. a literal `%` in a font or source name), so a
1302
+ * bad path becomes a lookup miss / 404 rather than a rejected fetch.
1303
+ */
1304
+ function safeDecodeURIComponent(value) {
1305
+ try {
1306
+ return decodeURIComponent(value);
1307
+ }
1308
+ catch {
1309
+ return value;
1310
+ }
1311
+ }
1312
+ // ---------------------------------------------------------------------------
1313
+ // Mapbox GL runtime assets
1314
+ // ---------------------------------------------------------------------------
1315
+ const MAPBOX_RUNTIME_ORIGIN = 'https://api.mapbox.com';
1316
+ const MAPBOX_RUNTIME_CACHE = 'map-gl-offline-runtime-v1';
1317
+ /**
1318
+ * Code Mapbox GL fetches from api.mapbox.com while rendering, so it has to be
1319
+ * available offline too. Paths are versioned, so a renderer that moves to a new
1320
+ * version misses here; `npm run check:mapbox-standard` compares this list with
1321
+ * the installed renderer. They match Mapbox GL 3.34; an app on a version with
1322
+ * other paths gets no offline copy of those files.
1323
+ */
1324
+ const MAPBOX_RUNTIME_ASSETS = [
1325
+ '/mapbox-gl-js/draco_decoder_gltf_v1.5.6.wasm',
1326
+ '/mapbox-gl-js/meshopt_base_v1.2.wasm',
1327
+ '/mapbox-gl-js/meshopt_simd_v1.2.wasm',
1328
+ '/mapbox-gl-js/rtl_text_v1.0.0.wasm',
1329
+ '/mapbox-gl-js/building-gen/building_gen_v1.2.6.wasm',
1330
+ '/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.3.0/mapbox-gl-rtl-text.js',
1331
+ ];
1280
1332
  /**
1281
1333
  * Extensions to try in order when the requested extension misses. `glb` is
1282
1334
  * last so batched-model sources (Mapbox Standard 3D buildings) resolve when
@@ -1314,9 +1366,10 @@ function findStyleByRegionIdIn(styles, regionId) {
1314
1366
  // Glyph candidate keys
1315
1367
  // ---------------------------------------------------------------------------
1316
1368
  /**
1317
- * Parse `FontA,FontB,FontC/0-255.pbf` into (fontstacks, rangePart). Mapbox
1318
- * requests a comma-joined font-family fallback chain; each glyph is stored
1319
- * individually, so the caller tries each fontstack in order.
1369
+ * Parse `FontA,FontB,FontC/0-255.pbf` into (fontstacks, rangePart). Older Mapbox
1370
+ * GL requests a comma-joined font-family fallback chain (current versions fetch
1371
+ * each font and merge them client-side); each glyph is stored individually, so
1372
+ * the caller tries each fontstack in order.
1320
1373
  */
1321
1374
  function parseGlyphPath(decodedPath) {
1322
1375
  const pathParts = decodedPath.split('/');
@@ -1328,6 +1381,47 @@ function parseGlyphPath(decodedPath) {
1328
1381
  .filter(Boolean);
1329
1382
  return { fontstacks, rangePart };
1330
1383
  }
1384
+ function readVarint(bytes, start) {
1385
+ let value = 0;
1386
+ let multiplier = 1;
1387
+ for (let pos = start; pos < bytes.length && pos < start + 5; pos++) {
1388
+ value += (bytes[pos] & 0x7f) * multiplier;
1389
+ if (bytes[pos] < 0x80)
1390
+ return { value, next: pos + 1 };
1391
+ multiplier *= 128;
1392
+ }
1393
+ return null;
1394
+ }
1395
+ /**
1396
+ * True for a glyph range PBF holding only its font name and range. Servers
1397
+ * return these for blocks a font doesn't cover, e.g. `DIN Pro Medium/1536-1791`.
1398
+ * Serving one in place of a later font in the chain blanks those labels.
1399
+ *
1400
+ * Reads `glyphs { fontstack stacks = 1 { name = 1; range = 2; glyph = 3 } }`.
1401
+ * Anything unexpected counts as not empty, so unknown data is still served.
1402
+ */
1403
+ function isEmptyGlyphRange(data) {
1404
+ const bytes = new Uint8Array(data);
1405
+ if (bytes[0] !== 0x0a)
1406
+ return false;
1407
+ const stack = readVarint(bytes, 1);
1408
+ if (!stack)
1409
+ return false;
1410
+ const end = stack.next + stack.value;
1411
+ if (end !== bytes.length)
1412
+ return false;
1413
+ let pos = stack.next;
1414
+ while (pos < end) {
1415
+ const tag = bytes[pos];
1416
+ if ((tag & 0x07) !== 2 || tag >> 3 === 3)
1417
+ return false;
1418
+ const length = readVarint(bytes, pos + 1);
1419
+ if (!length)
1420
+ return false;
1421
+ pos = length.next + length.value;
1422
+ }
1423
+ return true;
1424
+ }
1331
1425
  /**
1332
1426
  * Build the list of keys to try for a single (fontstack, range) pair.
1333
1427
  * Order: actualStyleId variants first (most common), then downloadId,
@@ -1498,10 +1592,10 @@ function clearAllCaches() {
1498
1592
  tileCache.clear();
1499
1593
  }
1500
1594
  /**
1501
- * Find a style entry that contains the given region ID
1502
- * Since styles are stored by style key but patched with region IDs,
1503
- * we need to search all styles to find which one contains this region
1504
- * Results are cached to avoid repeated DB queries during tile fetching
1595
+ * Resolve the `{id}` segment of an `idb://{id}/...` URL to its style entry.
1596
+ * Styles patched since URLs became style-keyed use the style key itself (one
1597
+ * keyed read); older stored styles use a region id, which needs a scan of all
1598
+ * styles. Results are cached to avoid repeated DB queries during tile fetching.
1505
1599
  */
1506
1600
  async function findStyleByRegionId(db, regionId) {
1507
1601
  // Check cache first
@@ -1510,6 +1604,11 @@ async function findStyleByRegionId(db, regionId) {
1510
1604
  return cached.styleEntry;
1511
1605
  }
1512
1606
  try {
1607
+ const direct = (await db.get('styles', regionId));
1608
+ if (direct) {
1609
+ regionToStyleCache.set(regionId, { styleEntry: direct, timestamp: Date.now() });
1610
+ return direct;
1611
+ }
1513
1612
  const allStyles = await db.getAll('styles');
1514
1613
  const hit = findStyleByRegionIdIn(allStyles, regionId);
1515
1614
  if (hit) {
@@ -1599,9 +1698,11 @@ async function idbFetchHandler(url, init) {
1599
1698
  }
1600
1699
  const db = await dbPromise;
1601
1700
  const parsed = url.replace('idb://', '').split('/');
1602
- const [downloadId, type, ...rest] = parsed;
1701
+ // A Request's URL arrives percent-encoded, so decode the style id as the Service Worker does.
1702
+ const [encodedDownloadId, type, ...rest] = parsed;
1703
+ const downloadId = safeDecodeURIComponent(encodedDownloadId ?? '');
1603
1704
  const resourcePath = rest.join('/');
1604
- const decodedResourcePath = decodeURIComponent(resourcePath);
1705
+ const decodedResourcePath = safeDecodeURIComponent(resourcePath);
1605
1706
  const key = `${downloadId}::${decodedResourcePath}`;
1606
1707
  try {
1607
1708
  switch (type) {
@@ -1620,7 +1721,7 @@ async function idbFetchHandler(url, init) {
1620
1721
  // MapLibre can request fractional zoom levels (e.g., 12.5)
1621
1722
  // but tiles are stored with integer zoom levels, so floor the value
1622
1723
  const z = Math.floor(parseFloat(pathParts[pathParts.length - 3]));
1623
- const sourceKey = pathParts.slice(0, pathParts.length - 3).join('/');
1724
+ const sourceKey = safeDecodeURIComponent(pathParts.slice(0, pathParts.length - 3).join('/'));
1624
1725
  const yMatch = yExt.match(/^(\d+)\.(\w+)$/);
1625
1726
  if (yMatch) {
1626
1727
  const y = parseInt(yMatch[1]);
@@ -1658,7 +1759,7 @@ async function idbFetchHandler(url, init) {
1658
1759
  if (pathParts.length === 1) {
1659
1760
  // Old format without sourceKey - try to extract from the URL
1660
1761
  const encodedTileUrl = pathParts[0];
1661
- const tileUrl = decodeURIComponent(encodedTileUrl);
1762
+ const tileUrl = safeDecodeURIComponent(encodedTileUrl);
1662
1763
  // Try to extract source from URL pattern
1663
1764
  // For example: https://domain.com/service/source/vt/{z}/{x}/{y}.pbf
1664
1765
  const urlParts = tileUrl.split('/');
@@ -1702,21 +1803,30 @@ async function idbFetchHandler(url, init) {
1702
1803
  const actualStyleId = styleEntry?.key || downloadId;
1703
1804
  const { fontstacks, rangePart } = parseGlyphPath(decodedResourcePath);
1704
1805
  idbLogger.debug(`Trying ${fontstacks.length} fonts in fallback order: ${fontstacks.join(', ')}`);
1806
+ const glyphResponse = (data) => new Response(data, {
1807
+ status: 200,
1808
+ headers: { 'Content-Type': 'application/x-protobuf' },
1809
+ });
1810
+ let emptyRange;
1705
1811
  for (const fontstack of fontstacks) {
1706
1812
  const candidateKeys = glyphCandidateKeys(actualStyleId, downloadId, fontstack, rangePart);
1707
1813
  for (const candidateKey of candidateKeys) {
1708
1814
  const resource = await db.get('glyphs', candidateKey);
1709
1815
  if (resource?.data) {
1710
- idbLogger.debug(`Found glyph using key: ${candidateKey} (font: ${fontstack})`);
1711
- return new Response(resource.data, {
1712
- status: 200,
1713
- headers: { 'Content-Type': 'application/x-protobuf' },
1714
- });
1816
+ if (!isEmptyGlyphRange(resource.data)) {
1817
+ idbLogger.debug(`Found glyph using key: ${candidateKey} (font: ${fontstack})`);
1818
+ return glyphResponse(resource.data);
1819
+ }
1820
+ emptyRange ??= resource.data;
1715
1821
  }
1716
1822
  }
1717
1823
  }
1718
- idbLogger.warn(`Glyph not found for any font in: ${fontstacks.join(', ')}`);
1719
- break;
1824
+ // Never a 404: Mapbox GL fails a tile's whole glyph request if one range
1825
+ // errors, so a range outside the downloaded set would blank all its labels.
1826
+ if (!emptyRange) {
1827
+ idbLogger.warn(`Glyph not found for any font in: ${fontstacks.join(', ')}`);
1828
+ }
1829
+ return glyphResponse(emptyRange ?? new ArrayBuffer(0));
1720
1830
  }
1721
1831
  case 'sprite': {
1722
1832
  idbLogger.debug(`Looking for sprite with key: ${key}`);
@@ -1808,7 +1918,8 @@ async function idbFetchHandler(url, init) {
1808
1918
  catch (error) {
1809
1919
  idbLogger.error(`Error fetching resource from IDB: ${url}`, error);
1810
1920
  }
1811
- idbLogger.warn(`Resource not found in IDB: ${url}`);
1921
+ // Normal once the map moves outside a saved region, so not worth a warning per tile.
1922
+ idbLogger.debug(`Resource not found in IDB: ${url}`);
1812
1923
  return new Response('Not found in IDB', { status: 404 });
1813
1924
  }
1814
1925
 
@@ -1869,57 +1980,103 @@ function resolveMapboxUrl(mapboxUrl, accessToken) {
1869
1980
  if (!accessToken) {
1870
1981
  throw new Error(`Mapbox access token is required to resolve mapbox:// URL: ${mapboxUrl}`);
1871
1982
  }
1872
- // Strip the protocol prefix
1873
- const path = mapboxUrl.slice(MAPBOX_API.PROTOCOL.length);
1983
+ // Strip the protocol prefix, and split off any query so path matching and
1984
+ // the resolved URL's suffixes (`.json`, `/sprite`) go before it. Existing
1985
+ // parameters are kept; `access_token` is set exactly once.
1986
+ const withoutProtocol = mapboxUrl.slice(MAPBOX_API.PROTOCOL.length);
1987
+ const queryIndex = withoutProtocol.indexOf('?');
1988
+ const path = queryIndex === -1 ? withoutProtocol : withoutProtocol.slice(0, queryIndex);
1989
+ const params = new URLSearchParams(queryIndex === -1 ? '' : withoutProtocol.slice(queryIndex + 1));
1990
+ params.set('access_token', accessToken);
1991
+ const query = `?${params.toString()}`;
1874
1992
  // mapbox://styles/{user}/{id}
1875
1993
  if (path.startsWith('styles/')) {
1876
1994
  const rest = path.slice('styles/'.length);
1877
- return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}?access_token=${accessToken}`;
1995
+ return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}${query}`;
1878
1996
  }
1879
1997
  // mapbox://sprites/{user}/{id}
1880
1998
  if (path.startsWith('sprites/')) {
1881
1999
  const rest = path.slice('sprites/'.length);
1882
- return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}/sprite?access_token=${accessToken}`;
2000
+ return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}/sprite${query}`;
1883
2001
  }
1884
2002
  // mapbox://fonts/{user}/{fontstack}/{range}.pbf
1885
2003
  if (path.startsWith('fonts/')) {
1886
2004
  const rest = path.slice('fonts/'.length);
1887
- return `${MAPBOX_API.BASE_URL}${MAPBOX_API.FONTS_PATH}/${rest}?access_token=${accessToken}`;
2005
+ return `${MAPBOX_API.BASE_URL}${MAPBOX_API.FONTS_PATH}/${rest}${query}`;
1888
2006
  }
1889
2007
  // mapbox://models/{path}
1890
2008
  if (path.startsWith('models/')) {
1891
2009
  const rest = path.slice('models/'.length);
1892
- return `${MAPBOX_API.BASE_URL}${MAPBOX_API.MODELS_PATH}/${rest}?access_token=${accessToken}`;
2010
+ return `${MAPBOX_API.BASE_URL}${MAPBOX_API.MODELS_PATH}/${rest}${query}`;
1893
2011
  }
1894
2012
  // mapbox://{tileset} (e.g. mapbox://mapbox.mapbox-streets-v8)
1895
- return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${path}.json?access_token=${accessToken}`;
2013
+ return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${path}.json${query}`;
1896
2014
  }
1897
2015
  /**
1898
- * Rewrite Mapbox CDN tile URLs to use the api.mapbox.com/v4 endpoint.
2016
+ * Pick the TileJSON variant Mapbox GL renders from, the way its `mergeVariants`
2017
+ * does: `meshopt-v2+lod`, then `meshopt`, then the base tileset.
2018
+ *
2019
+ * These aren't just different encodings of one dataset. For
2020
+ * `mapbox.mapbox-3dbuildings-v1`, the base tile over central Dubai is empty while
2021
+ * the variants hold its 3D buildings, so downloading the base loses them offline.
2022
+ * Variant tiles are `mapbox://3dtiles/...` URLs, resolved here using the base
2023
+ * URL's query string, which carries the access token.
2024
+ */
2025
+ function selectTileJsonVariant(tileJson) {
2026
+ const { variants, tiles: baseTiles } = tileJson;
2027
+ if (!Array.isArray(variants))
2028
+ return tileJson;
2029
+ const withCapability = (capability) => variants.find(variant => Array.isArray(variant?.capabilities) &&
2030
+ variant.capabilities.length === 1 &&
2031
+ variant.capabilities[0] === capability);
2032
+ const selected = withCapability('meshopt-v2+lod') ?? withCapability('meshopt');
2033
+ if (!selected || !Array.isArray(selected.tiles))
2034
+ return tileJson;
2035
+ const firstBaseTile = Array.isArray(baseTiles) ? String(baseTiles[0] ?? '') : '';
2036
+ const queryIndex = firstBaseTile.indexOf('?');
2037
+ const query = queryIndex === -1 ? '' : firstBaseTile.slice(queryIndex);
2038
+ const tiles = selected.tiles.map(tile => tile.startsWith('mapbox://3dtiles/')
2039
+ ? `${MAPBOX_API.BASE_URL}/3dtiles/v1/${tile.slice('mapbox://3dtiles/'.length)}${query}`
2040
+ : tile);
2041
+ return { ...tileJson, ...selected, tiles };
2042
+ }
2043
+ const SKU_LIFETIME_MS = 12 * 60 * 60 * 1000;
2044
+ const SKU_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
2045
+ let skuSession;
2046
+ /** A Mapbox session token in the format Mapbox GL JS generates: version + SKU id + 10 random. */
2047
+ function getSkuToken() {
2048
+ if (!skuSession || Date.now() > skuSession.expiresAt) {
2049
+ let randomizer = '';
2050
+ for (let i = 0; i < 10; i++) {
2051
+ randomizer += SKU_CHARS[Math.floor(Math.random() * SKU_CHARS.length)];
2052
+ }
2053
+ skuSession = { token: `101${randomizer}`, expiresAt: Date.now() + SKU_LIFETIME_MS };
2054
+ }
2055
+ return skuSession.token;
2056
+ }
2057
+ /**
2058
+ * Add the `sku` session token that Mapbox `/raster/v1/` tile URLs need.
1899
2059
  *
1900
- * TileJSON responses from Mapbox return tile URLs on *.tiles.mapbox.com CDN
1901
- * which may use paths like /raster/v1/ that require an SKU session token
1902
- * generated by the Mapbox GL JS SDK at runtime. Since we fetch tiles directly
1903
- * without the SDK, these 401. The /v4/ API endpoint on api.mapbox.com works
1904
- * with just an access_token, so we rewrite CDN URLs to use it.
2060
+ * TileJSON returns these URLs without one, and without it they 401. Mapbox GL
2061
+ * adds it at request time, but this library fetches tiles itself.
2062
+ *
2063
+ * Don't switch these to the `/v4/` endpoint, which needs no token: it serves a
2064
+ * different tile. For `mapbox.mapbox-terrain-dem-v1`, `/v4/….png` is a 256px
2065
+ * palette PNG with elevations off by up to ~200 m, while the renderer expects
2066
+ * the 514px RGB tile (`tileSize: 514`) that `/raster/v1/` returns.
1905
2067
  *
1906
2068
  * Example:
1907
2069
  * https://a.tiles.mapbox.com/raster/v1/mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png?access_token=...
1908
- * -> https://api.mapbox.com/v4/mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png?access_token=...
2070
+ * -> https://a.tiles.mapbox.com/raster/v1/mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png?access_token=...&sku=101...
1909
2071
  */
1910
2072
  function rewriteMapboxCdnTileUrl(tileUrl) {
1911
- // Only rewrite *.tiles.mapbox.com/raster/v1/ URLs — these require an SKU
1912
- // session token that only the Mapbox GL JS SDK can generate. Other CDN paths
1913
- // like /v4/ (vector tiles) and /3dtiles/v1/ (3D models) work with just an
1914
- // access_token.
1915
- const match = tileUrl.match(/^https?:\/\/[a-z]\.tiles\.mapbox\.com\/raster\/v1\/([^?]+)(\?.*)?$/);
1916
- if (!match) {
2073
+ if (!/^https?:\/\/(?:[a-z]\.tiles\.mapbox\.com|api\.mapbox\.com)\/raster\/v1\//.test(tileUrl)) {
2074
+ return tileUrl;
2075
+ }
2076
+ if (/[?&]sku=/.test(tileUrl)) {
1917
2077
  return tileUrl;
1918
2078
  }
1919
- // match[1] is the path after /raster/v1/: "mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png"
1920
- const tilesetAndPath = match[1];
1921
- const queryString = match[2] || '';
1922
- return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${tilesetAndPath}${queryString}`;
2079
+ return `${tileUrl}${tileUrl.includes('?') ? '&' : '?'}sku=${getSkuToken()}`;
1923
2080
  }
1924
2081
  /**
1925
2082
  * Detect the style provider based on the style URL or content
@@ -2145,24 +2302,100 @@ function validateStyleForProvider(style, provider) {
2145
2302
 
2146
2303
  const styleLogger = logger.scope('StyleUtils');
2147
2304
  /**
2148
- * Patches a MapboxStyle for offline use by replacing URLs with IndexedDB references
2305
+ * Patches a MapboxStyle for offline use by replacing URLs with IndexedDB references.
2306
+ *
2307
+ * Safe to call repeatedly on the same stored style (every `addRegion` does):
2308
+ * the first call stashes each source's upstream `tiles` / `maxzoom` under
2309
+ * `__originalTiles` / `__originalMaxzoom`, and later calls patch from those,
2310
+ * so the download pipeline can still reach the network and `maxzoom` can grow
2311
+ * again when a deeper region is added.
2312
+ *
2149
2313
  * @param style - The style to patch
2150
- * @param downloadId - The download/region ID for tiles and other resources
2151
- * @param maxZoom - Optional max zoom level for sources
2314
+ * @param downloadId - Fallback ID for `idb://` URLs when `styleId` is omitted
2315
+ * @param maxZoom - Max zoom downloaded for this style (the highest across its regions)
2152
2316
  * @param tileExtension - Optional tile extension
2153
- * @param styleId - Optional style ID for sprites (if different from downloadId). Sprites are shared
2154
- * across regions and stored with the style ID, so this parameter allows the sprite
2155
- * URLs to correctly reference the style ID.
2317
+ * @param styleId - Style ID used in all `idb://` URLs. Tiles, glyphs, sprites and
2318
+ * models are stored per style, so URLs keyed by style keep working
2319
+ * when any one region is deleted.
2320
+ */
2321
+ /**
2322
+ * Add a region's extra tile sources to a style, unless one with that id is
2323
+ * already there. Sources must be in the style before it's patched for offline use.
2324
+ */
2325
+ function injectExtraSources(style, extraSources = []) {
2326
+ for (const extra of extraSources) {
2327
+ if (style.sources[extra.id])
2328
+ continue;
2329
+ style.sources[extra.id] = {
2330
+ type: extra.type || 'vector',
2331
+ tiles: extra.tiles,
2332
+ ...(extra.minzoom !== undefined ? { minzoom: extra.minzoom } : {}),
2333
+ ...(extra.maxzoom !== undefined ? { maxzoom: extra.maxzoom } : {}),
2334
+ ...(extra.attribution ? { attribution: extra.attribution } : {}),
2335
+ };
2336
+ }
2337
+ }
2338
+ /**
2339
+ * Keep each model's upstream URL before the style is patched, the first time
2340
+ * only. Patching points every model at `idb://`, so without this a model that
2341
+ * failed to download couldn't be fetched again.
2342
+ */
2343
+ function stashOriginalModels(entry) {
2344
+ const models = entry.style.models;
2345
+ if (!models || entry.originalModels)
2346
+ return;
2347
+ const isPatched = (value) => String(typeof value === 'string' ? value : value?.uri).startsWith('idb://');
2348
+ // A style patched before this stash existed has no upstream URLs left to keep.
2349
+ if (Object.values(models).some(isPatched))
2350
+ return;
2351
+ entry.originalModels = JSON.parse(JSON.stringify(models));
2352
+ }
2353
+ /**
2354
+ * Point models that aren't stored back at their upstream URLs. Run after
2355
+ * patching, which points every model at `idb://`.
2356
+ */
2357
+ function restoreUnstoredModels(entry, storedNames) {
2358
+ const models = entry.style.models;
2359
+ if (!models || !entry.originalModels)
2360
+ return;
2361
+ for (const name of Object.keys(models)) {
2362
+ if (!storedNames.has(name) && entry.originalModels[name] !== undefined) {
2363
+ models[name] = JSON.parse(JSON.stringify(entry.originalModels[name]));
2364
+ }
2365
+ }
2366
+ }
2367
+ /**
2368
+ * Whether a style uses parts of the spec only Mapbox GL JS v3 draws: config
2369
+ * (`schema`), 3D `models`, `lights` or `imports`. MapLibre rejects these.
2156
2370
  */
2371
+ function needsMapboxGl(style) {
2372
+ const parts = style;
2373
+ return Boolean(parts.schema || parts.models || parts.lights || parts.imports);
2374
+ }
2375
+ /** Whether a map instance is Mapbox GL JS v3, the only renderer with style config. */
2376
+ function isMapboxGlMap(map) {
2377
+ return typeof map?.setConfigProperty === 'function';
2378
+ }
2157
2379
  function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId) {
2158
2380
  styleLogger.debug(`Patching style for offline use with downloadId: ${downloadId}, maxZoom: ${maxZoom}, tileExtension: ${tileExtension}`);
2159
2381
  styleLogger.debug(`Original style:`, style);
2382
+ const urlId = styleId || downloadId;
2160
2383
  // Patch sources
2161
2384
  for (const sourceKey in style.sources) {
2162
2385
  const source = style.sources[sourceKey];
2163
2386
  styleLogger.debug(`Patching source: ${sourceKey}`, source);
2387
+ // Stash upstream values once. Already-patched tiles (all idb://) are never
2388
+ // stashed as "original"; `null` records that the source had no maxzoom.
2389
+ if (source.tiles &&
2390
+ source.__originalTiles === undefined &&
2391
+ !source.tiles.every(url => url.startsWith('idb://'))) {
2392
+ source.__originalTiles = [...source.tiles];
2393
+ }
2394
+ if (source.__originalMaxzoom === undefined) {
2395
+ source.__originalMaxzoom = source.maxzoom ?? null;
2396
+ }
2164
2397
  if (source.tiles) {
2165
- const originalTiles = [...source.tiles];
2398
+ const originalTiles = source.__originalTiles ?? [...source.tiles];
2166
2399
  // Patch to idb://{downloadId}/tile/{sourceKey}/{z}/{x}/{y}.ext.
2167
2400
  // Extension extraction goes through the shared extractTileExtensionFromUrl
2168
2401
  // helper so the patched URL's extension matches what tileService used when
@@ -2170,9 +2403,9 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
2170
2403
  // stored key under `.pbf` but a patched URL with `.vector`, forcing
2171
2404
  // idbFetchHandler to fall through its pbf/mvt/png/jpg/webp fallback loop
2172
2405
  // on every tile.
2173
- source.tiles = source.tiles.map((url) => {
2406
+ source.tiles = originalTiles.map((url) => {
2174
2407
  const ext = tileExtension ?? extractTileExtensionFromUrl(url);
2175
- return `idb://${downloadId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`;
2408
+ return `idb://${urlId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`;
2176
2409
  });
2177
2410
  styleLogger.debug(`Patched tiles for ${sourceKey} with extension .${tileExtension || 'pbf'}:`, {
2178
2411
  original: originalTiles,
@@ -2180,10 +2413,12 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
2180
2413
  });
2181
2414
  }
2182
2415
  // Cap maxzoom so the map doesn't request tiles beyond what we downloaded.
2183
- // Use the lower of the region maxZoom and the source's original maxzoom so we
2184
- // don't raise it above the tileset's actual range (e.g. 3dbuildings at z14).
2416
+ // Use the lower of the downloaded maxZoom and the source's *upstream*
2417
+ // maxzoom so we don't raise it above the tileset's actual range (e.g.
2418
+ // 3dbuildings at z14) — and so an earlier, shallower region's cap doesn't
2419
+ // stick once a deeper region is added.
2185
2420
  if (maxZoom !== undefined) {
2186
- const originalMaxzoom = source.maxzoom;
2421
+ const originalMaxzoom = source.__originalMaxzoom ?? undefined;
2187
2422
  source.maxzoom = originalMaxzoom !== undefined ? Math.min(maxZoom, originalMaxzoom) : maxZoom;
2188
2423
  styleLogger.debug(`Set maxzoom for ${sourceKey}: ${originalMaxzoom} → ${source.maxzoom}`);
2189
2424
  }
@@ -2196,7 +2431,7 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
2196
2431
  let ext = tileExtension;
2197
2432
  if (!ext)
2198
2433
  ext = 'pbf';
2199
- source.tiles = [`idb://${downloadId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`];
2434
+ source.tiles = [`idb://${urlId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`];
2200
2435
  styleLogger.debug(`Added tiles array for TileJSON-only source ${sourceKey}:`, {
2201
2436
  tiles: source.tiles,
2202
2437
  });
@@ -2212,7 +2447,7 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
2212
2447
  // Patch glyphs
2213
2448
  if (style.glyphs) {
2214
2449
  const originalGlyphs = style.glyphs;
2215
- style.glyphs = `idb://${downloadId}/glyph/{fontstack}/{range}.pbf`;
2450
+ style.glyphs = `idb://${urlId}/glyph/{fontstack}/{range}.pbf`;
2216
2451
  styleLogger.debug(`Patched glyphs:`, { original: originalGlyphs, patched: style.glyphs });
2217
2452
  }
2218
2453
  // Patch sprite
@@ -2271,13 +2506,31 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
2271
2506
  styleLogger.debug(`Final patched style:`, style);
2272
2507
  return style;
2273
2508
  }
2509
+ /** Resolve one entry of a font stack: a name, `["config", key]` or a `["concat", ...]` of those. */
2510
+ function resolveFontName(value, schema) {
2511
+ if (typeof value === 'string')
2512
+ return value;
2513
+ if (!Array.isArray(value))
2514
+ return undefined;
2515
+ if (value[0] === 'config' && typeof value[1] === 'string') {
2516
+ const configured = schema?.[value[1]]?.default;
2517
+ return typeof configured === 'string' ? configured : undefined;
2518
+ }
2519
+ if (value[0] === 'concat') {
2520
+ const parts = value.slice(1).map(part => resolveFontName(part, schema));
2521
+ return parts.every(part => part !== undefined) ? parts.join('') : undefined;
2522
+ }
2523
+ return undefined;
2524
+ }
2274
2525
  /**
2275
2526
  * Extract individual font names from a `text-font` layout property value.
2276
2527
  *
2277
- * Handles both simple arrays (`["Font A", "Font B"]`) and Mapbox GL expression
2278
- * syntax (e.g. `["step", ["zoom"], ["literal", ["Font A"]], 10, ["literal", ["Font B"]]]`).
2528
+ * Handles simple arrays (`["Font A", "Font B"]`), Mapbox GL expression syntax
2529
+ * (e.g. `["step", ["zoom"], ["literal", ["Font A"]], 10, ["literal", ["Font B"]]]`),
2530
+ * and Mapbox Standard's config-built stacks
2531
+ * (`[["concat", ["config", "font"], " Medium"], "Arial Unicode MS Bold"]`).
2279
2532
  */
2280
- function extractFontNamesFromTextField(textFont) {
2533
+ function extractFontNamesFromTextField(textFont, schema) {
2281
2534
  const fonts = new Set();
2282
2535
  // Known expression operators — anything starting with one of these is NOT a font name list
2283
2536
  const expressionOps = new Set([
@@ -2317,20 +2570,40 @@ function extractFontNamesFromTextField(textFont) {
2317
2570
  }
2318
2571
  return;
2319
2572
  }
2320
- // Simple font stack: every element is a string and the first element is
2321
- // NOT a known expression operator
2322
- if (value.every((v) => typeof v === 'string') && !expressionOps.has(value[0])) {
2323
- for (const f of value) {
2324
- fonts.add(f);
2573
+ // A font stack: only font names, and the first isn't an expression operator
2574
+ const isFontEntry = (item) => typeof item === 'string' ||
2575
+ (Array.isArray(item) && (item[0] === 'config' || item[0] === 'concat'));
2576
+ if (value.every(isFontEntry) && !expressionOps.has(value[0])) {
2577
+ for (const item of value) {
2578
+ const name = resolveFontName(item, schema);
2579
+ if (name !== undefined)
2580
+ fonts.add(name);
2325
2581
  }
2326
2582
  return;
2327
2583
  }
2328
- // Recurse into sub-arrays (covers step/match/case branches)
2329
- for (const item of value) {
2330
- if (Array.isArray(item)) {
2331
- walk(item);
2584
+ // Only the branches that output a font. Conditions and inputs hold strings
2585
+ // like `["zoom"]` or match labels that aren't fonts.
2586
+ const last = value.length - 1;
2587
+ const isOutput = (i) => {
2588
+ switch (value[0]) {
2589
+ case 'step': // ["step", input, out0, stop, out, ...]
2590
+ return i >= 2 && i % 2 === 0;
2591
+ case 'match': // ["match", input, label, out, label, out, ..., fallback]
2592
+ return i === last || (i >= 3 && i % 2 === 1);
2593
+ case 'case': // ["case", cond, out, cond, out, ..., fallback]
2594
+ return i === last || (i >= 2 && i % 2 === 0);
2595
+ case 'coalesce':
2596
+ return i >= 1;
2597
+ case 'let': // ["let", name, value, ..., body]
2598
+ return i === last;
2599
+ default:
2600
+ return false;
2332
2601
  }
2333
- }
2602
+ };
2603
+ value.forEach((item, i) => {
2604
+ if (isOutput(i))
2605
+ walk(item);
2606
+ });
2334
2607
  }
2335
2608
  walk(textFont);
2336
2609
  return Array.from(fonts);
@@ -2358,7 +2631,7 @@ function extractAllFontNames(style) {
2358
2631
  if (style && Array.isArray(style.layers)) {
2359
2632
  for (const layer of style.layers) {
2360
2633
  if (layer.layout && layer.layout['text-font']) {
2361
- for (const name of extractFontNamesFromTextField(layer.layout['text-font'])) {
2634
+ for (const name of extractFontNamesFromTextField(layer.layout['text-font'], style.schema)) {
2362
2635
  fonts.add(name);
2363
2636
  }
2364
2637
  }
@@ -2427,8 +2700,13 @@ var styleUtils = /*#__PURE__*/Object.freeze({
2427
2700
  extractAllFontNames: extractAllFontNames,
2428
2701
  extractFontNamesFromTextField: extractFontNamesFromTextField,
2429
2702
  generateGlyphUrlsFromStyle: generateGlyphUrlsFromStyle,
2703
+ injectExtraSources: injectExtraSources,
2704
+ isMapboxGlMap: isMapboxGlMap,
2705
+ needsMapboxGl: needsMapboxGl,
2430
2706
  normalizeSpriteProperty: normalizeSpriteProperty,
2431
- patchStyleForOffline: patchStyleForOffline
2707
+ patchStyleForOffline: patchStyleForOffline,
2708
+ restoreUnstoredModels: restoreUnstoredModels,
2709
+ stashOriginalModels: stashOriginalModels
2432
2710
  });
2433
2711
 
2434
2712
  const cleanupLogger$1 = logger.scope('CleanupCompressedTiles');
@@ -2985,7 +3263,7 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
2985
3263
  // Collect all flattened sources, layers from imports
2986
3264
  const flattenedSources = {};
2987
3265
  const flattenedLayers = [];
2988
- let importedSprite;
3266
+ const importSprites = [];
2989
3267
  let importedGlyphs;
2990
3268
  let importedModels;
2991
3269
  for (const importEntry of imports) {
@@ -3037,6 +3315,7 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
3037
3315
  // Build config lookup: schema defaults + import config overrides
3038
3316
  const configValues = buildConfigLookup(importedStyle, importEntry.config);
3039
3317
  // Flatten layers: prefix layer IDs, update source references, resolve config expressions
3318
+ const importLayers = [];
3040
3319
  if (importedStyle.layers && Array.isArray(importedStyle.layers)) {
3041
3320
  for (const layer of importedStyle.layers) {
3042
3321
  const layerObj = layer;
@@ -3052,11 +3331,13 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
3052
3331
  // Resolve ["config", "key"] expressions using schema defaults and import overrides.
3053
3332
  resolveConfigExpressions(prefixedLayer, configValues);
3054
3333
  flattenedLayers.push(prefixedLayer);
3334
+ importLayers.push(prefixedLayer);
3055
3335
  }
3056
3336
  }
3057
- // Sprite: collect imported sprite
3058
- if (importedStyle.sprite && !importedSprite) {
3059
- importedSprite = importedStyle.sprite;
3337
+ // Sprite: collect every import's sprite with its layers, so each can be
3338
+ // scoped if the flattened style ends up with more than one sprite.
3339
+ if (importedStyle.sprite) {
3340
+ importSprites.push({ importId, sprite: importedStyle.sprite, layers: importLayers });
3060
3341
  }
3061
3342
  // Glyphs: collect imported glyphs
3062
3343
  if (importedStyle.glyphs && !importedGlyphs) {
@@ -3073,12 +3354,10 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
3073
3354
  // Layers: imported layers go first (bottom), outer layers on top
3074
3355
  const outerLayers = style.layers || [];
3075
3356
  style.layers = [...flattenedLayers, ...outerLayers];
3076
- // Sprite: merge if both exist
3077
- if (style.sprite && importedSprite) {
3078
- style.sprite = mergeSprites(style.sprite, importedSprite);
3079
- }
3080
- else if (!style.sprite && importedSprite) {
3081
- style.sprite = importedSprite;
3357
+ // Sprite: a single sprite stays as-is; several become an array sprite with
3358
+ // each import's image references scoped to its sprite id.
3359
+ if (importSprites.length > 0) {
3360
+ style.sprite = combineSprites(style.sprite, importSprites);
3082
3361
  }
3083
3362
  // Glyphs: outer wins; if absent, use imported
3084
3363
  if (!style.glyphs && importedGlyphs) {
@@ -3265,23 +3544,37 @@ function isExpression(value) {
3265
3544
  return Array.isArray(value) && value.length > 0 && typeof value[0] === 'string';
3266
3545
  }
3267
3546
  /**
3268
- * Merge two sprite values into array format.
3269
- * Handles string + string, string + array, array + string, array + array.
3270
- */
3271
- function mergeSprites(outer, imported) {
3272
- const result = [];
3273
- // Add imported sprites first (bottom)
3274
- if (typeof imported === 'string') {
3275
- result.push({ id: 'imported', url: imported });
3547
+ * Combine the outer style's sprite with its imports' sprites.
3548
+ *
3549
+ * With one sprite in total it is returned unchanged (a plain string keeps
3550
+ * working in Mapbox GL, which only accepts a string `sprite`). With several,
3551
+ * the result is a MapLibre array sprite: imports first, the outer sprite last
3552
+ * as `default`. Array-sprite images from any entry other than `default` are
3553
+ * only reachable as `{id}:{name}`, so each string-sprite import gets its
3554
+ * import id as sprite id and its layers' image references are prefixed with
3555
+ * `{importId}:`. Imports that already use an array sprite keep their entries
3556
+ * and references unchanged.
3557
+ */
3558
+ function combineSprites(outer, imports) {
3559
+ if (!outer && imports.length === 1) {
3560
+ return imports[0].sprite;
3276
3561
  }
3277
- else if (Array.isArray(imported)) {
3278
- for (const entry of imported) {
3279
- if (entry && typeof entry.url === 'string') {
3280
- result.push(entry);
3562
+ const result = [];
3563
+ for (const { importId, sprite, layers } of imports) {
3564
+ if (typeof sprite === 'string') {
3565
+ result.push({ id: importId, url: sprite });
3566
+ for (const layer of layers) {
3567
+ prefixLayerImages(layer, `${importId}:`);
3568
+ }
3569
+ }
3570
+ else if (Array.isArray(sprite)) {
3571
+ for (const entry of sprite) {
3572
+ if (entry && typeof entry.url === 'string') {
3573
+ result.push(entry);
3574
+ }
3281
3575
  }
3282
3576
  }
3283
3577
  }
3284
- // Add outer sprites on top
3285
3578
  if (typeof outer === 'string') {
3286
3579
  result.push({ id: 'default', url: outer });
3287
3580
  }
@@ -3294,8 +3587,74 @@ function mergeSprites(outer, imported) {
3294
3587
  }
3295
3588
  return result;
3296
3589
  }
3590
+ /** Layer properties whose values name sprite images. */
3591
+ const IMAGE_PROPERTIES = [
3592
+ ['layout', 'icon-image'],
3593
+ ['paint', 'fill-pattern'],
3594
+ ['paint', 'line-pattern'],
3595
+ ['paint', 'fill-extrusion-pattern'],
3596
+ ['paint', 'background-pattern'],
3597
+ ];
3598
+ function prefixLayerImages(layer, prefix) {
3599
+ for (const [group, property] of IMAGE_PROPERTIES) {
3600
+ const props = layer[group];
3601
+ if (props && property in props) {
3602
+ props[property] = prefixImageValue(props[property], prefix);
3603
+ }
3604
+ }
3605
+ }
3606
+ /**
3607
+ * Prefix the image name(s) a property value resolves to. Handles plain names
3608
+ * (including `{token}` templates), legacy `stops` functions, and expressions:
3609
+ * `image` / `literal` directly, the output branches of `coalesce` / `case` /
3610
+ * `match` / `step`, and anything else string-valued via `concat`.
3611
+ */
3612
+ function prefixImageValue(value, prefix) {
3613
+ if (typeof value === 'string') {
3614
+ return value === '' ? value : `${prefix}${value}`;
3615
+ }
3616
+ if (Array.isArray(value)) {
3617
+ if (value.length === 0 || typeof value[0] !== 'string')
3618
+ return value;
3619
+ const [op, ...args] = value;
3620
+ const mapAt = (isOutput) => value.map((arg, i) => (i > 0 && isOutput(i) ? prefixImageValue(arg, prefix) : arg));
3621
+ const lastIndex = value.length - 1;
3622
+ switch (op) {
3623
+ case 'image':
3624
+ return ['image', prefixImageValue(args[0], prefix), ...args.slice(1)];
3625
+ case 'literal':
3626
+ return typeof args[0] === 'string' ? ['literal', `${prefix}${args[0]}`] : value;
3627
+ case 'coalesce':
3628
+ return mapAt(() => true);
3629
+ case 'case':
3630
+ // ["case", cond, out, cond, out, ..., fallback]
3631
+ return mapAt(i => i % 2 === 0 || i === lastIndex);
3632
+ case 'match':
3633
+ // ["match", input, label, out, label, out, ..., fallback]
3634
+ return mapAt(i => (i >= 3 && i % 2 === 1) || i === lastIndex);
3635
+ case 'step':
3636
+ // ["step", input, out0, stop, out, ...]
3637
+ return mapAt(i => i >= 2 && i % 2 === 0);
3638
+ default:
3639
+ return ['concat', prefix, value];
3640
+ }
3641
+ }
3642
+ if (value && typeof value === 'object') {
3643
+ const fn = value;
3644
+ if (Array.isArray(fn.stops)) {
3645
+ return {
3646
+ ...fn,
3647
+ stops: fn.stops.map(stop => Array.isArray(stop) ? [stop[0], prefixImageValue(stop[1], prefix)] : stop),
3648
+ ...('default' in fn
3649
+ ? { default: prefixImageValue(fn.default, prefix) }
3650
+ : {}),
3651
+ };
3652
+ }
3653
+ }
3654
+ return value;
3655
+ }
3297
3656
 
3298
- const DEFAULT_WASM_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.14.1/dist/';
3657
+ const DEFAULT_WASM_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.15.2/dist/';
3299
3658
  let currentConfig = {};
3300
3659
  let sqlJsPromise = null;
3301
3660
  /**
@@ -3356,100 +3715,587 @@ async function getSqlJs() {
3356
3715
  return sqlJsPromise;
3357
3716
  }
3358
3717
 
3359
- const fontLogger = logger.scope('FontService');
3360
- class FontService {
3718
+ const modelLogger = logger.scope('ModelService');
3719
+ /**
3720
+ * Build the storage key for a model. Kept consistent with sprite/glyph
3721
+ * conventions: `{styleId}::model::{modelName}`.
3722
+ */
3723
+ function modelKey(styleId, modelName) {
3724
+ return `${styleId}::model::${modelName}`;
3725
+ }
3726
+ /** Names of the models stored for a style. */
3727
+ async function storedModelNames(styleId) {
3728
+ const db = await dbPromise;
3729
+ const prefix = modelKey(styleId, '');
3730
+ const keys = await db.getAllKeys('models', IDBKeyRange.bound(prefix, `${prefix}￿`));
3731
+ return new Set(keys.map(key => String(key).slice(prefix.length)));
3732
+ }
3733
+ /** True when the given key belongs to the given styleId's model store prefix. */
3734
+ function modelKeyBelongsToStyle(key, styleId) {
3735
+ return key.startsWith(`${styleId}::model::`);
3736
+ }
3737
+ /**
3738
+ * Service for downloading, storing, and serving Mapbox 3D model (.glb) files.
3739
+ *
3740
+ * Mapbox Standard declares 32 models at the top of `style.models`:
3741
+ *
3742
+ * ```json
3743
+ * {
3744
+ * "maple1-lod1": "mapbox://models/mapbox/maple1-v4-lod1.glb",
3745
+ * ...
3746
+ * }
3747
+ * ```
3748
+ *
3749
+ * `model` layers (e.g. `trees`, `wind-turbine-towers`) pick one by name at
3750
+ * render time. For offline use each referenced URL is fetched and stored
3751
+ * here, and `patchStyleForOffline` rewrites the dictionary entries to
3752
+ * `idb://{styleId}/model/{name}` URLs.
3753
+ */
3754
+ class ModelService {
3361
3755
  db = dbPromise;
3362
- async downloadFonts(fontUrls, styleName, options = {}) {
3756
+ /**
3757
+ * Download the set of models referenced by `style.models` for one style.
3758
+ *
3759
+ * @param models `{ modelName: resolvedHttpUrl }` — URLs must already be
3760
+ * resolved (mapbox:// URLs should be resolved by the caller).
3761
+ * @param styleId The owning style's key.
3762
+ */
3763
+ async downloadModels(models, styleId, options = {}) {
3363
3764
  const db = await this.db;
3364
- const { batchSize = 10, maxRetries = 3, skipExisting = true, retryDelay = 1000, timeout = 30000, validateFonts = true, storageQuotaCheck = true, quietMode = false, } = options;
3365
- const startTime = Date.now();
3366
- let totalSize = 0;
3367
- let downloadedFonts = 0;
3368
- let skippedFonts = 0;
3369
- let failedFonts = 0;
3370
- const errors = [];
3371
- const fontsByType = {};
3372
- // Create progress tracker
3373
- const progressTracker = createProgressTracker(fontUrls.length);
3374
- // Filter existing fonts if skipExisting is true
3375
- let urlsToDownload = fontUrls;
3765
+ const { onProgress, batchSize = 4, maxRetries = 3, skipExisting = true, timeoutMs = 30000, } = options;
3766
+ const entries = Object.entries(models);
3767
+ const progressTracker = createProgressTracker(entries.length);
3768
+ const result = {
3769
+ totalModels: entries.length,
3770
+ downloadedModels: 0,
3771
+ skippedModels: 0,
3772
+ failedModels: 0,
3773
+ totalSize: 0,
3774
+ errors: [],
3775
+ };
3776
+ const emit = () => onProgress?.(progressTracker.getProgress());
3777
+ emit();
3778
+ if (entries.length === 0)
3779
+ return result;
3780
+ // Pre-compute existing keys for skipExisting
3781
+ const existingKeys = new Set();
3376
3782
  if (skipExisting) {
3377
- const existingFonts = new Set();
3378
- const tx = db.transaction(['fonts'], 'readonly');
3379
- let cursor = await tx.objectStore('fonts').openCursor();
3380
- while (cursor) {
3381
- existingFonts.add(cursor.value.key);
3382
- cursor = await cursor.continue();
3783
+ const tx = db.transaction('models', 'readonly');
3784
+ for await (const cursor of tx.store) {
3785
+ existingKeys.add(cursor.value.key);
3383
3786
  }
3384
- urlsToDownload = fontUrls.filter(url => {
3385
- const key = this.createFontKey(url, styleName);
3386
- return !existingFonts.has(key);
3387
- });
3388
- skippedFonts = fontUrls.length - urlsToDownload.length;
3389
3787
  }
3390
- // Check storage quota if enabled
3391
- if (storageQuotaCheck && 'storage' in navigator && 'estimate' in navigator.storage) {
3392
- const estimate = await navigator.storage.estimate();
3393
- const usedSpace = estimate.usage || 0;
3394
- const availableSpace = (estimate.quota || 0) - usedSpace;
3395
- if (availableSpace < 50 * 1024 * 1024) {
3396
- // Less than 50MB
3397
- throw new Error('Insufficient storage space for font download');
3788
+ await processBatch(entries, async ([modelName, url]) => {
3789
+ const key = modelKey(styleId, modelName);
3790
+ const label = `${styleId}::${modelName}`;
3791
+ if (skipExisting && existingKeys.has(key)) {
3792
+ result.skippedModels++;
3793
+ progressTracker.update(1, label);
3794
+ emit();
3795
+ return;
3398
3796
  }
3399
- }
3400
- // Process fonts in batches with concurrency control
3401
- await processBatch(urlsToDownload, async (fontUrl) => {
3402
3797
  try {
3403
- const fontKey = this.createFontKey(fontUrl, styleName);
3404
- const response = await fetchResourceWithRetry(fontUrl, {
3798
+ const response = await fetchResourceWithRetry(url, {
3405
3799
  retries: maxRetries,
3406
- retryDelay,
3407
- timeout,
3408
- proxyType: 'fonts',
3800
+ timeout: timeoutMs,
3801
+ proxyType: 'tiles',
3409
3802
  });
3410
3803
  if (response.type === 'json') {
3411
- throw new Error(`Unexpected JSON response when fetching font: ${fontUrl}`);
3412
- }
3413
- const fontData = response.data;
3414
- const contentType = response.contentType ||
3415
- (response.type === 'other' ? 'font/opentype' : `font/${response.type}`);
3416
- // Validate font if enabled
3417
- if (validateFonts) {
3418
- await this.validateFont(fontData, contentType);
3804
+ throw new Error('Unexpected JSON response for model');
3419
3805
  }
3420
- // Store font in database
3421
- const fontEntry = {
3422
- key: fontKey,
3423
- url: fontUrl,
3424
- originalUrl: fontUrl,
3425
- data: fontData,
3806
+ const data = response.data;
3807
+ const contentType = ('contentType' in response && response.contentType) || 'model/gltf-binary';
3808
+ const entry = {
3809
+ key,
3810
+ data,
3426
3811
  contentType,
3427
- type: this.detectFontType(contentType, fontUrl),
3428
- size: fontData.byteLength,
3812
+ size: data.byteLength,
3813
+ url,
3814
+ styleId,
3815
+ modelName,
3429
3816
  lastModified: Date.now(),
3430
3817
  downloadedAt: new Date().toISOString(),
3431
- downloadId: styleName,
3432
- metadata: {
3433
- userAgent: navigator.userAgent,
3434
- downloadTimestamp: Date.now(),
3435
- },
3436
3818
  expires: response.expires,
3437
3819
  };
3438
- await db.put('fonts', fontEntry);
3439
- progressTracker.update(1, fontKey);
3440
- totalSize += fontData.byteLength;
3441
- downloadedFonts++;
3442
- // Track font types
3443
- const fontType = this.detectFontType(contentType, fontUrl);
3444
- fontsByType[fontType] = (fontsByType[fontType] || 0) + 1;
3820
+ await db.put('models', entry);
3821
+ result.downloadedModels++;
3822
+ result.totalSize += data.byteLength;
3823
+ progressTracker.update(1, label);
3445
3824
  }
3446
- catch (error) {
3447
- failedFonts++;
3448
- const errorMessage = error instanceof Error ? error.message : String(error);
3449
- errors.push({
3450
- url: fontUrl,
3451
- error: errorMessage,
3825
+ catch (err) {
3826
+ const message = err instanceof Error ? err.message : String(err);
3827
+ result.failedModels++;
3828
+ result.errors.push({ url, error: message });
3829
+ modelLogger.warn(`Failed to download model "${modelName}" from ${url}:`, err);
3830
+ progressTracker.update(1, label, message);
3831
+ }
3832
+ emit();
3833
+ }, { batchSize });
3834
+ modelLogger.info(`Models downloaded for style ${styleId}: ${result.downloadedModels} new, ${result.skippedModels} skipped, ${result.failedModels} failed`);
3835
+ return result;
3836
+ }
3837
+ /** Retrieve a single model by `{styleId, modelName}`. */
3838
+ async getModel(styleId, modelName) {
3839
+ const db = await this.db;
3840
+ return db.get('models', modelKey(styleId, modelName));
3841
+ }
3842
+ /** Aggregate stats across all stored models. */
3843
+ async getModelStats() {
3844
+ const db = await this.db;
3845
+ const stats = {
3846
+ count: 0,
3847
+ totalSize: 0,
3848
+ averageSize: 0,
3849
+ models: [],
3850
+ modelsByStyle: {},
3851
+ };
3852
+ const tx = db.transaction('models', 'readonly');
3853
+ for await (const cursor of tx.store) {
3854
+ const m = cursor.value;
3855
+ stats.count++;
3856
+ stats.totalSize += m.size;
3857
+ stats.models.push({ name: m.modelName, size: m.size, lastModified: m.lastModified });
3858
+ stats.modelsByStyle[m.styleId] = (stats.modelsByStyle[m.styleId] ?? 0) + 1;
3859
+ }
3860
+ stats.averageSize = stats.count > 0 ? stats.totalSize / stats.count : 0;
3861
+ return stats;
3862
+ }
3863
+ /**
3864
+ * Delete models older than `maxAge` days. Defaults to 30.
3865
+ */
3866
+ async cleanupOldModels(maxAge = 30) {
3867
+ const db = await this.db;
3868
+ const cutoff = Date.now() - maxAge * 24 * 60 * 60 * 1000;
3869
+ let deleted = 0;
3870
+ const tx = db.transaction('models', 'readwrite');
3871
+ for await (const cursor of tx.store) {
3872
+ if (cursor.value.lastModified < cutoff) {
3873
+ await cursor.delete();
3874
+ deleted++;
3875
+ }
3876
+ }
3877
+ return deleted;
3878
+ }
3879
+ /**
3880
+ * Basic integrity check: remove entries with empty/missing data.
3881
+ */
3882
+ async verifyAndRepairModels() {
3883
+ const db = await this.db;
3884
+ let verified = 0;
3885
+ let removed = 0;
3886
+ const tx = db.transaction('models', 'readwrite');
3887
+ for await (const cursor of tx.store) {
3888
+ const m = cursor.value;
3889
+ if (!m.data || m.data.byteLength === 0) {
3890
+ await cursor.delete();
3891
+ removed++;
3892
+ }
3893
+ else {
3894
+ verified++;
3895
+ }
3896
+ }
3897
+ return { verified, repaired: 0, removed };
3898
+ }
3899
+ }
3900
+ // Singleton + convenience exports, matching other service modules.
3901
+ const modelService = new ModelService();
3902
+ const downloadModels = (models, styleId, options) => modelService.downloadModels(models, styleId, options);
3903
+ const getModel = (styleId, modelName) => modelService.getModel(styleId, modelName);
3904
+ const getModelStats = () => modelService.getModelStats();
3905
+ const cleanupOldModels = (maxAge) => modelService.cleanupOldModels(maxAge);
3906
+ const verifyAndRepairModels = () => modelService.verifyAndRepairModels();
3907
+
3908
+ var modelService$1 = /*#__PURE__*/Object.freeze({
3909
+ __proto__: null,
3910
+ ModelService: ModelService,
3911
+ cleanupOldModels: cleanupOldModels,
3912
+ downloadModels: downloadModels,
3913
+ getModel: getModel,
3914
+ getModelStats: getModelStats,
3915
+ modelKeyBelongsToStyle: modelKeyBelongsToStyle,
3916
+ modelService: modelService,
3917
+ storedModelNames: storedModelNames,
3918
+ verifyAndRepairModels: verifyAndRepairModels
3919
+ });
3920
+
3921
+ /**
3922
+ * Mapbox GL v3 style-config helpers.
3923
+ *
3924
+ * Mapbox Standard is one style with switchable features: most of its layers set
3925
+ * `layout.visibility` from `["config", key]` expressions backed by `style.schema`
3926
+ * defaults. These helpers work out which sources the style actually needs for
3927
+ * the current config, so features that are switched off aren't downloaded and
3928
+ * features that are switched on aren't skipped.
3929
+ */
3930
+ const configLogger = logger.scope('StyleConfig');
3931
+ /** Sentinel for an expression that can't be decided from config alone. */
3932
+ const UNKNOWN = Symbol('unknown');
3933
+ /**
3934
+ * True for styles whose feature set is config-driven: they declare a `schema`,
3935
+ * or were flattened from `imports` (whose config is already resolved into the
3936
+ * layers). Other styles are never filtered by config.
3937
+ */
3938
+ function isConfigDrivenStyle(style) {
3939
+ const s = style;
3940
+ if (!s)
3941
+ return false;
3942
+ return ((s.schema !== undefined && Object.keys(s.schema).length > 0) ||
3943
+ (Array.isArray(s.imports) && s.imports.length > 0));
3944
+ }
3945
+ function evaluate(expr, config) {
3946
+ if (!Array.isArray(expr))
3947
+ return expr;
3948
+ const [op, ...args] = expr;
3949
+ switch (op) {
3950
+ case 'literal':
3951
+ return args[0];
3952
+ case 'config': {
3953
+ const key = args[0];
3954
+ return typeof key === 'string' && key in config ? config[key] : UNKNOWN;
3955
+ }
3956
+ case '!': {
3957
+ const value = evaluate(args[0], config);
3958
+ return typeof value === 'boolean' ? !value : UNKNOWN;
3959
+ }
3960
+ case 'all':
3961
+ case 'any': {
3962
+ const decisive = op === 'any';
3963
+ let sawUnknown = false;
3964
+ for (const arg of args) {
3965
+ const value = evaluate(arg, config);
3966
+ if (value === decisive)
3967
+ return decisive;
3968
+ if (typeof value !== 'boolean')
3969
+ sawUnknown = true;
3970
+ }
3971
+ return sawUnknown ? UNKNOWN : !decisive;
3972
+ }
3973
+ case '==':
3974
+ case '!=': {
3975
+ const a = evaluate(args[0], config);
3976
+ const b = evaluate(args[1], config);
3977
+ if (a === UNKNOWN || b === UNKNOWN)
3978
+ return UNKNOWN;
3979
+ return op === '==' ? a === b : a !== b;
3980
+ }
3981
+ case 'case': {
3982
+ // ["case", cond, out, cond, out, ..., fallback]
3983
+ for (let i = 0; i < args.length - 1; i += 2) {
3984
+ const condition = evaluate(args[i], config);
3985
+ if (typeof condition !== 'boolean')
3986
+ return UNKNOWN;
3987
+ if (condition)
3988
+ return evaluate(args[i + 1], config);
3989
+ }
3990
+ return evaluate(args[args.length - 1], config);
3991
+ }
3992
+ case 'match': {
3993
+ // ["match", input, label, out, label, out, ..., fallback]
3994
+ const input = evaluate(args[0], config);
3995
+ if (input === UNKNOWN)
3996
+ return UNKNOWN;
3997
+ for (let i = 1; i < args.length - 1; i += 2) {
3998
+ const label = args[i];
3999
+ if (Array.isArray(label) ? label.includes(input) : label === input) {
4000
+ return evaluate(args[i + 1], config);
4001
+ }
4002
+ }
4003
+ return evaluate(args[args.length - 1], config);
4004
+ }
4005
+ default:
4006
+ return UNKNOWN;
4007
+ }
4008
+ }
4009
+ function collectIconsetIds(value, ids) {
4010
+ if (Array.isArray(value)) {
4011
+ for (const item of value)
4012
+ collectIconsetIds(item, ids);
4013
+ }
4014
+ else if (value && typeof value === 'object') {
4015
+ const record = value;
4016
+ const iconset = record.iconset;
4017
+ if (iconset && typeof iconset.id === 'string')
4018
+ ids.add(iconset.id);
4019
+ for (const item of Object.values(record))
4020
+ collectIconsetIds(item, ids);
4021
+ }
4022
+ }
4023
+ /**
4024
+ * Sources a config-driven style uses under its current config (`active`), and
4025
+ * those it references but only from hidden layers or unused iconsets (`inactive`).
4026
+ *
4027
+ * Deliberately errs towards keeping sources: a visibility expression that can't
4028
+ * be decided counts as visible. Sources nothing in the style references
4029
+ * (`extraSources`, ones a caller adds at runtime) are in neither set.
4030
+ */
4031
+ function classifySources(style) {
4032
+ const inactive = new Set();
4033
+ const active = new Set();
4034
+ if (!isConfigDrivenStyle(style))
4035
+ return { active, inactive };
4036
+ const s = style;
4037
+ const config = {};
4038
+ for (const [key, entry] of Object.entries(s.schema ?? {})) {
4039
+ if (entry && typeof entry === 'object' && 'default' in entry)
4040
+ config[key] = entry.default;
4041
+ }
4042
+ const activeIconsets = new Set();
4043
+ for (const layer of (s.layers ?? [])) {
4044
+ const visible = evaluate(layer?.layout?.visibility ?? 'visible', config) !== 'none';
4045
+ if (typeof layer?.source === 'string') {
4046
+ inactive.add(layer.source);
4047
+ if (visible)
4048
+ active.add(layer.source);
4049
+ }
4050
+ if (visible) {
4051
+ // The whole layer, since `appearances` can also name an iconset.
4052
+ collectIconsetIds(layer, activeIconsets);
4053
+ }
4054
+ }
4055
+ for (const [id, iconset] of Object.entries(s.iconsets ?? {})) {
4056
+ if (typeof iconset?.source !== 'string')
4057
+ continue;
4058
+ inactive.add(iconset.source);
4059
+ if (activeIconsets.has(id))
4060
+ active.add(iconset.source);
4061
+ }
4062
+ if (typeof s.terrain?.source === 'string')
4063
+ active.add(s.terrain.source);
4064
+ for (const id of active)
4065
+ inactive.delete(id);
4066
+ return { active, inactive };
4067
+ }
4068
+ /**
4069
+ * Write config values into a style's `schema` defaults. `["config", key]` reads
4070
+ * those defaults at render time, so this one edit drives both what gets
4071
+ * downloaded and what the stored style shows offline. Keys the schema doesn't
4072
+ * declare are ignored with a warning.
4073
+ */
4074
+ function applyStyleConfig(style, config) {
4075
+ const schema = style.schema;
4076
+ for (const [key, value] of Object.entries(config)) {
4077
+ const entry = schema?.[key];
4078
+ if (entry && typeof entry === 'object') {
4079
+ entry.default = value;
4080
+ }
4081
+ else {
4082
+ configLogger.warn(`Ignoring styleConfig "${key}": the style's schema doesn't declare it`);
4083
+ }
4084
+ }
4085
+ }
4086
+
4087
+ /**
4088
+ * XYZ tile range covering `bounds` at zoom `z`. Shared by the download
4089
+ * pipeline (which fetches exactly this range) and region-scoped consumers
4090
+ * like MBTiles export, so both agree on which tiles belong to a region.
4091
+ */
4092
+ function getTileRangeAtZoom(bounds, z) {
4093
+ const minTile = pointToTileClamped(bounds[0][0], bounds[0][1], z);
4094
+ const maxTile = pointToTileClamped(bounds[1][0], bounds[1][1], z);
4095
+ return {
4096
+ minX: Math.min(minTile[0], maxTile[0]),
4097
+ maxX: Math.max(minTile[0], maxTile[0]),
4098
+ minY: Math.min(minTile[1], maxTile[1]),
4099
+ maxY: Math.max(minTile[1], maxTile[1]),
4100
+ };
4101
+ }
4102
+ /** Web Mercator's latitude limit; tiles don't exist beyond it. */
4103
+ const MAX_MERCATOR_LAT = 85.0511287798066;
4104
+ /**
4105
+ * `tilebelt.pointToTile`, kept on the tile grid. tilebelt wraps longitude 180
4106
+ * to x = 0, so a region whose east edge is the antimeridian became the range
4107
+ * 0..maxX — nearly the whole world at every zoom. Latitudes beyond the
4108
+ * Mercator limit likewise fall off the grid.
4109
+ */
4110
+ function pointToTileClamped(lon, lat, z) {
4111
+ const maxIndex = 2 ** z - 1;
4112
+ const clampedLon = Math.max(-180, Math.min(180, lon));
4113
+ const clampedLat = Math.max(-MAX_MERCATOR_LAT, Math.min(MAX_MERCATOR_LAT, lat));
4114
+ const [x, y] = tilebelt__namespace.pointToTile(clampedLon, clampedLat, z);
4115
+ return [
4116
+ clampedLon === 180 ? maxIndex : Math.max(0, Math.min(maxIndex, x)),
4117
+ Math.max(0, Math.min(maxIndex, y)),
4118
+ ];
4119
+ }
4120
+ /**
4121
+ * Whether tile `z/x/y` falls inside `bounds` and `[minZoom, maxZoom]`.
4122
+ */
4123
+ function isTileInRegion(z, x, y, region) {
4124
+ if (z < region.minZoom || z > region.maxZoom)
4125
+ return false;
4126
+ const { minX, maxX, minY, maxY } = getTileRangeAtZoom(region.bounds, z);
4127
+ return x >= minX && x <= maxX && y >= minY && y <= maxY;
4128
+ }
4129
+ /**
4130
+ * Zoom levels to download for one source in `region`: the overlap of the
4131
+ * region's and the source's zoom ranges. When they don't overlap, the source
4132
+ * still gets the zooms the map needs from it:
4133
+ * - source ends below the region (`maxzoom` < region min) → its `maxzoom`,
4134
+ * which the renderer overzooms for the region's zooms;
4135
+ * - source starts above the region (`minzoom` > region max) → `minzoom`
4136
+ * through `maxzoom` (or just `minzoom` if unbounded).
4137
+ */
4138
+ function sourceZoomRange(region, sourceMinZoom, sourceMaxZoom) {
4139
+ const srcMin = sourceMinZoom !== undefined ? Math.ceil(sourceMinZoom) : undefined;
4140
+ const srcMax = sourceMaxZoom !== undefined ? Math.floor(sourceMaxZoom) : undefined;
4141
+ if (srcMax !== undefined && srcMax < region.minZoom) {
4142
+ return { min: srcMax, max: srcMax };
4143
+ }
4144
+ if (srcMin !== undefined && srcMin > region.maxZoom) {
4145
+ return { min: srcMin, max: srcMax ?? srcMin };
4146
+ }
4147
+ return {
4148
+ min: Math.max(region.minZoom, srcMin ?? region.minZoom),
4149
+ max: Math.min(region.maxZoom, srcMax ?? region.maxZoom),
4150
+ };
4151
+ }
4152
+ /**
4153
+ * A stored source's upstream `maxzoom`. Patched styles cap `maxzoom` for
4154
+ * rendering and stash the upstream value under `__originalMaxzoom`.
4155
+ */
4156
+ function upstreamMaxzoom(source) {
4157
+ if (source?.__originalMaxzoom !== undefined)
4158
+ return source.__originalMaxzoom ?? undefined;
4159
+ return source?.maxzoom;
4160
+ }
4161
+ function hasValidBounds(region) {
4162
+ const b = region.bounds;
4163
+ return (Array.isArray(b) &&
4164
+ b.length === 2 &&
4165
+ b.every(corner => Array.isArray(corner) && corner.length === 2));
4166
+ }
4167
+ /**
4168
+ * Whether a stored tile was downloaded for `region`: inside its bounds, at a
4169
+ * zoom the download pipeline fetches for the tile's source (which can fall
4170
+ * outside the region's own range — see `sourceZoomRange`). `sources` is the
4171
+ * stored style's `sources`. Regions with malformed bounds match nothing.
4172
+ */
4173
+ function tileBelongsToRegion(tile, region, sources) {
4174
+ if (!hasValidBounds(region))
4175
+ return false;
4176
+ const source = tile.sourceId !== undefined ? sources[tile.sourceId] : undefined;
4177
+ const { min, max } = sourceZoomRange(region, source?.minzoom, upstreamMaxzoom(source));
4178
+ return isTileInRegion(tile.z, tile.x, tile.y, {
4179
+ bounds: region.bounds,
4180
+ minZoom: min,
4181
+ maxZoom: max,
4182
+ });
4183
+ }
4184
+
4185
+ const fontLogger = logger.scope('FontService');
4186
+ class FontService {
4187
+ db = dbPromise;
4188
+ async downloadFonts(fontUrls, styleName, options = {}) {
4189
+ const db = await this.db;
4190
+ const { batchSize = 10, maxRetries = 3, skipExisting = true, retryDelay = 1000, timeout = 30000, validateFonts = true, storageQuotaCheck = true, quietMode = false, onProgress, } = options;
4191
+ const startTime = Date.now();
4192
+ let totalSize = 0;
4193
+ let downloadedFonts = 0;
4194
+ let skippedFonts = 0;
4195
+ let failedFonts = 0;
4196
+ const errors = [];
4197
+ const fontsByType = {};
4198
+ // Create progress tracker
4199
+ const progressTracker = createProgressTracker(fontUrls.length);
4200
+ const reportProgress = (message) => {
4201
+ if (!onProgress)
4202
+ return;
4203
+ const progress = progressTracker.getProgress();
4204
+ onProgress({
4205
+ completed: progress.completed,
4206
+ total: progress.total,
4207
+ percentage: progress.percentage,
4208
+ message,
4209
+ errors: errors.map(e => e.error),
4210
+ });
4211
+ };
4212
+ // Filter existing fonts if skipExisting is true
4213
+ let urlsToDownload = fontUrls;
4214
+ if (skipExisting) {
4215
+ const existingFonts = new Set();
4216
+ const tx = db.transaction(['fonts'], 'readonly');
4217
+ let cursor = await tx.objectStore('fonts').openCursor();
4218
+ while (cursor) {
4219
+ existingFonts.add(cursor.value.key);
4220
+ cursor = await cursor.continue();
4221
+ }
4222
+ urlsToDownload = fontUrls.filter(url => {
4223
+ const key = this.createFontKey(url, styleName);
4224
+ return !existingFonts.has(key);
4225
+ });
4226
+ skippedFonts = fontUrls.length - urlsToDownload.length;
4227
+ // Skipped fonts count toward the total, so mark them done up front.
4228
+ if (skippedFonts > 0) {
4229
+ progressTracker.update(skippedFonts);
4230
+ }
4231
+ }
4232
+ // Check storage quota if enabled
4233
+ if (storageQuotaCheck && 'storage' in navigator && 'estimate' in navigator.storage) {
4234
+ const estimate = await navigator.storage.estimate();
4235
+ const usedSpace = estimate.usage || 0;
4236
+ const availableSpace = (estimate.quota || 0) - usedSpace;
4237
+ if (availableSpace < 50 * 1024 * 1024) {
4238
+ // Less than 50MB
4239
+ throw new Error('Insufficient storage space for font download');
4240
+ }
4241
+ }
4242
+ // Process fonts in batches with concurrency control
4243
+ await processBatch(urlsToDownload, async (fontUrl) => {
4244
+ try {
4245
+ const fontKey = this.createFontKey(fontUrl, styleName);
4246
+ const response = await fetchResourceWithRetry(fontUrl, {
4247
+ retries: maxRetries,
4248
+ retryDelay,
4249
+ timeout,
4250
+ proxyType: 'fonts',
4251
+ });
4252
+ if (response.type === 'json') {
4253
+ throw new Error(`Unexpected JSON response when fetching font: ${fontUrl}`);
4254
+ }
4255
+ const fontData = response.data;
4256
+ const contentType = response.contentType ||
4257
+ (response.type === 'other' ? 'font/opentype' : `font/${response.type}`);
4258
+ // Validate font if enabled
4259
+ if (validateFonts) {
4260
+ await this.validateFont(fontData, contentType);
4261
+ }
4262
+ // Store font in database
4263
+ const fontEntry = {
4264
+ key: fontKey,
4265
+ url: fontUrl,
4266
+ originalUrl: fontUrl,
4267
+ data: fontData,
4268
+ contentType,
4269
+ type: this.detectFontType(contentType, fontUrl),
4270
+ size: fontData.byteLength,
4271
+ lastModified: Date.now(),
4272
+ downloadedAt: new Date().toISOString(),
4273
+ downloadId: styleName,
4274
+ metadata: {
4275
+ userAgent: navigator.userAgent,
4276
+ downloadTimestamp: Date.now(),
4277
+ },
4278
+ expires: response.expires,
4279
+ };
4280
+ await db.put('fonts', fontEntry);
4281
+ progressTracker.update(1, fontKey);
4282
+ reportProgress(`Downloaded ${fontKey}`);
4283
+ totalSize += fontData.byteLength;
4284
+ downloadedFonts++;
4285
+ // Track font types
4286
+ const fontType = this.detectFontType(contentType, fontUrl);
4287
+ fontsByType[fontType] = (fontsByType[fontType] || 0) + 1;
4288
+ }
4289
+ catch (error) {
4290
+ failedFonts++;
4291
+ const errorMessage = error instanceof Error ? error.message : String(error);
4292
+ errors.push({
4293
+ url: fontUrl,
4294
+ error: errorMessage,
3452
4295
  });
4296
+ // A failed font is finished too; don't let the bar stall on it.
4297
+ progressTracker.update(1, fontUrl);
4298
+ reportProgress(`Failed: ${fontUrl}`);
3453
4299
  // Only log detailed errors if not in quiet mode
3454
4300
  if (!quietMode) {
3455
4301
  fontLogger.error(`Failed to download font ${fontUrl}:`, errorMessage);
@@ -3462,6 +4308,7 @@ class FontService {
3462
4308
  }, { batchSize });
3463
4309
  const downloadTime = Date.now() - startTime;
3464
4310
  const averageSpeed = downloadTime > 0 ? (totalSize / downloadTime) * 1000 : 0;
4311
+ reportProgress(`Font download complete: ${downloadedFonts} downloaded, ${skippedFonts} skipped, ${failedFonts} failed`);
3465
4312
  return {
3466
4313
  totalFonts: fontUrls.length,
3467
4314
  downloadedFonts,
@@ -3788,6 +4635,10 @@ class SpriteService {
3788
4635
  return !existingSprites.has(key);
3789
4636
  });
3790
4637
  skippedSprites = spriteUrls.length - urlsToDownload.length;
4638
+ // Skipped sprites count toward the total, so mark them done up front.
4639
+ if (skippedSprites > 0) {
4640
+ progressTracker.update(skippedSprites);
4641
+ }
3791
4642
  }
3792
4643
  // Check storage quota if enabled
3793
4644
  if (storageQuotaCheck && 'storage' in navigator && 'estimate' in navigator.storage) {
@@ -3903,7 +4754,9 @@ class SpriteService {
3903
4754
  error: errorMessage,
3904
4755
  });
3905
4756
  spriteLogger.error(`Failed to download sprite ${spriteUrl}:`, error);
3906
- // Update progress even on failure
4757
+ // Advance progress on failure too — a failed item is finished, and
4758
+ // otherwise the bar stalls, then jumps to 100% at the end.
4759
+ progressTracker.update(1, this.extractSpriteName(spriteUrl));
3907
4760
  if (onProgress) {
3908
4761
  const progress = progressTracker.getProgress();
3909
4762
  onProgress({
@@ -4217,8 +5070,75 @@ var spriteService$1 = /*#__PURE__*/Object.freeze({
4217
5070
  verifyAndRepairSprites: verifyAndRepairSprites
4218
5071
  });
4219
5072
 
4220
- // Helper functions to work with StyleEntry structure
4221
- function createStyleEntry(key, style, provider = 'auto', metadata = {}) {
5073
+ /** A style URL's identity, ignoring the access token (same style, different token). */
5074
+ function styleUrlIdentity(url) {
5075
+ try {
5076
+ const parsed = new URL(url);
5077
+ parsed.searchParams.delete('access_token');
5078
+ return parsed.toString();
5079
+ }
5080
+ catch {
5081
+ return url;
5082
+ }
5083
+ }
5084
+ /**
5085
+ * Pick the storage key for a style downloaded from `styleUrl`. `baseId` comes
5086
+ * from the style's `id`/`name`, which unrelated styles can share ("Streets"),
5087
+ * so a key already held by a style from a different URL is never reused:
5088
+ * fall back to the key this URL was stored under before, else a free
5089
+ * `${baseId}-N`. Entries without `originalUrl` (saved with
5090
+ * `includeMetadata: false`) are assumed to be the same style, so re-downloads
5091
+ * don't pile up duplicates; `carryOverRegions` keeps their regions either way.
5092
+ */
5093
+ async function resolveStyleKey(db, baseId, styleUrl) {
5094
+ const target = styleUrlIdentity(styleUrl);
5095
+ const isSameStyle = (entry) => !!entry.originalUrl && styleUrlIdentity(entry.originalUrl) === target;
5096
+ const existing = await db.get('styles', baseId);
5097
+ if (!existing || !existing.originalUrl || isSameStyle(existing))
5098
+ return baseId;
5099
+ const all = await db.getAll('styles');
5100
+ const previous = all.find(isSameStyle);
5101
+ if (previous)
5102
+ return previous.key;
5103
+ const taken = new Set(all.map(entry => entry.key));
5104
+ let n = 2;
5105
+ while (taken.has(`${baseId}-${n}`))
5106
+ n++;
5107
+ logger.warn(`Style key "${baseId}" is used by a style from another URL; storing ${styleUrl} as "${baseId}-${n}"`);
5108
+ return `${baseId}-${n}`;
5109
+ }
5110
+ /**
5111
+ * Re-downloading a style replaces its stored entry. Carry over the regions
5112
+ * already attached to it and re-patch the fresh style for them — otherwise
5113
+ * every existing region is dropped and their tiles are orphaned. Patches a
5114
+ * copy, so callers can keep using the unpatched style for resource downloads.
5115
+ */
5116
+ async function carryOverRegions(db, entry) {
5117
+ const existing = await db.get('styles', entry.key);
5118
+ const regions = existing?.regions;
5119
+ // Config set through `styleConfig` decides what renders and what's planned,
5120
+ // so it has to survive a fresh copy of the style.
5121
+ if (existing?.styleConfig) {
5122
+ entry.style = JSON.parse(JSON.stringify(entry.style));
5123
+ applyStyleConfig(entry.style, existing.styleConfig);
5124
+ entry.styleConfig = existing.styleConfig;
5125
+ }
5126
+ if (!Array.isArray(regions) || regions.length === 0)
5127
+ return;
5128
+ entry.regions = regions;
5129
+ entry.style = JSON.parse(JSON.stringify(entry.style));
5130
+ // Regions' extra sources were added to the old copy, so add them to this one.
5131
+ for (const region of regions) {
5132
+ injectExtraSources(entry.style, region.extraSources);
5133
+ }
5134
+ stashOriginalModels(entry);
5135
+ patchStyleForOffline(entry.style, entry.key, Math.max(...regions.map(region => region.maxZoom)), undefined, entry.key);
5136
+ const { storedModelNames } = await Promise.resolve().then(function () { return modelService$1; });
5137
+ restoreUnstoredModels(entry, await storedModelNames(entry.key));
5138
+ logger.debug(`Carried ${regions.length} region(s) over to re-downloaded style ${entry.key}`);
5139
+ }
5140
+ // Helper functions to work with StyleEntry structure
5141
+ function createStyleEntry(key, style, provider = 'auto', metadata = {}) {
4222
5142
  return {
4223
5143
  key,
4224
5144
  style,
@@ -4319,8 +5239,11 @@ async function downloadStyles(stylesUrl, options = {}) {
4319
5239
  layerCount: style.layers ? style.layers.length : 0,
4320
5240
  });
4321
5241
  }
5242
+ // Mapbox Standard now arrives flat, so resolveImports may not have run.
5243
+ sanitizeIndoorExpressions(style);
4322
5244
  // Check if style already exists in the database
4323
5245
  const db = await dbPromise;
5246
+ style.id = await resolveStyleKey(db, style.id, stylesUrl);
4324
5247
  if (skipExisting) {
4325
5248
  const existingStyle = await db.get('styles', style.id);
4326
5249
  if (existingStyle && typeof existingStyle === 'object') {
@@ -4384,7 +5307,7 @@ async function downloadStyles(stylesUrl, options = {}) {
4384
5307
  timeout: timeoutMs,
4385
5308
  retryDelay: 1000,
4386
5309
  });
4387
- const sourceData = await sourceResponse.json();
5310
+ const sourceData = selectTileJsonVariant(await sourceResponse.json());
4388
5311
  style.sources[sourceKey] = { ...source, ...sourceData }; // Embed source data
4389
5312
  sourcesEmbedded++;
4390
5313
  logger.debug(`Embedded source data for ${sourceKey}`);
@@ -4451,6 +5374,7 @@ async function downloadStyles(stylesUrl, options = {}) {
4451
5374
  }
4452
5375
  : {});
4453
5376
  // Save the style
5377
+ await carryOverRegions(db, styleStorageItem);
4454
5378
  await db.put('styles', styleStorageItem);
4455
5379
  logger.debug('Style with sources saved successfully');
4456
5380
  onProgress?.({
@@ -4724,18 +5648,26 @@ async function loadStyleById(styleId) {
4724
5648
  return null;
4725
5649
  }
4726
5650
  }
5651
+ /** Delete every style and everything stored for them. */
4727
5652
  async function deleteStyles() {
4728
5653
  try {
4729
5654
  const db = await dbPromise;
4730
- await db.clear('styles');
5655
+ await Promise.all(['tiles', 'fonts', 'glyphs', 'sprites', 'models', 'styles'].map(store => db.clear(store)));
5656
+ clearAllCaches();
4731
5657
  logger.debug('All styles deleted successfully');
4732
5658
  }
4733
5659
  catch (error) {
4734
5660
  logger.error('Error deleting styles:', error);
4735
5661
  }
4736
5662
  }
5663
+ /**
5664
+ * Delete a style, its regions and everything stored for it. The entry goes last,
5665
+ * so a failure part-way leaves the style listed and able to be deleted again.
5666
+ */
4737
5667
  async function deleteStyleById(styleId) {
4738
5668
  try {
5669
+ const { RegionService } = await Promise.resolve().then(function () { return regionService; });
5670
+ await new RegionService().deleteStyleData(styleId);
4739
5671
  const db = await dbPromise;
4740
5672
  await db.delete('styles', styleId);
4741
5673
  logger.debug(`Style with ID ${styleId} deleted successfully`);
@@ -4744,6 +5676,24 @@ async function deleteStyleById(styleId) {
4744
5676
  logger.error(`Error deleting style with ID ${styleId}:`, error);
4745
5677
  }
4746
5678
  }
5679
+ /**
5680
+ * Bytes stored for each style, by style id: its tiles, and its other resources
5681
+ * (glyphs, sprites, models and fonts). Tiles shared by regions count once.
5682
+ */
5683
+ async function getStyleStorageSizes() {
5684
+ const db = await dbPromise;
5685
+ const sizes = {};
5686
+ for (const storeName of ['tiles', 'glyphs', 'sprites', 'models', 'fonts']) {
5687
+ for await (const cursor of db.transaction(storeName).store) {
5688
+ const { styleId, size } = cursor.value;
5689
+ if (!styleId)
5690
+ continue;
5691
+ sizes[styleId] ??= { tiles: 0, resources: 0 };
5692
+ sizes[styleId][storeName === 'tiles' ? 'tiles' : 'resources'] += size ?? 0;
5693
+ }
5694
+ }
5695
+ return sizes;
5696
+ }
4747
5697
  /**
4748
5698
  * Get enhanced style statistics
4749
5699
  */
@@ -4969,9 +5919,10 @@ async function isStyleDownloaded(styleId, styleUrl) {
4969
5919
  * Enhanced style download that supports both Mapbox GL and MapLibre GL
4970
5920
  */
4971
5921
  async function downloadStyleWithProvider(styleUrl, options = {}) {
4972
- const { provider: explicitProvider, accessToken: explicitAccessToken, forceProvider = false, skipExisting = false, enableSourceEmbedding = true, maxRetries = 3, timeoutMs = 30000, } = options;
5922
+ const { provider: explicitProvider, accessToken: explicitAccessToken, forceProvider = false, skipExisting = false, enableSourceEmbedding = true, maxRetries = 3, timeoutMs = 30000, onProgress, } = options;
4973
5923
  const startTime = Date.now();
4974
5924
  let sourcesEmbedded = 0;
5925
+ const emit = (percentage, message) => onProgress?.({ completed: percentage, total: 100, percentage, message, errors: [] });
4975
5926
  logger.debug(`Downloading style from: ${styleUrl}`);
4976
5927
  try {
4977
5928
  // Auto-detect provider if not explicitly set
@@ -4992,6 +5943,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
4992
5943
  fetchUrl = normalizeStyleUrl(styleUrl, extractedToken);
4993
5944
  }
4994
5945
  // Fetch the style
5946
+ emit(0, 'Fetching style');
4995
5947
  const response = await fetchWithRetry(fetchUrl, {
4996
5948
  timeout: timeoutMs,
4997
5949
  retries: maxRetries,
@@ -5000,6 +5952,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
5000
5952
  throw new Error(`Failed to fetch style: ${response.status} ${response.statusText}`);
5001
5953
  }
5002
5954
  const style = (await response.json());
5955
+ emit(20, 'Style fetched');
5003
5956
  // Resolve imports (Mapbox Standard / compositional styles)
5004
5957
  if (hasImports(style)) {
5005
5958
  const importToken = extractedToken || '';
@@ -5013,13 +5966,19 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
5013
5966
  layerCount: style.layers ? style.layers.length : 0,
5014
5967
  });
5015
5968
  }
5969
+ // Mapbox Standard now arrives flat, so resolveImports may not have run.
5970
+ sanitizeIndoorExpressions(style);
5971
+ emit(40, 'Processing style sources');
5016
5972
  // Process style for the detected provider
5017
5973
  const processedStyle = processStyleSources(style, detectedProvider, extractedToken);
5018
5974
  // Embed TileJSON data into sources for reliable offline tile downloads
5019
5975
  // This fetches each source's TileJSON URL and merges the result (including tiles array,
5020
5976
  // minzoom, maxzoom) into the source config, so tile downloads don't need extra fetches
5021
5977
  if (enableSourceEmbedding && processedStyle.sources) {
5022
- for (const [sourceKey, sourceConfig] of Object.entries(processedStyle.sources)) {
5978
+ const sourceEntries = Object.entries(processedStyle.sources);
5979
+ let sourcesVisited = 0;
5980
+ for (const [sourceKey, sourceConfig] of sourceEntries) {
5981
+ emit(40 + Math.round((sourcesVisited++ / Math.max(1, sourceEntries.length)) * 40), `Embedding source ${sourceKey}`);
5023
5982
  const source = sourceConfig;
5024
5983
  if (source.url && typeof source.url === 'string' && !source.tiles) {
5025
5984
  const sourceUrl = source.url;
@@ -5032,7 +5991,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
5032
5991
  timeout: timeoutMs,
5033
5992
  retryDelay: 1000,
5034
5993
  });
5035
- const sourceData = await sourceResponse.json();
5994
+ const sourceData = selectTileJsonVariant(await sourceResponse.json());
5036
5995
  // Only copy valid MapLibre/Mapbox source properties from TileJSON
5037
5996
  // Copying unknown properties (like "name", "tilejson") causes
5038
5997
  // MapLibre to throw "unknown property" errors on setStyle()
@@ -5066,6 +6025,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
5066
6025
  }
5067
6026
  }
5068
6027
  }
6028
+ emit(80, 'Saving style');
5069
6029
  // Set default glyphs URL if not present
5070
6030
  if (!processedStyle.glyphs) {
5071
6031
  processedStyle.glyphs = GLYPH_CONFIG.DEFAULT_URL;
@@ -5077,13 +6037,17 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
5077
6037
  throw new Error(`Style validation failed: ${validation.errors.join(', ')}`);
5078
6038
  }
5079
6039
  // Generate style ID
5080
- const styleId = processedStyle.name?.toLowerCase().replace(/\s+/g, '-') || `style-${Date.now()}`;
5081
- // Check if style already exists
5082
6040
  const db = await dbPromise;
6041
+ const styleId = await resolveStyleKey(db,
6042
+ // The key goes into resource keys ("{key}:...") and idb:// URLs, so ":" or "/"
6043
+ // in a style's name would make its data look like another style's or break its URLs.
6044
+ processedStyle.name?.toLowerCase().replace(/[\s:/\\?#%]+/g, '-') || `style-${Date.now()}`, styleUrl);
6045
+ // Check if style already exists
5083
6046
  if (skipExisting) {
5084
6047
  const existingStyle = await db.get('styles', styleId);
5085
6048
  if (existingStyle) {
5086
6049
  logger.debug(`Style ${styleId} already exists, skipping`);
6050
+ emit(100, 'Style already exists');
5087
6051
  return {
5088
6052
  styleId,
5089
6053
  success: true,
@@ -5114,7 +6078,9 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
5114
6078
  accessToken: extractedToken,
5115
6079
  });
5116
6080
  // Save style
6081
+ await carryOverRegions(db, styleEntry);
5117
6082
  await db.put('styles', styleEntry);
6083
+ emit(100, 'Style saved');
5118
6084
  const downloadTime = Date.now() - startTime;
5119
6085
  logger.info(`Style ${styleId} downloaded in ${downloadTime}ms`);
5120
6086
  return {
@@ -5163,12 +6129,38 @@ var styleService = /*#__PURE__*/Object.freeze({
5163
6129
  downloadStyleWithProvider: downloadStyleWithProvider,
5164
6130
  downloadStyles: downloadStyles,
5165
6131
  getStyleStats: getStyleStats,
6132
+ getStyleStorageSizes: getStyleStorageSizes,
5166
6133
  isStyleDownloaded: isStyleDownloaded,
5167
6134
  loadStyleById: loadStyleById,
5168
6135
  loadStyles: loadStyles
5169
6136
  });
5170
6137
 
5171
6138
  const regionLogger$1 = logger.scope('RegionService');
6139
+ /**
6140
+ * Where each phase sits in a download's overall progress, as [start, end] in
6141
+ * percent. Tiles get the most because they're usually most of the bytes.
6142
+ */
6143
+ const PHASE_PROGRESS_RANGES = {
6144
+ style: [0, 5],
6145
+ sprites: [5, 10],
6146
+ glyphs: [10, 25],
6147
+ models: [25, 30],
6148
+ tiles: [30, 99],
6149
+ metadata: [99, 100],
6150
+ };
6151
+ /** True for a Mapbox-hosted style sprite, which has an `iconset.pbf` beside it. */
6152
+ function hasMapboxIconset(spriteUrl) {
6153
+ try {
6154
+ const { hostname, pathname } = new URL(spriteUrl);
6155
+ return (hostname === 'api.mapbox.com' &&
6156
+ pathname.startsWith('/styles/v1/') &&
6157
+ pathname.endsWith('/sprite'));
6158
+ }
6159
+ catch {
6160
+ // Not an absolute URL, so not a Mapbox-hosted sprite.
6161
+ return false;
6162
+ }
6163
+ }
5172
6164
  /**
5173
6165
  * True when `key` belongs to the given styleId.
5174
6166
  *
@@ -5185,44 +6177,10 @@ function resourceKeyBelongsToStyle(key, styleId) {
5185
6177
  }
5186
6178
  class RegionService {
5187
6179
  /**
5188
- * Check if a tile overlaps with any region bounds
5189
- */
5190
- tileOverlapsWithRegion(tileX, tileY, tileZ, region) {
5191
- // Validate region bounds exist and have correct structure
5192
- if (!region.bounds ||
5193
- !Array.isArray(region.bounds) ||
5194
- region.bounds.length !== 2 ||
5195
- !Array.isArray(region.bounds[0]) ||
5196
- !Array.isArray(region.bounds[1]) ||
5197
- region.bounds[0].length !== 2 ||
5198
- region.bounds[1].length !== 2) {
5199
- regionLogger$1.warn('Invalid region bounds structure:', region.bounds);
5200
- return false;
5201
- }
5202
- // Get tile bounds using tilebelt
5203
- const tileBounds = tilebelt__namespace.tileToBBOX([tileX, tileY, tileZ]);
5204
- const [tileWest, tileSouth, tileEast, tileNorth] = tileBounds;
5205
- // Region bounds format: [[west, south], [east, north]]
5206
- const regionWest = region.bounds[0][0];
5207
- const regionSouth = region.bounds[0][1];
5208
- const regionEast = region.bounds[1][0];
5209
- const regionNorth = region.bounds[1][1];
5210
- // Check if bounding boxes overlap
5211
- return !(tileEast < regionWest ||
5212
- tileWest > regionEast ||
5213
- tileNorth < regionSouth ||
5214
- tileSouth > regionNorth);
5215
- }
5216
- /**
5217
- * Check if a tile overlaps with any of the given regions
6180
+ * Delete tiles no remaining region needs — outside every remaining region's
6181
+ * bounds, or at a zoom none of them downloads for that tile's source.
5218
6182
  */
5219
- tileOverlapsWithAnyRegion(tileX, tileY, tileZ, regions) {
5220
- return regions.some(region => this.tileOverlapsWithRegion(tileX, tileY, tileZ, region));
5221
- }
5222
- /**
5223
- * Delete tiles that don't overlap with any remaining regions
5224
- */
5225
- async deleteNonOverlappingTiles(styleId, remainingRegions) {
6183
+ async deleteNonOverlappingTiles(styleId, remainingRegions, sources) {
5226
6184
  const db = await dbPromise;
5227
6185
  let deletedCount = 0;
5228
6186
  regionLogger$1.debug(`Checking tiles for style ${styleId} against ${remainingRegions.length} remaining regions`);
@@ -5239,9 +6197,13 @@ class RegionService {
5239
6197
  regionLogger$1.warn(`Invalid tile key format: ${tileEntry.key}`);
5240
6198
  continue;
5241
6199
  }
5242
- const { x, y, z } = parsed;
5243
- // Check if this tile overlaps with any remaining region
5244
- if (!this.tileOverlapsWithAnyRegion(x, y, z, remainingRegions)) {
6200
+ const tile = {
6201
+ sourceId: tileEntry.sourceId ?? parsed.sourceId,
6202
+ z: parsed.z,
6203
+ x: parsed.x,
6204
+ y: parsed.y,
6205
+ };
6206
+ if (!remainingRegions.some(region => tileBelongsToRegion(tile, region, sources))) {
5245
6207
  regionLogger$1.debug(`Deleting non-overlapping tile: ${tileEntry.key}`);
5246
6208
  await cursor.delete();
5247
6209
  deletedCount++;
@@ -5295,6 +6257,46 @@ class RegionService {
5295
6257
  /**
5296
6258
  * Delete all tiles for a style
5297
6259
  */
6260
+ /**
6261
+ * Delete everything stored for a style except its own entry: tiles, fonts,
6262
+ * glyphs, sprites and models.
6263
+ */
6264
+ /**
6265
+ * Undo what a failed or cancelled download left behind: its partly stored
6266
+ * tiles and its config change. A style this download created, which no other
6267
+ * region uses, goes entirely, and so does a style deleted during the download.
6268
+ */
6269
+ async cleanUpFailedDownload(styleId, createdStyle, configBefore) {
6270
+ try {
6271
+ const db = await dbPromise;
6272
+ const entry = await db.get('styles', styleId);
6273
+ if (!entry) {
6274
+ await this.deleteStyleData(styleId);
6275
+ return;
6276
+ }
6277
+ const regions = Array.isArray(entry.regions) ? entry.regions : [];
6278
+ if (createdStyle && regions.length === 0) {
6279
+ await this.deleteStyleData(styleId);
6280
+ await db.delete('styles', styleId);
6281
+ return;
6282
+ }
6283
+ if (configBefore) {
6284
+ entry.style.schema = configBefore.schema;
6285
+ entry.styleConfig = configBefore.values;
6286
+ await db.put('styles', entry);
6287
+ }
6288
+ await this.deleteNonOverlappingTiles(styleId, regions, (entry.style?.sources ?? {}));
6289
+ clearAllCaches();
6290
+ }
6291
+ catch (error) {
6292
+ regionLogger$1.warn(`Could not clean up after the failed download on ${styleId}:`, error);
6293
+ }
6294
+ }
6295
+ async deleteStyleData(styleId) {
6296
+ await this.deleteAllStyleTiles(styleId);
6297
+ await this.deleteStyleResources(styleId);
6298
+ clearAllCaches();
6299
+ }
5298
6300
  async deleteAllStyleTiles(styleId) {
5299
6301
  const db = await dbPromise;
5300
6302
  let deletedCount = 0;
@@ -5341,23 +6343,15 @@ class RegionService {
5341
6343
  styleEntry.regions = [];
5342
6344
  }
5343
6345
  // Inject extra sources into the style so they get patched for offline use
5344
- if (region.extraSources && region.extraSources.length > 0) {
5345
- for (const extra of region.extraSources) {
5346
- if (!styleEntry.style.sources[extra.id]) {
5347
- styleEntry.style.sources[extra.id] = {
5348
- type: extra.type || 'vector',
5349
- tiles: extra.tiles,
5350
- ...(extra.minzoom !== undefined ? { minzoom: extra.minzoom } : {}),
5351
- ...(extra.maxzoom !== undefined ? { maxzoom: extra.maxzoom } : {}),
5352
- ...(extra.attribution ? { attribution: extra.attribution } : {}),
5353
- };
5354
- regionLogger$1.debug(`Injected extra source into style: ${extra.id}`);
5355
- }
5356
- }
5357
- }
5358
- // Patch style for offline use with the region's maxZoom and tileExtension
5359
- // Pass styleId for sprites since they're stored with the style ID, not region ID
5360
- patchStyleForOffline(styleEntry.style, region.id, region.maxZoom, region.tileExtension, styleId);
6346
+ injectExtraSources(styleEntry.style, region.extraSources);
6347
+ stashOriginalModels(styleEntry);
6348
+ // Patch style for offline use. URLs are keyed by styleId (resources are
6349
+ // stored per style), and maxzoom is capped at the deepest region on the
6350
+ // style — not just this one — so adding a shallow region doesn't hide a
6351
+ // deeper sibling's tiles.
6352
+ const otherRegions = styleEntry.regions.filter((r) => r.id !== region.id && r.regionId !== region.id);
6353
+ patchStyleForOffline(styleEntry.style, region.id, Math.max(region.maxZoom, ...otherRegions.map(r => r.maxZoom)), region.tileExtension, styleId);
6354
+ restoreUnstoredModels(styleEntry, await storedModelNames(styleId));
5361
6355
  // Add or update region metadata on the style entry.
5362
6356
  // `expiry` is stored as an absolute timestamp (per the type doc and all
5363
6357
  // readers in cleanupService/cleanupManagement/RegionList). If the caller
@@ -5385,6 +6379,8 @@ class RegionService {
5385
6379
  styleEntry.regions.push(regionWithMeta);
5386
6380
  }
5387
6381
  await db.put('styles', styleEntry);
6382
+ // Drop in-memory region→style / tile caches so the fetch handler sees the new region.
6383
+ clearAllCaches();
5388
6384
  }
5389
6385
  async loadRegion(region, options = {}) {
5390
6386
  return this.downloadRegion(region, options);
@@ -5398,17 +6394,22 @@ class RegionService {
5398
6394
  if (!region.styleUrl) {
5399
6395
  throw new Error('Region must have a styleUrl');
5400
6396
  }
5401
- const { onProgress, provider = 'auto', accessToken, skipGlyphs = false, skipSprites = false, skipModels = false, glyphRanges, tileOptions, } = options;
6397
+ validateZoomLevels(region.minZoom, region.maxZoom);
6398
+ const { onProgress, provider = 'auto', accessToken, skipGlyphs = false, skipSprites = false, skipModels = false, glyphRanges, tileOptions, styleConfig, signal, } = options;
6399
+ let overallPercentage = 0;
5402
6400
  const emit = (phase, completed, total, message) => {
5403
6401
  if (!onProgress)
5404
6402
  return;
5405
6403
  const safeTotal = total > 0 ? total : 1;
5406
6404
  const clamped = Math.min(completed, safeTotal);
6405
+ const [start, end] = PHASE_PROGRESS_RANGES[phase];
6406
+ overallPercentage = Math.max(overallPercentage, start + (end - start) * (clamped / safeTotal));
5407
6407
  onProgress({
5408
6408
  phase,
5409
6409
  completed: clamped,
5410
- total: safeTotal,
6410
+ total,
5411
6411
  percentage: (clamped / safeTotal) * 100,
6412
+ overallPercentage,
5412
6413
  message,
5413
6414
  });
5414
6415
  };
@@ -5447,6 +6448,18 @@ class RegionService {
5447
6448
  const originalSpriteUrl = (styleEntry.originalSpriteUrl ??
5448
6449
  storedStyle.sprite);
5449
6450
  const originalGlyphsUrl = styleEntry.originalGlyphsUrl ?? storedStyle.glyphs;
6451
+ // Applied before the resource phases, which all plan from the style's config.
6452
+ // Kept so a failed download can put the config back as it was.
6453
+ const configBefore = styleConfig
6454
+ ? JSON.parse(JSON.stringify({ schema: storedStyle.schema, values: styleEntry.styleConfig }))
6455
+ : undefined;
6456
+ if (styleConfig) {
6457
+ applyStyleConfig(storedStyle, styleConfig);
6458
+ styleEntry.styleConfig = { ...styleEntry.styleConfig, ...styleConfig };
6459
+ }
6460
+ // A reused style may predate download-time sanitizing.
6461
+ sanitizeIndoorExpressions(storedStyle);
6462
+ await (await dbPromise).put('styles', styleEntry);
5450
6463
  // 2. Sprites
5451
6464
  const spriteResults = [];
5452
6465
  if (!skipSprites) {
@@ -5457,16 +6470,17 @@ class RegionService {
5457
6470
  // sibling is also served under the same /styles/v1/.../<hash>/ path
5458
6471
  // — we detect that case per-source below and append it to the list.
5459
6472
  const baseSuffixes = ['.json', '.png', '@2x.json', '@2x.png'];
5460
- // Estimate total files assuming iconset is always included (the actual
5461
- // number may be smaller; the emit helper clamps progress to total).
5462
- const totalFiles = spriteSources.length * (baseSuffixes.length + 1);
6473
+ const resolveSpriteBase = (url) => isMapboxProtocol(url) && effectiveAccessToken
6474
+ ? resolveMapboxUrl(url, effectiveAccessToken)
6475
+ : url;
6476
+ const totalFiles = spriteSources.reduce((sum, source) => {
6477
+ const base = resolveSpriteBase(source.url).split('?')[0];
6478
+ return sum + baseSuffixes.length + (hasMapboxIconset(base) ? 1 : 0);
6479
+ }, 0);
5463
6480
  let completed = 0;
5464
6481
  emit('sprites', 0, totalFiles, 'Downloading sprites');
5465
6482
  for (const source of spriteSources) {
5466
- let spriteBase = source.url;
5467
- if (isMapboxProtocol(spriteBase) && effectiveAccessToken) {
5468
- spriteBase = resolveMapboxUrl(spriteBase, effectiveAccessToken);
5469
- }
6483
+ const spriteBase = resolveSpriteBase(source.url);
5470
6484
  const qIndex = spriteBase.indexOf('?');
5471
6485
  const suffixes = [...baseSuffixes];
5472
6486
  // Mapbox Standard serves an iconset.pbf alongside the sprite under
@@ -5474,18 +6488,7 @@ class RegionService {
5474
6488
  // /styles/v1/{owner}/{style}/{hash}/iconset.pbf. The last path
5475
6489
  // segment is `sprite`, so replacing it with `iconset.pbf` works.
5476
6490
  const pathWithoutQuery = qIndex !== -1 ? spriteBase.slice(0, qIndex) : spriteBase;
5477
- let isMapboxStandardSprite = false;
5478
- try {
5479
- const parsed = new URL(pathWithoutQuery);
5480
- isMapboxStandardSprite =
5481
- parsed.hostname === 'api.mapbox.com' &&
5482
- parsed.pathname.startsWith('/styles/v1/') &&
5483
- parsed.pathname.endsWith('/sprite');
5484
- }
5485
- catch {
5486
- // Non-URL sprite base (e.g. relative); not a Mapbox Standard sprite.
5487
- }
5488
- if (isMapboxStandardSprite) {
6491
+ if (hasMapboxIconset(pathWithoutQuery)) {
5489
6492
  // The path-rewrite suffix replaces the trailing `sprite` segment.
5490
6493
  suffixes.push('__ICONSET__');
5491
6494
  }
@@ -5549,14 +6552,16 @@ class RegionService {
5549
6552
  // (plain string or `{ uri }`) — we accept both.
5550
6553
  let modelResult;
5551
6554
  if (!skipModels && storedStyle.models) {
5552
- const rawModels = storedStyle.models;
6555
+ // Upstream URLs, from before patching, so models that failed last time are retried.
6556
+ const rawModels = (styleEntry.originalModels ?? storedStyle.models);
6557
+ const alreadyStored = await storedModelNames(styleId);
5553
6558
  const resolved = {};
5554
6559
  for (const [name, value] of Object.entries(rawModels)) {
5555
6560
  const uri = typeof value === 'string' ? value : value?.uri;
5556
- if (!uri)
6561
+ if (!uri || alreadyStored.has(name))
5557
6562
  continue;
5558
6563
  if (uri.startsWith('idb://'))
5559
- continue; // already patched
6564
+ continue; // patched before upstream URLs were kept
5560
6565
  const httpUrl = isMapboxProtocol(uri) && effectiveAccessToken
5561
6566
  ? resolveMapboxUrl(uri, effectiveAccessToken)
5562
6567
  : uri;
@@ -5579,29 +6584,56 @@ class RegionService {
5579
6584
  }
5580
6585
  }
5581
6586
  }
6587
+ // Renderer code fetched from api.mapbox.com at render time (3D decoders,
6588
+ // RTL text, procedural buildings). Never throws; see cacheMapboxRuntimeAssets.
6589
+ let runtimeAssetsCached;
6590
+ if (/mapbox:\/\/|api\.mapbox\.com/.test(`${region.styleUrl ?? ''} ${originalGlyphsUrl ?? ''}`)) {
6591
+ const { cacheMapboxRuntimeAssets } = await Promise.resolve().then(function () { return runtimeAssetService; });
6592
+ runtimeAssetsCached = await cacheMapboxRuntimeAssets();
6593
+ }
5582
6594
  // 5. Tiles — use the stored (source-embedded) style, which still has HTTP tile URLs
5583
- const { downloadTiles } = await Promise.resolve().then(function () { return tileService$1; });
5584
- const regionForTiles = { ...region, styleId };
5585
- emit('tiles', 0, 100, 'Downloading tiles');
5586
- const tileResult = await downloadTiles(regionForTiles, storedStyle, styleId, {
5587
- skipExisting: false,
5588
- batchSize: 20,
5589
- ...tileOptions,
5590
- onProgress: progress => {
5591
- emit('tiles', progress.completed, progress.total, progress.message ?? 'Downloading tiles');
5592
- tileOptions?.onProgress?.(progress);
5593
- },
5594
- });
5595
- // 6. Metadata — must run last, since addRegion patches style URLs to idb://.
5596
- // Do NOT auto-fill tileExtension from tileResult: that's only the first
5597
- // source's extension, and addRegion feeds it to patchStyleForOffline which
5598
- // would override ALL sources — breaking mixed raster+vector styles. The
5599
- // patcher extracts per-source extensions from tile URL patterns when
5600
- // tileExtension is undefined. Callers who need to force an extension can
5601
- // still pass `region.tileExtension` explicitly.
5602
- emit('metadata', 0, 1, 'Saving region metadata');
5603
- await this.addRegion({ ...region, styleId });
5604
- emit('metadata', 1, 1, 'Region ready');
6595
+ let tileResult;
6596
+ try {
6597
+ signal?.throwIfAborted();
6598
+ const { downloadTiles } = await Promise.resolve().then(function () { return tileService$1; });
6599
+ const regionForTiles = { ...region, styleId };
6600
+ // The tile count isn't known until the plan is made, which can take a while.
6601
+ emit('tiles', 0, 0, 'Planning tiles');
6602
+ tileResult = await downloadTiles(regionForTiles, storedStyle, styleId, {
6603
+ skipExisting: false,
6604
+ batchSize: 20,
6605
+ ...tileOptions,
6606
+ signal,
6607
+ onProgress: progress => {
6608
+ emit('tiles', progress.completed, progress.total, progress.message ?? 'Downloading tiles');
6609
+ tileOptions?.onProgress?.(progress);
6610
+ },
6611
+ });
6612
+ signal?.throwIfAborted();
6613
+ // A region with no tiles at all isn't worth saving as if it had worked, as happens
6614
+ // with an expired token. Partial failures still save: tileResult reports them.
6615
+ const { failedTiles, downloadedTiles, skippedTiles } = tileResult;
6616
+ if (failedTiles > 0 && downloadedTiles === 0 && skippedTiles === 0) {
6617
+ throw new Error(`None of the ${failedTiles} tiles could be downloaded. Check your connection and access token.`);
6618
+ }
6619
+ // 6. Metadata — must run last, since addRegion patches style URLs to idb://.
6620
+ // Do NOT auto-fill tileExtension from tileResult: that's only the first
6621
+ // source's extension, and addRegion feeds it to patchStyleForOffline which
6622
+ // would override ALL sources — breaking mixed raster+vector styles. The
6623
+ // patcher extracts per-source extensions from tile URL patterns when
6624
+ // tileExtension is undefined. Callers who need to force an extension can
6625
+ // still pass `region.tileExtension` explicitly.
6626
+ emit('metadata', 0, 1, 'Saving region metadata');
6627
+ if (!(await (await dbPromise).get('styles', styleId))) {
6628
+ throw new Error('The style was deleted while this region was downloading.');
6629
+ }
6630
+ await this.addRegion({ ...region, styleId });
6631
+ emit('metadata', 1, 1, 'Region ready');
6632
+ }
6633
+ catch (error) {
6634
+ await this.cleanUpFailedDownload(styleId, Boolean(styleResult), configBefore);
6635
+ throw error;
6636
+ }
5605
6637
  return {
5606
6638
  regionId: region.id,
5607
6639
  styleId,
@@ -5609,6 +6641,7 @@ class RegionService {
5609
6641
  spriteResults,
5610
6642
  glyphResult,
5611
6643
  modelResult,
6644
+ runtimeAssetsCached,
5612
6645
  tileResult,
5613
6646
  };
5614
6647
  }
@@ -5633,12 +6666,20 @@ class RegionService {
5633
6666
  }
5634
6667
  return undefined;
5635
6668
  }
5636
- async deleteRegion(regionId) {
6669
+ /**
6670
+ * Delete a region and the tiles/resources only it needed.
6671
+ * @param styleId - The style holding the region. Region ids are only unique
6672
+ * within a style, so pass it whenever known; without it the first style
6673
+ * containing `regionId` is used.
6674
+ */
6675
+ async deleteRegion(regionId, styleId) {
5637
6676
  const db = await dbPromise;
5638
- regionLogger$1.debug(`Deleting region: ${regionId}`);
6677
+ regionLogger$1.debug(`Deleting region: ${regionId}${styleId ? ` from style ${styleId}` : ''}`);
5639
6678
  try {
5640
6679
  // Find which style contains this region
5641
- const allStyles = await db.getAll('styles');
6680
+ const allStyles = styleId
6681
+ ? [await db.get('styles', styleId)].filter((entry) => entry !== undefined)
6682
+ : await db.getAll('styles');
5642
6683
  regionLogger$1.debug(`Found ${allStyles.length} styles to search through`);
5643
6684
  let foundStyle = null;
5644
6685
  let foundRegion = null;
@@ -5657,7 +6698,7 @@ class RegionService {
5657
6698
  }
5658
6699
  }
5659
6700
  if (!foundStyle || !foundRegion) {
5660
- regionLogger$1.warn(`Region ${regionId} not found in any style`);
6701
+ regionLogger$1.warn(`Region ${regionId} not found in ${styleId ? `style ${styleId}` : 'any style'}`);
5661
6702
  return;
5662
6703
  }
5663
6704
  regionLogger$1.debug(`Found region in style: ${foundStyle.key}`, foundRegion);
@@ -5668,11 +6709,7 @@ class RegionService {
5668
6709
  // If this was the last region for the style, delete the entire style and all its resources
5669
6710
  if (remainingRegions.length === 0) {
5670
6711
  regionLogger$1.info(`Style ${foundStyle.key} has no remaining regions, deleting entire style and all resources`);
5671
- // Delete all tiles for this style
5672
- const deletedTileCount = await this.deleteAllStyleTiles(foundStyle.key);
5673
- regionLogger$1.debug(`Deleted ${deletedTileCount} tiles`);
5674
- // Delete all style resources (fonts, glyphs, sprites)
5675
- await this.deleteStyleResources(foundStyle.key);
6712
+ await this.deleteStyleData(foundStyle.key);
5676
6713
  // Delete the style entry itself
5677
6714
  await db.delete('styles', foundStyle.key);
5678
6715
  regionLogger$1.info(`Completely deleted style ${foundStyle.key} and all associated resources`);
@@ -5680,11 +6717,18 @@ class RegionService {
5680
6717
  else {
5681
6718
  regionLogger$1.debug(`Style ${foundStyle.key} has ${remainingRegions.length} remaining regions, cleaning up non-overlapping tiles`);
5682
6719
  // Delete tiles that don't overlap with any remaining regions
5683
- const deletedTileCount = await this.deleteNonOverlappingTiles(foundStyle.key, remainingRegions);
6720
+ const deletedTileCount = await this.deleteNonOverlappingTiles(foundStyle.key, remainingRegions, (foundStyle.style?.sources ?? {}));
6721
+ // Re-patch for the remaining regions: shrink maxzoom to the deepest
6722
+ // survivor, and re-key URLs by styleId. Styles patched before URLs
6723
+ // were style-keyed still point at `idb://{deletedRegionId}/...`,
6724
+ // which no longer resolves once the region is gone.
6725
+ patchStyleForOffline(foundStyle.style, foundStyle.key, Math.max(...remainingRegions.map((r) => r.maxZoom)), undefined, foundStyle.key);
6726
+ restoreUnstoredModels(foundStyle, await storedModelNames(foundStyle.key));
5684
6727
  // Update the style entry with the remaining regions
5685
6728
  await db.put('styles', foundStyle);
5686
6729
  regionLogger$1.info(`Region ${regionId} deleted successfully from style ${foundStyle.key}, cleaned up ${deletedTileCount} non-overlapping tiles`);
5687
6730
  }
6731
+ clearAllCaches();
5688
6732
  regionLogger$1.info(`Region deletion completed successfully for: ${regionId}`);
5689
6733
  }
5690
6734
  catch (error) {
@@ -5735,6 +6779,13 @@ async function loadAllStoredRegions() {
5735
6779
  }
5736
6780
  }
5737
6781
 
6782
+ var regionService = /*#__PURE__*/Object.freeze({
6783
+ __proto__: null,
6784
+ RegionService: RegionService,
6785
+ loadAllStoredRegions: loadAllStoredRegions,
6786
+ resourceKeyBelongsToStyle: resourceKeyBelongsToStyle
6787
+ });
6788
+
5738
6789
  const cleanupLogger = logger.scope('CleanupService');
5739
6790
  class CleanupService {
5740
6791
  db = dbPromise;
@@ -5833,9 +6884,9 @@ class CleanupService {
5833
6884
  let deletedCount = 0;
5834
6885
  for (const region of uniqueRegionsToDelete) {
5835
6886
  try {
5836
- const regionSize = await this.getRegionSize(region.id);
6887
+ const bytesBefore = await this.getStyleStoredBytes(region.styleId);
5837
6888
  await this.deleteRegionCallback(region.id, region.styleId);
5838
- result.freedSpace += regionSize;
6889
+ result.freedSpace += Math.max(0, bytesBefore - (await this.getStyleStoredBytes(region.styleId)));
5839
6890
  deletedCount++;
5840
6891
  onProgress?.({
5841
6892
  phase: 'cleaning',
@@ -5899,7 +6950,7 @@ class CleanupService {
5899
6950
  neverExpiring: 0,
5900
6951
  };
5901
6952
  for (const region of regions) {
5902
- const regionSize = await this.getRegionSize(region.id);
6953
+ const regionSize = await this.getRegionSize(region.id, region.styleId);
5903
6954
  totalSize += regionSize;
5904
6955
  // Track by style
5905
6956
  const styleId = region.styleId || 'unknown';
@@ -6001,8 +7052,8 @@ class CleanupService {
6001
7052
  async getExpiredResourceCount() {
6002
7053
  const db = await this.db;
6003
7054
  const now = Date.now();
6004
- const counts = { tiles: 0, fonts: 0, sprites: 0, glyphs: 0, total: 0 };
6005
- const stores = ['tiles', 'fonts', 'sprites', 'glyphs'];
7055
+ const counts = { tiles: 0, fonts: 0, sprites: 0, glyphs: 0, models: 0, total: 0 };
7056
+ const stores = ['tiles', 'fonts', 'sprites', 'glyphs', 'models'];
6006
7057
  for (const storeName of stores) {
6007
7058
  try {
6008
7059
  const tx = db.transaction([storeName], 'readonly');
@@ -6048,46 +7099,77 @@ class CleanupService {
6048
7099
  async getAllRegions() {
6049
7100
  return loadAllStoredRegions();
6050
7101
  }
7102
+ /**
7103
+ * Bytes of the tiles stored for a region: tiles on its style inside its
7104
+ * bounds, at the zooms the download pipeline fetched for each source. Tiles
7105
+ * are stored per style, so regions that overlap share (and both count)
7106
+ * their common tiles; shared style resources are not included.
7107
+ */
6051
7108
  async getRegionSize(regionId, styleIdParam) {
6052
7109
  const db = await this.db;
6053
- let totalSize = 0;
6054
- let styleId = styleIdParam;
6055
- let regionMinZoom = 0;
6056
- let regionMaxZoom = 22;
6057
- // Find the region's styleId and zoom range from styles.regions[]
6058
- if (!styleId) {
6059
- const styles = await db.getAll('styles');
6060
- for (const style of styles) {
6061
- const styleEntry = style;
6062
- if (styleEntry.regions && Array.isArray(styleEntry.regions)) {
6063
- const found = styleEntry.regions.find(r => r.id === regionId);
6064
- if (found) {
6065
- styleId = styleEntry.key;
6066
- regionMinZoom = found.minZoom ?? 0;
6067
- regionMaxZoom = found.maxZoom ?? 22;
6068
- break;
6069
- }
6070
- }
7110
+ const candidates = styleIdParam
7111
+ ? [await db.get('styles', styleIdParam)]
7112
+ : await db.getAll('styles');
7113
+ let styleEntry;
7114
+ let region;
7115
+ for (const entry of candidates) {
7116
+ region = entry?.regions?.find((r) => r.id === regionId || r.regionId === regionId);
7117
+ if (region) {
7118
+ styleEntry = entry;
7119
+ break;
6071
7120
  }
6072
7121
  }
6073
- if (!styleId) {
7122
+ if (!styleEntry || !region) {
6074
7123
  return 0;
6075
7124
  }
6076
- // Calculate size from tiles that belong to this style within the region's zoom range
7125
+ const styleId = styleEntry.key;
7126
+ const sources = (styleEntry.style?.sources ?? {});
7127
+ let totalSize = 0;
6077
7128
  const tx = db.transaction(['tiles'], 'readonly');
6078
7129
  for await (const cursor of tx.objectStore('tiles')) {
6079
7130
  const tile = cursor.value;
6080
7131
  if (tile.styleId !== styleId)
6081
7132
  continue;
6082
- // Filter by zoom range when possible
6083
7133
  const parsed = parseTileKey(tile.key);
6084
- if (parsed && (parsed.z < regionMinZoom || parsed.z > regionMaxZoom)) {
7134
+ if (!parsed)
6085
7135
  continue;
7136
+ const coords = {
7137
+ sourceId: tile.sourceId ?? parsed.sourceId,
7138
+ z: parsed.z,
7139
+ x: parsed.x,
7140
+ y: parsed.y,
7141
+ };
7142
+ if (tileBelongsToRegion(coords, region, sources)) {
7143
+ totalSize += tile.size || 0;
6086
7144
  }
6087
- totalSize += tile.size || 0;
6088
7145
  }
6089
7146
  return totalSize;
6090
7147
  }
7148
+ /**
7149
+ * Bytes stored for a style: its tiles plus the fonts, glyphs, sprites and
7150
+ * models keyed to it. Measured before and after a deletion to report the
7151
+ * space actually freed (a region's own size over-counts tiles it shares).
7152
+ */
7153
+ async getStyleStoredBytes(styleId) {
7154
+ if (!styleId)
7155
+ return 0;
7156
+ const db = await this.db;
7157
+ let total = 0;
7158
+ const tx = db.transaction(['tiles'], 'readonly');
7159
+ for await (const cursor of tx.objectStore('tiles')) {
7160
+ if (cursor.value.styleId === styleId)
7161
+ total += cursor.value.size || 0;
7162
+ }
7163
+ for (const storeName of ['fonts', 'glyphs', 'sprites', 'models']) {
7164
+ const storeTx = db.transaction([storeName], 'readonly');
7165
+ for await (const cursor of storeTx.objectStore(storeName)) {
7166
+ const entry = cursor.value;
7167
+ if (resourceKeyBelongsToStyle(entry.key, styleId))
7168
+ total += entry.size || 0;
7169
+ }
7170
+ }
7171
+ return total;
7172
+ }
6091
7173
  async calculateTotalStorageSize() {
6092
7174
  if ('storage' in navigator && 'estimate' in navigator.storage) {
6093
7175
  const estimate = await navigator.storage.estimate();
@@ -6101,6 +7183,7 @@ class CleanupService {
6101
7183
  'fonts',
6102
7184
  'sprites',
6103
7185
  'glyphs',
7186
+ 'models',
6104
7187
  'styles',
6105
7188
  ];
6106
7189
  for (const storeName of stores) {
@@ -6142,7 +7225,7 @@ class CleanupService {
6142
7225
  if (currentSize >= targetSize)
6143
7226
  break;
6144
7227
  selected.push(region);
6145
- const regionSize = await this.getRegionSize(region.id);
7228
+ const regionSize = await this.getRegionSize(region.id, region.styleId);
6146
7229
  currentSize += regionSize > 0 ? regionSize : 10 * 1024 * 1024; // Fallback to 10MB if size unknown
6147
7230
  }
6148
7231
  return selected;
@@ -6233,7 +7316,7 @@ class TileService {
6233
7316
  */
6234
7317
  async downloadTiles(region, style, styleId, options = {}) {
6235
7318
  const db = await this.db;
6236
- 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;
7319
+ 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;
6237
7320
  const startTime = Date.now();
6238
7321
  let totalSize = 0;
6239
7322
  let downloadedTiles = 0;
@@ -6274,31 +7357,31 @@ class TileService {
6274
7357
  if (tileSources.size === 0) {
6275
7358
  throw new Error('No valid tile sources found in style definition');
6276
7359
  }
6277
- // Some sources have zoom ranges outside the user's region (e.g. procedural-buildings
6278
- // at z15 when the user requested z0-z14). Generate additional tile coordinates so
7360
+ // Features a config-driven style has switched off (Mapbox Standard's HD
7361
+ // roads, indoor, landmark icons...) don't need their tiles.
7362
+ const { active: activeSources, inactive: inactiveSources } = classifySources(style);
7363
+ if (skipInactiveSources) {
7364
+ for (const sourceId of inactiveSources) {
7365
+ if (tileSources.delete(sourceId)) {
7366
+ tileLogger.info(`Skipping source "${sourceId}" — nothing visible uses it under the style's config`);
7367
+ }
7368
+ }
7369
+ }
7370
+ // Some sources have zoom ranges entirely outside the user's region (e.g.
7371
+ // procedural-buildings at z15 when the user requested z0-z14, or a z14-capped
7372
+ // basemap for a z15-z17 region). Generate the extra coordinates they need so
6279
7373
  // these sources aren't silently skipped.
7374
+ const generatedZooms = new Set();
7375
+ for (let z = region.minZoom; z <= region.maxZoom; z++)
7376
+ generatedZooms.add(z);
6280
7377
  for (const [sourceId, sourceConfig] of tileSources) {
6281
- const srcMin = sourceConfig.minzoom;
6282
- const srcMax = sourceConfig.maxzoom;
6283
- if (srcMin === undefined && srcMax === undefined)
6284
- continue;
6285
- const extraMinZ = srcMin !== undefined && srcMin > region.maxZoom ? srcMin : null;
6286
- const extraMaxZ = srcMax !== undefined && srcMax < region.minZoom ? srcMax : null;
6287
- // Only extend upward (higher zoom) — sources that need zooms above region.maxZoom
6288
- if (extraMinZ !== null) {
6289
- const upperBound = srcMax !== undefined ? srcMax : extraMinZ;
6290
- tileLogger.debug(`Source ${sourceId} needs zoom ${extraMinZ}-${upperBound} beyond region max ${region.maxZoom}, generating extra tiles`);
6291
- const extraRegion = { ...region, minZoom: extraMinZ, maxZoom: upperBound };
6292
- const extraCoords = this.generateTileCoordinates(extraRegion);
6293
- tileCoords.push(...extraCoords);
6294
- }
6295
- // Extend downward (lower zoom) — sources that need zooms below region.minZoom
6296
- if (extraMaxZ !== null) {
6297
- const lowerBound = srcMin !== undefined ? srcMin : extraMaxZ;
6298
- tileLogger.debug(`Source ${sourceId} needs zoom ${lowerBound}-${extraMaxZ} below region min ${region.minZoom}, generating extra tiles`);
6299
- const extraRegion = { ...region, minZoom: lowerBound, maxZoom: extraMaxZ };
6300
- const extraCoords = this.generateTileCoordinates(extraRegion);
6301
- tileCoords.push(...extraCoords);
7378
+ const { min, max } = sourceZoomRange(region, sourceConfig.minzoom, sourceConfig.maxzoom);
7379
+ for (let z = min; z <= max; z++) {
7380
+ if (generatedZooms.has(z))
7381
+ continue;
7382
+ generatedZooms.add(z);
7383
+ tileLogger.debug(`Source ${sourceId} needs zoom ${z} outside region ${region.minZoom}-${region.maxZoom}, generating extra tiles`);
7384
+ tileCoords.push(...this.generateTileCoordinates({ ...region, minZoom: z, maxZoom: z }));
6302
7385
  }
6303
7386
  }
6304
7387
  // Sort by priority zoom levels if requested (lower zoom first)
@@ -6341,14 +7424,16 @@ class TileService {
6341
7424
  // issuing any network request. They 404 for nearly every coord in a
6342
7425
  // typical region; the downstream probe step would catch them, but the
6343
7426
  // probe itself logs 404s in devtools. Matching here keeps the console
6344
- // clean.
6345
- if (skipKnownSparseSources &&
6346
- tiles.some(template => urlReferencesKnownSparseTileset(template))) {
7427
+ // clean. A sparse source a switched-on feature needs is kept instead, and
7428
+ // not probed, because its tiles cluster and the three probe tiles could
7429
+ // easily all miss.
7430
+ const knownSparse = tiles.some(template => urlReferencesKnownSparseTileset(template));
7431
+ const neededSparseSource = knownSparse && activeSources.has(sourceId);
7432
+ if (skipKnownSparseSources && knownSparse && !neededSparseSource) {
6347
7433
  tileLogger.info(`Skipping source "${sourceId}" — references a known sparse Mapbox Standard sub-tileset (no probe/download issued)`);
6348
7434
  continue;
6349
7435
  }
6350
- const sourceMinZ = Math.ceil(sourceConfig.minzoom ?? region.minZoom);
6351
- const sourceMaxZ = Math.floor(sourceConfig.maxzoom ?? region.maxZoom);
7436
+ const { min: sourceMinZ, max: sourceMaxZ } = sourceZoomRange(region, sourceConfig.minzoom, sourceConfig.maxzoom);
6352
7437
  let coordsForSource = tileCoords.filter(coord => coord.z >= sourceMinZ && coord.z <= sourceMaxZ);
6353
7438
  tileLogger.debug(`After zoom filter (${sourceMinZ}-${sourceMaxZ}): ${coordsForSource.length} tiles`);
6354
7439
  if (coordsForSource.length === 0) {
@@ -6382,7 +7467,7 @@ class TileService {
6382
7467
  // A single probe on the wrong coord would false-pass and cause a
6383
7468
  // flood of 404s during the download. Probing start/middle/end of
6384
7469
  // the plan catches this case.
6385
- if (probeSourcesBeforeDownload) {
7470
+ if (probeSourcesBeforeDownload && !neededSparseSource) {
6386
7471
  const picks = [
6387
7472
  coordsForSource[0],
6388
7473
  coordsForSource[Math.floor(coordsForSource.length / 2)],
@@ -6441,6 +7526,9 @@ class TileService {
6441
7526
  .map(([z, count]) => `Z${z}: ${count} tiles`)
6442
7527
  .join(', '));
6443
7528
  await processBatch(plan.coords, async (coord) => {
7529
+ // Cancelled: let the remaining tiles go by without fetching them.
7530
+ if (signal?.aborted)
7531
+ return;
6444
7532
  const { x, y, z } = coord;
6445
7533
  const label = `${plan.sourceId}:${z}/${x}/${y}`;
6446
7534
  let tileUrl = '';
@@ -6550,7 +7638,9 @@ class TileService {
6550
7638
  const errorObject = _error;
6551
7639
  errorMessage = errorObject instanceof Error ? errorObject.message : String(errorObject);
6552
7640
  // 404s on sparse tilesets (landmarks, POIs) are expected — don't count as failures
6553
- const is404 = errorMessage.includes('404') || errorMessage.includes('not found');
7641
+ // Match the start only: the message goes on to include the URL, whose
7642
+ // coordinates or token can contain "404".
7643
+ const is404 = /^(Resource not found \(404\)|HTTP 404\b)/.test(errorMessage);
6554
7644
  const isExpected = errorMessage.includes('NonRetryableError');
6555
7645
  if (is404) {
6556
7646
  skippedTiles++;
@@ -6728,13 +7818,7 @@ class TileService {
6728
7818
  tileLogger.debug(`Approximate area: ${areaApproxKm2.toFixed(2)} km² (improved calculation)`);
6729
7819
  tileLogger.debug(`Region dimensions: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km`);
6730
7820
  for (let z = region.minZoom; z <= region.maxZoom; z++) {
6731
- const bounds = region.bounds;
6732
- const minTile = tilebelt__namespace.pointToTile(bounds[0][0], bounds[0][1], z);
6733
- const maxTile = tilebelt__namespace.pointToTile(bounds[1][0], bounds[1][1], z);
6734
- const minX = Math.min(minTile[0], maxTile[0]);
6735
- const maxX = Math.max(minTile[0], maxTile[0]);
6736
- const minY = Math.min(minTile[1], maxTile[1]);
6737
- const maxY = Math.max(minTile[1], maxTile[1]);
7821
+ const { minX, maxX, minY, maxY } = getTileRangeAtZoom(region.bounds, z);
6738
7822
  const tilesAtZoom = (maxX - minX + 1) * (maxY - minY + 1);
6739
7823
  tilesByZoom[z] = tilesAtZoom;
6740
7824
  tileLogger.debug(`Zoom ${z}: ${tilesAtZoom} tiles (X: ${minX}-${maxX}, Y: ${minY}-${maxY})`);
@@ -6768,7 +7852,10 @@ class TileService {
6768
7852
  }
6769
7853
  tileLogger.debug('Processing sources in extractTileSources:', Object.keys(style.sources));
6770
7854
  for (const [sourceId, sourceConfig] of Object.entries(style.sources)) {
6771
- const config = sourceConfig;
7855
+ // Shallow copy: the maxzoom override below must not mutate the caller's style.
7856
+ const config = {
7857
+ ...sourceConfig,
7858
+ };
6772
7859
  tileLogger.debug(`Source ${sourceId} RAW DATA:`, JSON.stringify(config, null, 2));
6773
7860
  tileLogger.debug(`Processing source ${sourceId}:`, {
6774
7861
  type: config.type,
@@ -6779,18 +7866,26 @@ class TileService {
6779
7866
  });
6780
7867
  // Handle tile-based sources (vector, raster, raster-dem, batched-model,
6781
7868
  // raster-array). `raster-array` is used by Mapbox Standard for layers
6782
- // like `mapbox-landmarks` (mapbox.mapbox-landmark-icons-v1) — the tiles
6783
- // are fetched from the same /v4/ endpoint as other tilesets, so the
6784
- // TileJSON resolution path below handles them uniformly.
7869
+ // like `mapbox-landmarks` (mapbox.mapbox-landmark-icons-v1). Each type has
7870
+ // its own tile endpoint (/v4/, /raster/v1/, /rasterarrays/v1/, /3dtiles/v1/),
7871
+ // but TileJSON gives the URL for all of them, so the resolution path below
7872
+ // handles them uniformly.
6785
7873
  if (config.type === 'vector' ||
6786
7874
  config.type === 'raster' ||
6787
7875
  config.type === 'raster-dem' ||
6788
7876
  config.type === 'raster-array' ||
6789
7877
  config.type === 'batched-model') {
7878
+ // A style already patched for offline (every region after the first)
7879
+ // has idb:// tiles and a maxzoom capped to earlier regions; plan from
7880
+ // the upstream values patchStyleForOffline stashed instead.
7881
+ const sourceTiles = config.__originalTiles ?? config.tiles;
7882
+ if (config.__originalMaxzoom !== undefined) {
7883
+ config.maxzoom = config.__originalMaxzoom ?? undefined;
7884
+ }
6790
7885
  // Handle direct tile URLs in the source config
6791
- if (config.tiles && Array.isArray(config.tiles) && config.tiles.length > 0) {
7886
+ if (sourceTiles && Array.isArray(sourceTiles) && sourceTiles.length > 0) {
6792
7887
  // Resolve mapbox:// tile URLs to HTTPS, then filter for HTTP(S) URLs
6793
- const resolvedTiles = config.tiles.map((tile) => {
7888
+ const resolvedTiles = sourceTiles.map((tile) => {
6794
7889
  if (isMapboxProtocol(tile)) {
6795
7890
  // Try to find an access token from the style or source URL
6796
7891
  const accessToken = this.extractAccessTokenFromStyle(style);
@@ -6862,7 +7957,9 @@ class TileService {
6862
7957
  });
6863
7958
  tileLogger.debug(`TileJSON fetch response type: ${response.type}`);
6864
7959
  if (response.type === 'json') {
6865
- const jsonData = response.data;
7960
+ const jsonData = response.data
7961
+ ? selectTileJsonVariant(response.data)
7962
+ : null;
6866
7963
  tileLogger.debug(`TileJSON data keys: ${Object.keys(jsonData || {}).join(', ')}`);
6867
7964
  if (jsonData &&
6868
7965
  typeof jsonData === 'object' &&
@@ -6966,16 +8063,9 @@ class TileService {
6966
8063
  }
6967
8064
  }
6968
8065
  if (tileSources.size === 0) {
8066
+ // No fabricated fallback: a relative '{z}/{x}/{y}.pbf' template resolves
8067
+ // against the page and stores whatever the app server returns as tiles.
6969
8068
  tileLogger.warn('No valid tile sources found in style', Object.keys(style.sources));
6970
- // As a last resort, try to use a common vector tile source pattern if we can't extract any
6971
- if (style.sources && Object.keys(style.sources).length > 0) {
6972
- const firstSourceId = Object.keys(style.sources)[0];
6973
- tileLogger.debug(`Attempting to create fallback source from ${firstSourceId}`);
6974
- tileSources.set(firstSourceId, {
6975
- type: 'vector',
6976
- tiles: ['{z}/{x}/{y}.pbf'],
6977
- });
6978
- }
6979
8069
  }
6980
8070
  return tileSources;
6981
8071
  }
@@ -7383,341 +8473,146 @@ class GlyphService {
7383
8473
  health: {
7384
8474
  corruptedGlyphs: stats.corruptedGlyphs.length,
7385
8475
  corruptionRate: stats.count > 0 ? (stats.corruptedGlyphs.length / stats.count) * 100 : 0,
7386
- },
7387
- temporal: {
7388
- oldestGlyph: stats.oldestGlyph,
7389
- newestGlyph: stats.newestGlyph,
7390
- ageSpan: stats.oldestGlyph && stats.newestGlyph
7391
- ? stats.newestGlyph.lastModified - stats.oldestGlyph.lastModified
7392
- : 0,
7393
- },
7394
- };
7395
- }
7396
- /**
7397
- * Remove glyphs older than the specified age. When `options.styleId` is
7398
- * provided, only glyphs belonging to that style (per
7399
- * `resourceKeyBelongsToStyle`) are eligible.
7400
- */
7401
- async cleanupOldGlyphs(maxAge = 30, options = {}) {
7402
- const db = await this.db;
7403
- const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
7404
- const { styleId } = options;
7405
- let deletedCount = 0;
7406
- const tx = db.transaction('glyphs', 'readwrite');
7407
- for await (const cursor of tx.store) {
7408
- const glyphEntry = cursor.value;
7409
- const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
7410
- if (belongs && glyphEntry.lastModified < cutoffTime) {
7411
- await cursor.delete();
7412
- deletedCount++;
7413
- }
7414
- }
7415
- return deletedCount;
7416
- }
7417
- async verifyAndRepairGlyphs() {
7418
- const db = await this.db;
7419
- let verified = 0;
7420
- // eslint-disable-next-line prefer-const -- will be incremented when repair logic is added
7421
- let repaired = 0;
7422
- let removed = 0;
7423
- const tx = db.transaction('glyphs', 'readwrite');
7424
- for await (const cursor of tx.store) {
7425
- const glyphEntry = cursor.value;
7426
- try {
7427
- // Basic validation
7428
- if (!glyphEntry.data || glyphEntry.size === 0) {
7429
- await cursor.delete();
7430
- removed++;
7431
- }
7432
- else {
7433
- this.validateGlyphData(glyphEntry.data);
7434
- verified++;
7435
- }
7436
- }
7437
- catch {
7438
- // Remove corrupted glyphs (they can be re-downloaded)
7439
- await cursor.delete();
7440
- removed++;
7441
- }
7442
- }
7443
- return { verified, repaired, removed };
7444
- }
7445
- validateGlyphData(data) {
7446
- if (data.byteLength < 10) {
7447
- throw new Error('Glyph data too short');
7448
- }
7449
- // Basic Protocol Buffer validation
7450
- const view = new Uint8Array(data);
7451
- // Check for valid protobuf structure (basic check)
7452
- if (view[0] === 0x00 && view[1] === 0x00 && view[2] === 0x00 && view[3] === 0x00) {
7453
- throw new Error('Invalid glyph data - appears to be empty');
7454
- }
7455
- }
7456
- normalizeFontstack(fontstack) {
7457
- try {
7458
- return decodeURIComponent(fontstack);
7459
- }
7460
- catch {
7461
- return fontstack;
7462
- }
7463
- }
7464
- buildGlyphPath(fontstack, range) {
7465
- const normalizedFontstack = this.normalizeFontstack(fontstack);
7466
- return `${normalizedFontstack}/${range}.pbf`;
7467
- }
7468
- parseGlyphKey(key) {
7469
- let remaining = key;
7470
- let styleId;
7471
- if (remaining.includes('::')) {
7472
- const [prefix, ...rest] = remaining.split('::');
7473
- styleId = prefix;
7474
- remaining = rest.join('::');
7475
- }
7476
- else if (remaining.includes(':') && !remaining.includes('/')) {
7477
- const [prefix, rest] = remaining.split(':');
7478
- styleId = styleId ?? prefix;
7479
- remaining = rest;
7480
- }
7481
- const trimmed = remaining.endsWith('.pbf') ? remaining.slice(0, -4) : remaining;
7482
- if (trimmed.includes('/')) {
7483
- const [fontstack, range] = trimmed.split('/');
7484
- if (fontstack && range) {
7485
- return { styleId, fontstack, range };
7486
- }
7487
- }
7488
- if (trimmed.includes('_')) {
7489
- const [fontstack, range] = trimmed.split('_');
7490
- if (fontstack && range) {
7491
- return { styleId, fontstack, range };
7492
- }
7493
- }
7494
- return { styleId, fontstack: trimmed, range: '' };
7495
- }
7496
- createGlyphKey(fontstack, range, styleName) {
7497
- // Create a consistent key from the style name, fontstack and range
7498
- // Format: stylename::fontstack/range.pbf (if styleName provided) or fontstack/range.pbf
7499
- const glyphPath = this.buildGlyphPath(fontstack, range);
7500
- if (styleName) {
7501
- return `${styleName}::${glyphPath}`;
7502
- }
7503
- // Fallback to original format for backward compatibility
7504
- return glyphPath;
7505
- }
7506
- }
7507
- // Export singleton instance and functions for backward compatibility
7508
- const glyphService = new GlyphService();
7509
- const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => glyphService.downloadGlyphs(glyphUrl, fontstacks, styleName, ranges, options);
7510
- const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
7511
- const getGlyphStats = () => glyphService.getGlyphStats();
7512
- const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
7513
- const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
7514
- const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
7515
-
7516
- var glyphService$1 = /*#__PURE__*/Object.freeze({
7517
- __proto__: null,
7518
- GlyphService: GlyphService,
7519
- cleanupOldGlyphs: cleanupOldGlyphs,
7520
- downloadGlyphs: downloadGlyphs,
7521
- getGlyphAnalytics: getGlyphAnalytics,
7522
- getGlyphStats: getGlyphStats,
7523
- glyphService: glyphService,
7524
- loadGlyphs: loadGlyphs,
7525
- verifyAndRepairGlyphs: verifyAndRepairGlyphs
7526
- });
7527
-
7528
- const modelLogger = logger.scope('ModelService');
7529
- /**
7530
- * Build the storage key for a model. Kept consistent with sprite/glyph
7531
- * conventions: `{styleId}::model::{modelName}`.
7532
- */
7533
- function modelKey(styleId, modelName) {
7534
- return `${styleId}::model::${modelName}`;
7535
- }
7536
- /** True when the given key belongs to the given styleId's model store prefix. */
7537
- function modelKeyBelongsToStyle(key, styleId) {
7538
- return key.startsWith(`${styleId}::model::`);
7539
- }
7540
- /**
7541
- * Service for downloading, storing, and serving Mapbox 3D model (.glb) files.
7542
- *
7543
- * Mapbox Standard declares 32 models at the top of `style.models`:
7544
- *
7545
- * ```json
7546
- * {
7547
- * "maple1-lod1": "mapbox://models/mapbox/maple1-v4-lod1.glb",
7548
- * ...
7549
- * }
7550
- * ```
7551
- *
7552
- * `model` layers (e.g. `trees`, `wind-turbine-towers`) pick one by name at
7553
- * render time. For offline use each referenced URL is fetched and stored
7554
- * here, and `patchStyleForOffline` rewrites the dictionary entries to
7555
- * `idb://{styleId}/model/{name}` URLs.
7556
- */
7557
- class ModelService {
7558
- db = dbPromise;
7559
- /**
7560
- * Download the set of models referenced by `style.models` for one style.
7561
- *
7562
- * @param models `{ modelName: resolvedHttpUrl }` — URLs must already be
7563
- * resolved (mapbox:// URLs should be resolved by the caller).
7564
- * @param styleId The owning style's key.
7565
- */
7566
- async downloadModels(models, styleId, options = {}) {
7567
- const db = await this.db;
7568
- const { onProgress, batchSize = 4, maxRetries = 3, skipExisting = true, timeoutMs = 30000, } = options;
7569
- const entries = Object.entries(models);
7570
- const progressTracker = createProgressTracker(entries.length);
7571
- const result = {
7572
- totalModels: entries.length,
7573
- downloadedModels: 0,
7574
- skippedModels: 0,
7575
- failedModels: 0,
7576
- totalSize: 0,
7577
- errors: [],
7578
- };
7579
- const emit = () => onProgress?.(progressTracker.getProgress());
7580
- emit();
7581
- if (entries.length === 0)
7582
- return result;
7583
- // Pre-compute existing keys for skipExisting
7584
- const existingKeys = new Set();
7585
- if (skipExisting) {
7586
- const tx = db.transaction('models', 'readonly');
7587
- for await (const cursor of tx.store) {
7588
- existingKeys.add(cursor.value.key);
7589
- }
7590
- }
7591
- await processBatch(entries, async ([modelName, url]) => {
7592
- const key = modelKey(styleId, modelName);
7593
- const label = `${styleId}::${modelName}`;
7594
- if (skipExisting && existingKeys.has(key)) {
7595
- result.skippedModels++;
7596
- progressTracker.update(1, label);
7597
- emit();
7598
- return;
7599
- }
7600
- try {
7601
- const response = await fetchResourceWithRetry(url, {
7602
- retries: maxRetries,
7603
- timeout: timeoutMs,
7604
- proxyType: 'tiles',
7605
- });
7606
- if (response.type === 'json') {
7607
- throw new Error('Unexpected JSON response for model');
7608
- }
7609
- const data = response.data;
7610
- const contentType = ('contentType' in response && response.contentType) || 'model/gltf-binary';
7611
- const entry = {
7612
- key,
7613
- data,
7614
- contentType,
7615
- size: data.byteLength,
7616
- url,
7617
- styleId,
7618
- modelName,
7619
- lastModified: Date.now(),
7620
- downloadedAt: new Date().toISOString(),
7621
- expires: response.expires,
7622
- };
7623
- await db.put('models', entry);
7624
- result.downloadedModels++;
7625
- result.totalSize += data.byteLength;
7626
- progressTracker.update(1, label);
7627
- }
7628
- catch (err) {
7629
- const message = err instanceof Error ? err.message : String(err);
7630
- result.failedModels++;
7631
- result.errors.push({ url, error: message });
7632
- modelLogger.warn(`Failed to download model "${modelName}" from ${url}:`, err);
7633
- progressTracker.update(1, label, message);
7634
- }
7635
- emit();
7636
- }, { batchSize });
7637
- modelLogger.info(`Models downloaded for style ${styleId}: ${result.downloadedModels} new, ${result.skippedModels} skipped, ${result.failedModels} failed`);
7638
- return result;
7639
- }
7640
- /** Retrieve a single model by `{styleId, modelName}`. */
7641
- async getModel(styleId, modelName) {
7642
- const db = await this.db;
7643
- return db.get('models', modelKey(styleId, modelName));
7644
- }
7645
- /** Aggregate stats across all stored models. */
7646
- async getModelStats() {
7647
- const db = await this.db;
7648
- const stats = {
7649
- count: 0,
7650
- totalSize: 0,
7651
- averageSize: 0,
7652
- models: [],
7653
- modelsByStyle: {},
8476
+ },
8477
+ temporal: {
8478
+ oldestGlyph: stats.oldestGlyph,
8479
+ newestGlyph: stats.newestGlyph,
8480
+ ageSpan: stats.oldestGlyph && stats.newestGlyph
8481
+ ? stats.newestGlyph.lastModified - stats.oldestGlyph.lastModified
8482
+ : 0,
8483
+ },
7654
8484
  };
7655
- const tx = db.transaction('models', 'readonly');
7656
- for await (const cursor of tx.store) {
7657
- const m = cursor.value;
7658
- stats.count++;
7659
- stats.totalSize += m.size;
7660
- stats.models.push({ name: m.modelName, size: m.size, lastModified: m.lastModified });
7661
- stats.modelsByStyle[m.styleId] = (stats.modelsByStyle[m.styleId] ?? 0) + 1;
7662
- }
7663
- stats.averageSize = stats.count > 0 ? stats.totalSize / stats.count : 0;
7664
- return stats;
7665
8485
  }
7666
8486
  /**
7667
- * Delete models older than `maxAge` days. Defaults to 30.
8487
+ * Remove glyphs older than the specified age. When `options.styleId` is
8488
+ * provided, only glyphs belonging to that style (per
8489
+ * `resourceKeyBelongsToStyle`) are eligible.
7668
8490
  */
7669
- async cleanupOldModels(maxAge = 30) {
8491
+ async cleanupOldGlyphs(maxAge = 30, options = {}) {
7670
8492
  const db = await this.db;
7671
- const cutoff = Date.now() - maxAge * 24 * 60 * 60 * 1000;
7672
- let deleted = 0;
7673
- const tx = db.transaction('models', 'readwrite');
8493
+ const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
8494
+ const { styleId } = options;
8495
+ let deletedCount = 0;
8496
+ const tx = db.transaction('glyphs', 'readwrite');
7674
8497
  for await (const cursor of tx.store) {
7675
- if (cursor.value.lastModified < cutoff) {
8498
+ const glyphEntry = cursor.value;
8499
+ const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
8500
+ if (belongs && glyphEntry.lastModified < cutoffTime) {
7676
8501
  await cursor.delete();
7677
- deleted++;
8502
+ deletedCount++;
7678
8503
  }
7679
8504
  }
7680
- return deleted;
8505
+ return deletedCount;
7681
8506
  }
7682
- /**
7683
- * Basic integrity check: remove entries with empty/missing data.
7684
- */
7685
- async verifyAndRepairModels() {
8507
+ async verifyAndRepairGlyphs() {
7686
8508
  const db = await this.db;
7687
8509
  let verified = 0;
8510
+ // eslint-disable-next-line prefer-const -- will be incremented when repair logic is added
8511
+ let repaired = 0;
7688
8512
  let removed = 0;
7689
- const tx = db.transaction('models', 'readwrite');
8513
+ const tx = db.transaction('glyphs', 'readwrite');
7690
8514
  for await (const cursor of tx.store) {
7691
- const m = cursor.value;
7692
- if (!m.data || m.data.byteLength === 0) {
8515
+ const glyphEntry = cursor.value;
8516
+ try {
8517
+ // Basic validation
8518
+ if (!glyphEntry.data || glyphEntry.size === 0) {
8519
+ await cursor.delete();
8520
+ removed++;
8521
+ }
8522
+ else {
8523
+ this.validateGlyphData(glyphEntry.data);
8524
+ verified++;
8525
+ }
8526
+ }
8527
+ catch {
8528
+ // Remove corrupted glyphs (they can be re-downloaded)
7693
8529
  await cursor.delete();
7694
8530
  removed++;
7695
8531
  }
7696
- else {
7697
- verified++;
8532
+ }
8533
+ return { verified, repaired, removed };
8534
+ }
8535
+ validateGlyphData(data) {
8536
+ if (data.byteLength < 10) {
8537
+ throw new Error('Glyph data too short');
8538
+ }
8539
+ // Basic Protocol Buffer validation
8540
+ const view = new Uint8Array(data);
8541
+ // Check for valid protobuf structure (basic check)
8542
+ if (view[0] === 0x00 && view[1] === 0x00 && view[2] === 0x00 && view[3] === 0x00) {
8543
+ throw new Error('Invalid glyph data - appears to be empty');
8544
+ }
8545
+ }
8546
+ normalizeFontstack(fontstack) {
8547
+ try {
8548
+ return decodeURIComponent(fontstack);
8549
+ }
8550
+ catch {
8551
+ return fontstack;
8552
+ }
8553
+ }
8554
+ buildGlyphPath(fontstack, range) {
8555
+ const normalizedFontstack = this.normalizeFontstack(fontstack);
8556
+ return `${normalizedFontstack}/${range}.pbf`;
8557
+ }
8558
+ parseGlyphKey(key) {
8559
+ let remaining = key;
8560
+ let styleId;
8561
+ if (remaining.includes('::')) {
8562
+ const [prefix, ...rest] = remaining.split('::');
8563
+ styleId = prefix;
8564
+ remaining = rest.join('::');
8565
+ }
8566
+ else if (remaining.includes(':') && !remaining.includes('/')) {
8567
+ const [prefix, rest] = remaining.split(':');
8568
+ styleId = styleId ?? prefix;
8569
+ remaining = rest;
8570
+ }
8571
+ const trimmed = remaining.endsWith('.pbf') ? remaining.slice(0, -4) : remaining;
8572
+ if (trimmed.includes('/')) {
8573
+ const [fontstack, range] = trimmed.split('/');
8574
+ if (fontstack && range) {
8575
+ return { styleId, fontstack, range };
7698
8576
  }
7699
8577
  }
7700
- return { verified, repaired: 0, removed };
8578
+ if (trimmed.includes('_')) {
8579
+ const [fontstack, range] = trimmed.split('_');
8580
+ if (fontstack && range) {
8581
+ return { styleId, fontstack, range };
8582
+ }
8583
+ }
8584
+ return { styleId, fontstack: trimmed, range: '' };
8585
+ }
8586
+ createGlyphKey(fontstack, range, styleName) {
8587
+ // Create a consistent key from the style name, fontstack and range
8588
+ // Format: stylename::fontstack/range.pbf (if styleName provided) or fontstack/range.pbf
8589
+ const glyphPath = this.buildGlyphPath(fontstack, range);
8590
+ if (styleName) {
8591
+ return `${styleName}::${glyphPath}`;
8592
+ }
8593
+ // Fallback to original format for backward compatibility
8594
+ return glyphPath;
7701
8595
  }
7702
8596
  }
7703
- // Singleton + convenience exports, matching other service modules.
7704
- const modelService = new ModelService();
7705
- const downloadModels = (models, styleId, options) => modelService.downloadModels(models, styleId, options);
7706
- const getModel = (styleId, modelName) => modelService.getModel(styleId, modelName);
7707
- const getModelStats = () => modelService.getModelStats();
7708
- const cleanupOldModels = (maxAge) => modelService.cleanupOldModels(maxAge);
7709
- const verifyAndRepairModels = () => modelService.verifyAndRepairModels();
8597
+ // Export singleton instance and functions for backward compatibility
8598
+ const glyphService = new GlyphService();
8599
+ const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => glyphService.downloadGlyphs(glyphUrl, fontstacks, styleName, ranges, options);
8600
+ const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
8601
+ const getGlyphStats = () => glyphService.getGlyphStats();
8602
+ const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
8603
+ const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
8604
+ const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
7710
8605
 
7711
- var modelService$1 = /*#__PURE__*/Object.freeze({
8606
+ var glyphService$1 = /*#__PURE__*/Object.freeze({
7712
8607
  __proto__: null,
7713
- ModelService: ModelService,
7714
- cleanupOldModels: cleanupOldModels,
7715
- downloadModels: downloadModels,
7716
- getModel: getModel,
7717
- getModelStats: getModelStats,
7718
- modelKeyBelongsToStyle: modelKeyBelongsToStyle,
7719
- modelService: modelService,
7720
- verifyAndRepairModels: verifyAndRepairModels
8608
+ GlyphService: GlyphService,
8609
+ cleanupOldGlyphs: cleanupOldGlyphs,
8610
+ downloadGlyphs: downloadGlyphs,
8611
+ getGlyphAnalytics: getGlyphAnalytics,
8612
+ getGlyphStats: getGlyphStats,
8613
+ glyphService: glyphService,
8614
+ loadGlyphs: loadGlyphs,
8615
+ verifyAndRepairGlyphs: verifyAndRepairGlyphs
7721
8616
  });
7722
8617
 
7723
8618
  class ResourceService {
@@ -7817,23 +8712,26 @@ class AnalyticsService {
7817
8712
  return getGlyphStats();
7818
8713
  }
7819
8714
  async getComprehensiveStorageAnalytics(getRegionAnalytics) {
7820
- const [tileStats, fontStats, spriteStats, glyphStats, regionAnalytics] = await Promise.all([
8715
+ const [tileStats, fontStats, spriteStats, glyphStats, modelStats, styleStats, regionAnalytics] = await Promise.all([
7821
8716
  this.getAllTileStats(),
7822
8717
  this.getAllFontStats(),
7823
8718
  this.getAllSpriteStats(),
7824
8719
  this.getAllGlyphStats(),
8720
+ getModelStats(),
8721
+ getStyleStats(),
7825
8722
  getRegionAnalytics(),
7826
8723
  ]);
7827
- const totalStorageSize = (tileStats.totalSize || 0) +
7828
- (fontStats.totalSize || 0) +
7829
- (spriteStats.totalSize || 0) +
7830
- (glyphStats.totalSize || 0);
7831
8724
  const storageByType = {
8725
+ // The style documents themselves, which the panel's per-style totals include.
8726
+ styles: styleStats.totalSize || 0,
7832
8727
  tiles: tileStats.totalSize || 0,
7833
8728
  fonts: fontStats.totalSize || 0,
7834
8729
  sprites: spriteStats.totalSize || 0,
7835
8730
  glyphs: glyphStats.totalSize || 0,
8731
+ // Mapbox Standard 3D models (.glb) — often several MB each.
8732
+ models: modelStats.totalSize || 0,
7836
8733
  };
8734
+ const totalStorageSize = Object.values(storageByType).reduce((sum, size) => sum + size, 0);
7837
8735
  const recommendations = this.generateStorageRecommendations({
7838
8736
  tiles: tileStats,
7839
8737
  fonts: fontStats,
@@ -7847,6 +8745,7 @@ class AnalyticsService {
7847
8745
  fonts: fontStats,
7848
8746
  sprites: spriteStats,
7849
8747
  glyphs: glyphStats,
8748
+ models: modelStats,
7850
8749
  regions: regionAnalytics,
7851
8750
  totalStorageSize,
7852
8751
  storageByType,
@@ -7978,6 +8877,53 @@ function buildVectorJsonMetadata(style, sourceIds) {
7978
8877
  return JSON.stringify({ vector_layers: merged });
7979
8878
  }
7980
8879
  const serviceLogger = logger.scope('ImportExportService');
8880
+ /** Map stored tile extensions to MBTiles 1.3 `format` values (`pbf`, `jpg`, ...). */
8881
+ function normalizeMBTilesFormat(ext) {
8882
+ const lower = ext.toLowerCase();
8883
+ if (lower === 'mvt')
8884
+ return 'pbf';
8885
+ if (lower === 'jpeg')
8886
+ return 'jpg';
8887
+ return lower;
8888
+ }
8889
+ /**
8890
+ * MBTiles holds one tile per z/x/y, so tiles from several style sources can't
8891
+ * share a file — they'd overwrite each other at every shared coordinate.
8892
+ * Keep only `sourceId` when given, otherwise the source with the most tiles
8893
+ * (the basemap in practice), and warn about what was left out.
8894
+ */
8895
+ function selectSingleSource(tiles, sourceId) {
8896
+ const bySource = new Map();
8897
+ for (const tile of tiles) {
8898
+ const group = bySource.get(tile.sourceId);
8899
+ if (group)
8900
+ group.push(tile);
8901
+ else
8902
+ bySource.set(tile.sourceId, [tile]);
8903
+ }
8904
+ if (sourceId !== undefined) {
8905
+ const selected = bySource.get(sourceId);
8906
+ if (!selected) {
8907
+ const available = [...bySource.keys()].join(', ') || 'none';
8908
+ throw new Error(`Source "${sourceId}" has no tiles in this region (available: ${available})`);
8909
+ }
8910
+ return selected;
8911
+ }
8912
+ if (bySource.size <= 1)
8913
+ return tiles;
8914
+ let chosen = '';
8915
+ let chosenTiles = [];
8916
+ for (const [id, group] of bySource) {
8917
+ if (group.length > chosenTiles.length) {
8918
+ chosen = id;
8919
+ chosenTiles = group;
8920
+ }
8921
+ }
8922
+ const skipped = [...bySource.keys()].filter(id => id !== chosen);
8923
+ serviceLogger.warn(`Region has tiles from ${bySource.size} sources; MBTiles holds one. Exporting "${chosen}", ` +
8924
+ `skipping ${skipped.join(', ')}. Pass { sourceId } to choose another.`);
8925
+ return chosenTiles;
8926
+ }
7981
8927
  class ImportExportService {
7982
8928
  db = dbPromise;
7983
8929
  constructor() {
@@ -8002,10 +8948,11 @@ class ImportExportService {
8002
8948
  if (!region) {
8003
8949
  throw new Error(`Region ${regionId} not found`);
8004
8950
  }
8005
- const tiles = await this.exportTiles(regionId, onProgress);
8006
- // Pick format: caller override → region.tileExtension → default pbf.
8007
- // Drives both the metadata row and whether tile bytes get gzipped.
8008
- const format = String(options.format || region.tileExtension || 'pbf').toLowerCase();
8951
+ const regionTiles = await this.exportTiles(region, onProgress);
8952
+ const tiles = selectSingleSource(regionTiles, options.sourceId);
8953
+ // Pick format: caller override → stored tile extension → region.tileExtension
8954
+ // → default pbf. Drives both the metadata row and whether tile bytes get gzipped.
8955
+ const format = normalizeMBTilesFormat(String(options.format || tiles[0]?.format || region.tileExtension || 'pbf'));
8009
8956
  const isVector = VECTOR_FORMATS.has(format);
8010
8957
  onProgress({
8011
8958
  stage: 'processing',
@@ -8230,11 +9177,19 @@ class ImportExportService {
8230
9177
  /**
8231
9178
  * Export tiles data
8232
9179
  */
8233
- async exportTiles(regionId, onProgress) {
9180
+ async exportTiles(region, onProgress) {
8234
9181
  const db = await this.db;
8235
- // First, find the styleId for this region
8236
- const region = await this.getRegionMetadata(regionId);
8237
- const styleId = region?.styleId || regionId;
9182
+ const styleId = region.styleId || region.id;
9183
+ // Tiles are stored per style, not per region. When the style holds other
9184
+ // regions too, keep only tiles inside this region's bounds and the zooms
9185
+ // the download pipeline fetches for each source (which can fall outside
9186
+ // the region's own range — see sourceZoomRange), or sibling regions' tiles
9187
+ // leak into the export. A sole region owns all of its style's tiles — and
9188
+ // an imported region's bounds come from MBTiles metadata, which can be
9189
+ // tighter than the tiles — so don't filter then.
9190
+ const styleEntry = await db.get('styles', styleId);
9191
+ const sharesStyle = (styleEntry?.regions?.length ?? 0) > 1;
9192
+ const styleSources = (styleEntry?.style?.sources ?? {});
8238
9193
  const transaction = db.transaction(['tiles'], 'readonly');
8239
9194
  const store = transaction.objectStore('tiles');
8240
9195
  const tiles = [];
@@ -8243,22 +9198,31 @@ class ImportExportService {
8243
9198
  let processed = 0;
8244
9199
  while (cursor) {
8245
9200
  const tile = cursor.value;
8246
- // Filter tiles by the region's styleId
8247
- if (tile.styleId === styleId) {
9201
+ const parsed = parseTileKey(String(tile.key));
9202
+ const z = tile.z ?? parsed?.z;
9203
+ const x = tile.x ?? parsed?.x;
9204
+ const y = tile.y ?? parsed?.y;
9205
+ if (tile.styleId === styleId &&
9206
+ z !== undefined &&
9207
+ x !== undefined &&
9208
+ y !== undefined &&
9209
+ (!sharesStyle ||
9210
+ tileBelongsToRegion({ sourceId: tile.sourceId ?? parsed?.sourceId, z, x, y }, region, styleSources))) {
8248
9211
  tiles.push({
8249
- z: tile.z ?? 0, // Handle optional z
8250
- x: tile.x ?? 0, // Handle optional x
8251
- y: tile.y ?? 0, // Handle optional y
9212
+ z,
9213
+ x,
9214
+ y,
8252
9215
  data: tile.data,
8253
- format: 'pbf', // TileEntry doesn't have format, use default
8254
- sourceId: tile.sourceId ?? 'default', // Handle optional sourceId
9216
+ format: (parsed?.ext || 'pbf').toLowerCase(),
9217
+ sourceId: tile.sourceId ?? parsed?.sourceId ?? 'default',
8255
9218
  });
8256
9219
  }
8257
9220
  processed++;
8258
9221
  if (onProgress && processed % 100 === 0) {
8259
9222
  onProgress({
8260
9223
  stage: 'exporting',
8261
- percentage: 30 + (processed / 1000) * 40, // Rough estimation
9224
+ // Total is unknown while walking the cursor; approach 70% asymptotically.
9225
+ percentage: 30 + 40 * (1 - 1 / (1 + processed / 1000)),
8262
9226
  message: `Exported ${processed} tiles...`,
8263
9227
  currentItem: `${tile.z ?? 0}/${tile.x ?? 0}/${tile.y ?? 0}`,
8264
9228
  completedItems: processed,
@@ -8439,7 +9403,7 @@ class ImportExportService {
8439
9403
  sourceId,
8440
9404
  downloadedAt: new Date().toISOString(),
8441
9405
  size: tile.data instanceof ArrayBuffer ? tile.data.byteLength : 0,
8442
- type: 'vector',
9406
+ type: VECTOR_FORMATS.has(ext) ? 'vector' : 'raster',
8443
9407
  url: `tile://${tile.z}/${tile.x}/${tile.y}`,
8444
9408
  lastModified: Date.now(),
8445
9409
  });
@@ -8483,7 +9447,7 @@ const createRegionManagement = (services) => ({
8483
9447
  addRegion: async (region) => services.regionService.addRegion(region),
8484
9448
  downloadRegion: async (region, options) => services.regionService.downloadRegion(region, options),
8485
9449
  loadRegion: async (region, options) => services.regionService.loadRegion(region, options),
8486
- deleteRegion: async (regionId) => services.regionService.deleteRegion(regionId),
9450
+ deleteRegion: async (regionId, styleId) => services.regionService.deleteRegion(regionId, styleId),
8487
9451
  listRegions: async () => services.regionService.listRegions(),
8488
9452
  listStoredRegions: async () => services.regionService.listStoredRegions(),
8489
9453
  getStoredRegion: async (regionId) => {
@@ -8505,7 +9469,7 @@ const createCleanupManagement = (services) => ({
8505
9469
  let deletedCount = 0;
8506
9470
  for (const region of allRegions) {
8507
9471
  if (region.expiry && region.expiry < now) {
8508
- await services.regionService.deleteRegion(region.id);
9472
+ await services.regionService.deleteRegion(region.id, region.styleId);
8509
9473
  deletedCount++;
8510
9474
  }
8511
9475
  }
@@ -8956,7 +9920,7 @@ const en = {
8956
9920
  'language.ar': 'Arabic',
8957
9921
  // Header
8958
9922
  'header.title': 'Offline Manager',
8959
- 'header.subtitle': '{{count}} regions • {{size}} total',
9923
+ 'header.subtitle': '{{regions}} • {{size}} total',
8960
9924
  // Actions
8961
9925
  'actions.addRegion': 'Add Region',
8962
9926
  'actions.refresh': 'Refresh',
@@ -8972,6 +9936,8 @@ const en = {
8972
9936
  'regionForm.namePlaceholder': 'e.g., Downtown District',
8973
9937
  'regionForm.minZoom': 'Min Zoom',
8974
9938
  'regionForm.maxZoom': 'Max Zoom',
9939
+ 'regionForm.buildingsHint': "Mapbox Standard's buildings start at zoom 15. Set Max Zoom to 15 or more to include them.",
9940
+ 'regionForm.zoomRangeError': 'Max Zoom must be at least Min Zoom.',
8975
9941
  'regionForm.styleUrl': 'Style URL',
8976
9942
  'regionForm.provider': 'Style Provider',
8977
9943
  'regionForm.providerAuto': 'Auto-detect',
@@ -9001,7 +9967,10 @@ const en = {
9001
9967
  'regionList.downloaded': 'Downloaded',
9002
9968
  'regionList.size': 'Size',
9003
9969
  'regionList.tiles': 'Tiles',
9970
+ 'regionList.resources': 'Fonts, icons and models',
9004
9971
  'regionList.styleData': 'Style',
9972
+ 'regionList.regionCountOne': '1 region',
9973
+ 'regionList.regionCount': '{{count}} regions',
9005
9974
  'regionList.totalSize': 'Total',
9006
9975
  'regionList.styleId': 'Style ID',
9007
9976
  // Region Details
@@ -9087,6 +10056,13 @@ const en = {
9087
10056
  'mbtiles.importFailed': 'Import failed. Please try again.',
9088
10057
  // Active Downloads
9089
10058
  'download.activeCount': 'Active Downloads ({{count}})',
10059
+ 'download.status.style': 'Preparing style',
10060
+ 'download.status.sprites': 'Sprites: {{completed}} of {{total}}',
10061
+ 'download.status.glyphs': 'Fonts: {{completed}} of {{total}}',
10062
+ 'download.status.models': '3D models: {{completed}} of {{total}}',
10063
+ 'download.status.tiles': 'Tiles: {{completed}} of {{total}}',
10064
+ 'download.status.planning': 'Planning tiles',
10065
+ 'download.status.saving': 'Saving region',
9090
10066
  // Panel Manager additional strings
9091
10067
  'panel.downloadedRegions': 'Downloaded Regions',
9092
10068
  'panel.noExpiry': 'No expiry',
@@ -9108,6 +10084,8 @@ const en = {
9108
10084
  'control.saveSelectedRegion': 'Save Selected Region',
9109
10085
  // Alerts/Messages
9110
10086
  'alert.unknownError': 'Unknown error occurred',
10087
+ 'alert.downloadFailed': 'Download failed',
10088
+ '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.',
9111
10089
  'alert.downloadNotImplemented': 'Download feature not implemented in this refactored version',
9112
10090
  'alert.mapNotAvailable': 'Map not available',
9113
10091
  'alert.invalidBounds': 'Invalid bounds for region',
@@ -9164,7 +10142,7 @@ const ar = {
9164
10142
  'language.ar': 'العربية',
9165
10143
  // Header - الرأس
9166
10144
  'header.title': 'مدير الخرائط غير المتصلة',
9167
- 'header.subtitle': '{{count}} منطقة • {{size}} إجمالي',
10145
+ 'header.subtitle': '{{regions}} • {{size}} إجمالي',
9168
10146
  // Actions - الإجراءات
9169
10147
  'actions.addRegion': 'إضافة منطقة',
9170
10148
  'actions.refresh': 'تحديث',
@@ -9180,6 +10158,8 @@ const ar = {
9180
10158
  'regionForm.namePlaceholder': 'مثال: وسط المدينة',
9181
10159
  'regionForm.minZoom': 'أدنى تكبير',
9182
10160
  'regionForm.maxZoom': 'أقصى تكبير',
10161
+ 'regionForm.buildingsHint': 'تبدأ مباني Mapbox Standard عند مستوى التكبير 15. اضبط أقصى تكبير على 15 أو أكثر لتضمينها.',
10162
+ 'regionForm.zoomRangeError': 'يجب ألا يقل أقصى تكبير عن أدنى تكبير.',
9183
10163
  'regionForm.styleUrl': 'رابط النمط',
9184
10164
  'regionForm.provider': 'مزود النمط',
9185
10165
  'regionForm.providerAuto': 'اكتشاف تلقائي',
@@ -9209,7 +10189,10 @@ const ar = {
9209
10189
  'regionList.downloaded': 'تم التحميل',
9210
10190
  'regionList.size': 'الحجم',
9211
10191
  'regionList.tiles': 'البلاطات',
10192
+ 'regionList.resources': 'الخطوط والرموز والنماذج',
9212
10193
  'regionList.styleData': 'النمط',
10194
+ 'regionList.regionCountOne': 'منطقة واحدة',
10195
+ 'regionList.regionCount': '{{count}} منطقة',
9213
10196
  'regionList.totalSize': 'الإجمالي',
9214
10197
  'regionList.styleId': 'معرف النمط',
9215
10198
  // Region Details - تفاصيل المنطقة
@@ -9295,6 +10278,13 @@ const ar = {
9295
10278
  'mbtiles.importFailed': 'فشل الاستيراد. يرجى المحاولة مرة أخرى.',
9296
10279
  // Active Downloads - التحميلات النشطة
9297
10280
  'download.activeCount': 'التحميلات النشطة ({{count}})',
10281
+ 'download.status.style': 'جارٍ تجهيز النمط',
10282
+ 'download.status.sprites': 'الرموز: {{completed}} من {{total}}',
10283
+ 'download.status.glyphs': 'الخطوط: {{completed}} من {{total}}',
10284
+ 'download.status.models': 'النماذج ثلاثية الأبعاد: {{completed}} من {{total}}',
10285
+ 'download.status.tiles': 'البلاطات: {{completed}} من {{total}}',
10286
+ 'download.status.planning': 'جارٍ تخطيط البلاطات',
10287
+ 'download.status.saving': 'جارٍ حفظ المنطقة',
9298
10288
  // Panel Manager additional strings - سلاسل إضافية للوحة
9299
10289
  'panel.downloadedRegions': 'المناطق المحملة',
9300
10290
  'panel.noExpiry': 'بدون انتهاء',
@@ -9316,6 +10306,8 @@ const ar = {
9316
10306
  'control.saveSelectedRegion': 'حفظ المنطقة المحددة',
9317
10307
  // Alerts/Messages - التنبيهات/الرسائل
9318
10308
  'alert.unknownError': 'حدث خطأ غير معروف',
10309
+ 'alert.downloadFailed': 'فشل التحميل',
10310
+ 'alert.needsMapboxGl': 'يستخدم هذا النمط ميزات Mapbox GL JS، مثل النماذج ثلاثية الأبعاد أو إعدادات النمط، لذا لا يمكن عرضه إلا على خريطة Mapbox GL.',
9319
10311
  'alert.downloadNotImplemented': 'ميزة التحميل غير مطبقة في هذه النسخة',
9320
10312
  'alert.mapNotAvailable': 'الخريطة غير متاحة',
9321
10313
  'alert.invalidBounds': 'حدود غير صالحة للمنطقة',
@@ -9450,6 +10442,9 @@ const i18n = new I18nManager();
9450
10442
  const t = (key, replacements) => {
9451
10443
  return i18n.t(key, replacements);
9452
10444
  };
10445
+ // Re-export i18next for advanced usage
10446
+ /** "1 region" or "3 regions", in the current language. */
10447
+ const regionCountLabel = (count) => count === 1 ? t('regionList.regionCountOne') : t('regionList.regionCount', { count });
9453
10448
 
9454
10449
  /**
9455
10450
  * Base Component Class
@@ -9670,7 +10665,7 @@ class Button extends BaseComponent {
9670
10665
  createProgressBadge() {
9671
10666
  this.progressBadge = document.createElement('span');
9672
10667
  this.progressBadge.className =
9673
- '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';
10668
+ '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';
9674
10669
  // Accessibility: badge ID for aria-describedby
9675
10670
  const badgeId = `btn-badge-${Date.now()}`;
9676
10671
  this.progressBadge.id = badgeId;
@@ -9687,6 +10682,20 @@ class Button extends BaseComponent {
9687
10682
  else {
9688
10683
  this.element.textContent = text;
9689
10684
  }
10685
+ this.restoreProgressBadge();
10686
+ }
10687
+ /**
10688
+ * Replace the button's content, keeping its progress badge.
10689
+ */
10690
+ setContent(html) {
10691
+ this.element.innerHTML = html;
10692
+ this.restoreProgressBadge();
10693
+ }
10694
+ /** Replacing the button's content removes the badge, so put it back. */
10695
+ restoreProgressBadge() {
10696
+ if (this.progressBadge) {
10697
+ this.element.appendChild(this.progressBadge);
10698
+ }
9690
10699
  }
9691
10700
  /**
9692
10701
  * Update button state
@@ -9782,7 +10791,7 @@ class ButtonManager {
9782
10791
  * Reset button to default state
9783
10792
  */
9784
10793
  resetToDefault() {
9785
- this.button.getElement().innerHTML = icons.cloud({ size: 20, color: 'black' });
10794
+ this.button.setContent(icons.cloud({ size: 20, color: 'black' }));
9786
10795
  this.button.setDisabled(false);
9787
10796
  this.button.getElement().title = 'Offline Map Manager';
9788
10797
  this.button.hideProgressBadge();
@@ -9895,8 +10904,7 @@ class Modal extends BaseComponent {
9895
10904
  titleSection.className = 'flex flex-col';
9896
10905
  if (this.config.title) {
9897
10906
  const title = document.createElement('h2');
9898
- title.className =
9899
- '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';
10907
+ title.className = 'text-xl font-semibold text-gray-900 dark:text-white';
9900
10908
  title.textContent = this.config.title;
9901
10909
  titleSection.appendChild(title);
9902
10910
  }
@@ -10075,14 +11083,14 @@ class RegionDetailsModal {
10075
11083
  }
10076
11084
  content.innerHTML = `
10077
11085
  <div class="glass-input p-4 rounded-xl border-0 bg-gray-50/50 dark:bg-gray-800/50">
10078
- <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">
11086
+ <h3 class="m-0 text-xl font-semibold text-gray-900 dark:text-white">
10079
11087
  ${region.name}
10080
11088
  </h3>
10081
11089
  </div>
10082
11090
 
10083
11091
  <div class="grid grid-cols-2 gap-4">
10084
11092
  <div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
10085
- <label class="block mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
11093
+ <label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
10086
11094
  ${t('regionDetails.bounds')}
10087
11095
  </label>
10088
11096
  <div class="text-sm font-mono text-gray-700 dark:text-gray-300 leading-relaxed">
@@ -10092,7 +11100,7 @@ class RegionDetailsModal {
10092
11100
  </div>
10093
11101
 
10094
11102
  <div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
10095
- <label class="block mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
11103
+ <label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
10096
11104
  ${t('regionDetails.zoomRange')}
10097
11105
  </label>
10098
11106
  <div class="text-lg font-semibold text-gray-900 dark:text-white">
@@ -10104,7 +11112,7 @@ class RegionDetailsModal {
10104
11112
  ${region.created
10105
11113
  ? `
10106
11114
  <div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
10107
- <label class="block mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
11115
+ <label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
10108
11116
  ${t('regionDetails.created')}
10109
11117
  </label>
10110
11118
  <div class="text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -10859,11 +11867,13 @@ class PanelRenderer extends BaseComponent {
10859
11867
  headerContainer;
10860
11868
  actionButtonsContainer;
10861
11869
  regionsList;
11870
+ awaitingProgressRender = false;
10862
11871
  downloadProgressContainer;
10863
11872
  languageSelector;
10864
11873
  // Debounce mechanism
10865
11874
  refreshTimeout;
10866
11875
  isRefreshing = false;
11876
+ refreshQueued = false;
10867
11877
  // Language change unsubscribe function
10868
11878
  unsubscribeLanguage;
10869
11879
  // Stored event handler for cleanup
@@ -10932,7 +11942,7 @@ class PanelRenderer extends BaseComponent {
10932
11942
  titleSection.innerHTML = `
10933
11943
  <h2 class="text-xl font-semibold text-gray-900 dark:text-white">${t('header.title')}</h2>
10934
11944
  <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
10935
- ${t('header.subtitle', { count: regions.length, size: formatBytes(analytics.totalStorageSize) })}
11945
+ ${t('header.subtitle', { regions: regionCountLabel(regions.length), size: formatBytes(analytics.totalStorageSize) })}
10936
11946
  </p>
10937
11947
  `;
10938
11948
  // Actions section
@@ -11006,6 +12016,7 @@ class PanelRenderer extends BaseComponent {
11006
12016
  this.element.removeChild(this.downloadProgressContainer);
11007
12017
  this.downloadProgressContainer = undefined;
11008
12018
  }
12019
+ this.awaitingProgressRender = false;
11009
12020
  // Only show if there are active downloads
11010
12021
  if (this.downloadManager?.hasActiveDownloads()) {
11011
12022
  this.downloadProgressContainer = document.createElement('div');
@@ -11028,9 +12039,10 @@ class PanelRenderer extends BaseComponent {
11028
12039
  }
11029
12040
  try {
11030
12041
  // Load styles from IndexedDB and get stats
11031
- const { loadStyles, getStyleStats } = await Promise.resolve().then(function () { return styleService; });
12042
+ const { loadStyles, getStyleStats, getStyleStorageSizes } = await Promise.resolve().then(function () { return styleService; });
11032
12043
  const styles = await loadStyles();
11033
12044
  const statsResult = await getStyleStats();
12045
+ const storageSizes = await getStyleStorageSizes();
11034
12046
  panelLogger.debug('Loaded styles:', styles);
11035
12047
  panelLogger.debug('Style stats:', statsResult);
11036
12048
  // Build style size map from stats
@@ -11053,12 +12065,14 @@ class PanelRenderer extends BaseComponent {
11053
12065
  for (const style of styles) {
11054
12066
  const styleRegions = regionsByStyle[style.key] || [];
11055
12067
  const styleJsonSize = styleSizeMap[style.key] || 0;
11056
- const regionTilesSize = styleRegions.reduce((sum, r) => sum + (r.tileSize || 0), 0);
11057
- const totalSize = styleJsonSize + regionTilesSize;
12068
+ const regionTilesSize = storageSizes[style.key]?.tiles ?? 0;
12069
+ const resourcesSize = storageSizes[style.key]?.resources ?? 0;
12070
+ const totalSize = styleJsonSize + regionTilesSize + resourcesSize;
11058
12071
  // Add style item with computed sizes
11059
12072
  const styleWithSizes = {
11060
12073
  ...style,
11061
12074
  styleJsonSize,
12075
+ resourcesSize,
11062
12076
  regionTilesSize,
11063
12077
  totalSize,
11064
12078
  };
@@ -11174,7 +12188,7 @@ class PanelRenderer extends BaseComponent {
11174
12188
  <div class="text-xs text-slate-500 dark:text-slate-400 mt-1">
11175
12189
  ${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')}
11176
12190
  </div>
11177
- <div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500 mt-2">
12191
+ <div class="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400 mt-2">
11178
12192
  <span>${t('regionList.zoom')}: ${region.minZoom}-${region.maxZoom}</span>
11179
12193
  ${region.downloadedAt ? `<span>${t('regionList.downloaded')}: ${new Date(region.downloadedAt).toLocaleDateString()}</span>` : ''}
11180
12194
  ${region.size ? `<span>${t('regionList.size')}: ${formatBytes(region.size)}</span>` : ''}
@@ -11197,16 +12211,16 @@ class PanelRenderer extends BaseComponent {
11197
12211
  <div class="flex items-center justify-between">
11198
12212
  <div class="flex-1">
11199
12213
  <h3 class="font-semibold text-slate-800 dark:text-slate-100 text-base">
11200
- ${escapeHtml$1(style.style?.name || style.key || t('style.unnamedStyle'))}
12214
+ ${escapeHtml$1(styleDisplayName(style))}
11201
12215
  </h3>
11202
12216
  <p class="text-sm text-slate-600 dark:text-slate-300 mt-1">
11203
12217
  ${t('regionList.styleId')}: ${escapeHtml$1(style.key)}
11204
12218
  </p>
11205
12219
  <p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
11206
- ${regionCount} region${regionCount === 1 ? '' : 's'} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
12220
+ ${regionCountLabel(regionCount)} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
11207
12221
  </p>
11208
- <p class="text-xs text-slate-400 dark:text-slate-500 mt-1">
11209
- ${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
12222
+ <p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
12223
+ ${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.resources')}: ${formatBytes(style.resourcesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
11210
12224
  </p>
11211
12225
  </div>
11212
12226
  <div class="flex items-center gap-2">
@@ -11240,7 +12254,7 @@ class PanelRenderer extends BaseComponent {
11240
12254
  <div class="text-xs text-slate-500 dark:text-slate-400 mt-1">
11241
12255
  ${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')}
11242
12256
  </div>
11243
- <div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500 mt-2">
12257
+ <div class="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400 mt-2">
11244
12258
  <span>${t('regionList.zoom')}: ${region.minZoom}-${region.maxZoom}</span>
11245
12259
  ${region.downloadedAt ? `<span>${t('regionList.downloaded')}: ${new Date(region.downloadedAt).toLocaleDateString()}</span>` : ''}
11246
12260
  ${region.tileSize ? `<span>${t('regionList.tiles')}: ${formatBytes(region.tileSize)}</span>` : ''}
@@ -11279,16 +12293,16 @@ class PanelRenderer extends BaseComponent {
11279
12293
  <div class="flex items-center justify-between">
11280
12294
  <div class="flex-1">
11281
12295
  <h3 class="font-semibold text-blue-900 dark:text-blue-100 text-base">
11282
- ${escapeHtml$1(style.style?.name || style.key || t('style.unnamedStyle'))}
12296
+ ${escapeHtml$1(styleDisplayName(style))}
11283
12297
  </h3>
11284
12298
  <p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
11285
12299
  ${t('regionList.styleId')}: ${escapeHtml$1(style.key)}
11286
12300
  </p>
11287
12301
  <p class="text-sm text-blue-600 dark:text-blue-400 mt-1">
11288
- ${regionCount} region${regionCount === 1 ? '' : 's'} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
12302
+ ${regionCountLabel(regionCount)} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
11289
12303
  </p>
11290
12304
  <p class="text-xs text-blue-500 dark:text-blue-500 mt-1">
11291
- ${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
12305
+ ${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.resources')}: ${formatBytes(style.resourcesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
11292
12306
  </p>
11293
12307
  </div>
11294
12308
  <div class="flex items-center gap-2">
@@ -11328,6 +12342,39 @@ class PanelRenderer extends BaseComponent {
11328
12342
  </div>
11329
12343
  `;
11330
12344
  }
12345
+ /**
12346
+ * Show the latest download progress. Called on every update, so it only
12347
+ * redraws the progress section rather than the whole panel.
12348
+ */
12349
+ updateDownloadProgress(downloads) {
12350
+ if (downloads.size === 0)
12351
+ return;
12352
+ const container = this.downloadProgressContainer;
12353
+ if (container?.isConnected) {
12354
+ container.innerHTML = this.createDownloadProgressHTML(downloads);
12355
+ return;
12356
+ }
12357
+ // The download started while the panel was open, so it has no section yet.
12358
+ if (this.element.isConnected && !this.awaitingProgressRender) {
12359
+ this.awaitingProgressRender = true;
12360
+ void this.refresh();
12361
+ }
12362
+ }
12363
+ /** What the current phase is doing, such as "Tiles: 64 of 128". */
12364
+ describeDownloadPhase(download) {
12365
+ const phase = download.phase;
12366
+ if (phase === 'style')
12367
+ return t('download.status.style');
12368
+ if (phase === 'tiles' && !download.total)
12369
+ return t('download.status.planning');
12370
+ if (phase === 'sprites' || phase === 'glyphs' || phase === 'models' || phase === 'tiles') {
12371
+ return t(`download.status.${phase}`, {
12372
+ completed: Number(download.completed) || 0,
12373
+ total: Number(download.total) || 0,
12374
+ });
12375
+ }
12376
+ return t('download.status.saving');
12377
+ }
11331
12378
  /**
11332
12379
  * Create download progress HTML
11333
12380
  */
@@ -11341,11 +12388,14 @@ class PanelRenderer extends BaseComponent {
11341
12388
  <div class="space-y-2">
11342
12389
  ${downloadArray
11343
12390
  .map(download => `
11344
- <div class="flex items-center justify-between text-sm">
11345
- <span class="text-blue-800 dark:text-blue-200">${escapeHtml$1(download.regionName || download.regionId)}</span>
11346
- <div class="flex items-center gap-2">
11347
- <div class="w-24 h-2 bg-blue-200 dark:bg-blue-800 rounded-full overflow-hidden">
11348
- <div class="h-full bg-blue-600 transition-all duration-300" style="width: ${download.percentage || 0}%"></div>
12391
+ <div class="flex items-center justify-between gap-4 text-sm">
12392
+ <div class="min-w-0">
12393
+ <div class="truncate text-blue-800 dark:text-blue-200">${escapeHtml$1(download.regionName || download.regionId)}</div>
12394
+ <div class="text-xs text-gray-600 dark:text-gray-300">${escapeHtml$1(this.describeDownloadPhase(download))}</div>
12395
+ </div>
12396
+ <div class="flex shrink-0 items-center gap-2">
12397
+ <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)}">
12398
+ <div class="h-full bg-blue-600" style="width: ${download.percentage || 0}%"></div>
11349
12399
  </div>
11350
12400
  <span class="text-blue-700 dark:text-blue-300 min-w-12">${Math.round(download.percentage || 0)}%</span>
11351
12401
  </div>
@@ -11422,7 +12472,15 @@ class PanelRenderer extends BaseComponent {
11422
12472
  cancelText: t('app.cancel'),
11423
12473
  onConfirm: async () => {
11424
12474
  try {
11425
- await this.offlineManager.deleteRegion(regionId);
12475
+ // Deleting a style's last region deletes the style too.
12476
+ const { loadStyleById } = await Promise.resolve().then(function () { return styleService; });
12477
+ const shownEntry = this.isStyleShown(region.styleId)
12478
+ ? await loadStyleById(region.styleId)
12479
+ : null;
12480
+ await this.offlineManager.deleteRegion(regionId, region.styleId);
12481
+ if (shownEntry && !(await loadStyleById(region.styleId))) {
12482
+ this.showOnlineStyle(shownEntry);
12483
+ }
11426
12484
  this.modalManager.close();
11427
12485
  // Refresh the panel to show updated regions
11428
12486
  await this.refresh();
@@ -11475,7 +12533,7 @@ class PanelRenderer extends BaseComponent {
11475
12533
  }
11476
12534
  // Delete the existing region (and its tiles/resources)
11477
12535
  panelLogger.debug('Deleting region before re-download:', regionId);
11478
- await this.offlineManager.deleteRegion(regionId);
12536
+ await this.offlineManager.deleteRegion(regionId, region.styleId);
11479
12537
  // Prepare form data for re-download
11480
12538
  const formData = {
11481
12539
  name: region.name,
@@ -11696,7 +12754,8 @@ class PanelRenderer extends BaseComponent {
11696
12754
  clearTimeout(this.refreshTimeout);
11697
12755
  }
11698
12756
  if (this.isRefreshing) {
11699
- panelLogger.debug('Refresh already in progress, skipping...');
12757
+ // Run again once this one finishes, or the panel misses what changed meanwhile.
12758
+ this.refreshQueued = true;
11700
12759
  return;
11701
12760
  }
11702
12761
  this.refreshTimeout = setTimeout(async () => {
@@ -11740,6 +12799,10 @@ class PanelRenderer extends BaseComponent {
11740
12799
  }
11741
12800
  finally {
11742
12801
  this.isRefreshing = false;
12802
+ if (this.refreshQueued) {
12803
+ this.refreshQueued = false;
12804
+ void this.refresh();
12805
+ }
11743
12806
  }
11744
12807
  }
11745
12808
  /**
@@ -11877,6 +12940,20 @@ class PanelRenderer extends BaseComponent {
11877
12940
  /**
11878
12941
  * Handle loading a style to the map
11879
12942
  */
12943
+ /** Whether the map is drawing this stored style, through `idb://` or the Service Worker. */
12944
+ isStyleShown(styleKey) {
12945
+ const shown = this.map?.getStyle?.();
12946
+ if (!shown)
12947
+ return false;
12948
+ const text = JSON.stringify(shown);
12949
+ return text.includes(`idb://${styleKey}/`) || text.includes(`/__offline__/${styleKey}/`);
12950
+ }
12951
+ /** After its stored copy is deleted, show a style from its original URL, not a blank map. */
12952
+ showOnlineStyle(entry) {
12953
+ if (!entry.originalUrl)
12954
+ return;
12955
+ this.map?.setStyle?.(entry.originalUrl);
12956
+ }
11880
12957
  async handleLoadStyle(styleData) {
11881
12958
  if (!this.map) {
11882
12959
  panelLogger.warn('Map not available for loading style');
@@ -11895,6 +12972,10 @@ class PanelRenderer extends BaseComponent {
11895
12972
  alert(t('error.invalidStyleData'));
11896
12973
  return;
11897
12974
  }
12975
+ if (needsMapboxGl(styleEntry.style) && !isMapboxGlMap(this.map)) {
12976
+ alert(t('alert.needsMapboxGl'));
12977
+ return;
12978
+ }
11898
12979
  panelLogger.debug('Loading style from IndexedDB:', {
11899
12980
  key: styleEntry.key,
11900
12981
  provider: styleEntry.provider,
@@ -12079,13 +13160,16 @@ class PanelRenderer extends BaseComponent {
12079
13160
  const style = styleData;
12080
13161
  const confirmModal = new ConfirmationModal({
12081
13162
  title: t('delete.styleTitle'),
12082
- message: t('delete.styleMessage', { name: style.style?.name || style.key }),
13163
+ message: t('delete.styleMessage', { name: styleDisplayName(style) }),
12083
13164
  confirmText: t('actions.deleteStyle'),
12084
13165
  cancelText: t('app.cancel'),
12085
13166
  onConfirm: async () => {
12086
13167
  try {
12087
13168
  const { deleteStyleById } = await Promise.resolve().then(function () { return styleService; });
13169
+ const wasShown = this.isStyleShown(style.key);
12088
13170
  await deleteStyleById(style.key);
13171
+ if (wasShown)
13172
+ this.showOnlineStyle(styleData);
12089
13173
  this.modalManager.close();
12090
13174
  // Refresh the panel
12091
13175
  await this.refresh();
@@ -12190,7 +13274,7 @@ class PolygonControl {
12190
13274
  this.saveButton = document.createElement('button');
12191
13275
  this.saveButton.type = 'button';
12192
13276
  const prefix = this.options.cssPrefix || 'maplibregl';
12193
- 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`;
13277
+ 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`;
12194
13278
  this.saveButton.innerHTML = icons.check({ size: 16, color: 'white' });
12195
13279
  this.saveButton.title = 'Save Selected Region';
12196
13280
  this.saveButton.addEventListener('click', () => this.handleSave());
@@ -12257,6 +13341,33 @@ class PolygonControl {
12257
13341
  handleMapMove = () => {
12258
13342
  this.updateVisualization();
12259
13343
  };
13344
+ /**
13345
+ * The area to select: the middle of what's on screen. On a tilted or rotated map
13346
+ * `getBounds()` reaches far past the view, so insetting it would miss the part
13347
+ * nearest the camera. There, measure from the map's centre to the screen's edges.
13348
+ */
13349
+ getSelectionBbox(minLng, minLat, maxLng, maxLat) {
13350
+ if (!this.map.getPitch() && !this.map.getBearing()) {
13351
+ const lngInset = (maxLng - minLng) * 0.2;
13352
+ const latInset = (maxLat - minLat) * 0.2;
13353
+ return [minLng + lngInset, minLat + latInset, maxLng - lngInset, maxLat - latInset];
13354
+ }
13355
+ const center = this.map.getCenter();
13356
+ const { clientWidth, clientHeight } = this.map.getContainer();
13357
+ const lngScale = Math.cos((center.lat * Math.PI) / 180);
13358
+ const degreesFromCenter = (point) => {
13359
+ const { lng, lat } = this.map.unproject(point);
13360
+ return Math.hypot((lng - center.lng) * lngScale, lat - center.lat);
13361
+ };
13362
+ const halfSize = 0.6 *
13363
+ Math.min(degreesFromCenter([0, clientHeight / 2]), degreesFromCenter([clientWidth / 2, clientHeight]));
13364
+ return [
13365
+ center.lng - halfSize / lngScale,
13366
+ center.lat - halfSize,
13367
+ center.lng + halfSize / lngScale,
13368
+ center.lat + halfSize,
13369
+ ];
13370
+ }
12260
13371
  /**
12261
13372
  * Update polygon visualization based on current map bounds
12262
13373
  */
@@ -12266,9 +13377,7 @@ class PolygonControl {
12266
13377
  return;
12267
13378
  const [minLng, minLat] = bboxArray[0];
12268
13379
  const [maxLng, maxLat] = bboxArray[1];
12269
- const lngDiff = (maxLng - minLng) * 0.2;
12270
- const latDiff = (maxLat - minLat) * 0.2;
12271
- const clippedBbox = [minLng + lngDiff, minLat + latDiff, maxLng - lngDiff, maxLat - latDiff];
13380
+ const clippedBbox = this.getSelectionBbox(minLng, minLat, maxLng, maxLat);
12272
13381
  // Store current bounds for later use
12273
13382
  this.currentBounds = clippedBbox;
12274
13383
  const clippedPolygon = bboxPolygon.bboxPolygon([
@@ -12335,7 +13444,7 @@ class PolygonControl {
12335
13444
  source: 'selectionPolygon',
12336
13445
  layout: {},
12337
13446
  paint: {
12338
- 'line-color': '#8B5CF6', // Primary purple color
13447
+ 'line-color': '#A8116F', // Revision magenta, the UI's accent
12339
13448
  'line-width': 3,
12340
13449
  'line-dasharray': [2, 2],
12341
13450
  },
@@ -12369,6 +13478,30 @@ class PolygonControl {
12369
13478
  */
12370
13479
  // import { MaplibreMap } from 'maplibre-gl';
12371
13480
  const formLogger = logger.scope('RegionFormModal');
13481
+ /** Mapbox Standard's buildings start at this zoom. */
13482
+ const STANDARD_BUILDINGS_MIN_ZOOM = 15;
13483
+ let fieldCount = 0;
13484
+ /**
13485
+ * Point each label at the one field beside it, so screen readers name the field
13486
+ * and clicking the label focuses it. Groups with several fields are left alone.
13487
+ */
13488
+ function linkLabelsToFields(form) {
13489
+ for (const label of Array.from(form.querySelectorAll('label'))) {
13490
+ const fields = label.parentElement?.querySelectorAll('input, select, textarea');
13491
+ if (label.htmlFor || fields?.length !== 1)
13492
+ continue;
13493
+ const field = fields[0];
13494
+ field.id ||= `map-gl-offline-field-${++fieldCount}`;
13495
+ label.htmlFor = field.id;
13496
+ }
13497
+ }
13498
+ /**
13499
+ * True when a region of Mapbox Standard stops below the zoom where its buildings
13500
+ * start. Standard Satellite has no buildings, so it doesn't count.
13501
+ */
13502
+ function regionMissesBuildings(styleUrl, maxZoom) {
13503
+ return /mapbox\/standard(?![\w-])/.test(styleUrl) && maxZoom < STANDARD_BUILDINGS_MIN_ZOOM;
13504
+ }
12372
13505
  class RegionFormModal {
12373
13506
  options;
12374
13507
  modal;
@@ -12376,6 +13509,7 @@ class RegionFormModal {
12376
13509
  minZoomInput;
12377
13510
  maxZoomInput;
12378
13511
  styleUrlInput;
13512
+ buildingsHint;
12379
13513
  // Enhanced Mapbox GL support
12380
13514
  providerSelect;
12381
13515
  accessTokenInput;
@@ -12426,13 +13560,13 @@ class RegionFormModal {
12426
13560
  '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';
12427
13561
  infoGroup.innerHTML = `
12428
13562
  <div class="flex flex-col justify-center">
12429
- <strong class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.area')}</strong>
12430
- <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">
13563
+ <strong class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.area')}</strong>
13564
+ <div class="text-xl font-semibold text-gray-900 dark:text-white">
12431
13565
  ${area.toLocaleString()} <span class="text-sm font-medium text-gray-500">km²</span>
12432
13566
  </div>
12433
13567
  </div>
12434
13568
  <div>
12435
- <strong class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.bounds')}</strong>
13569
+ <strong class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.bounds')}</strong>
12436
13570
  <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">
12437
13571
  ${west.toFixed(4)}, ${south.toFixed(4)}<br>
12438
13572
  ${east.toFixed(4)}, ${north.toFixed(4)}
@@ -12488,6 +13622,12 @@ class RegionFormModal {
12488
13622
  zoomGroup.appendChild(minZoomDiv);
12489
13623
  zoomGroup.appendChild(maxZoomDiv);
12490
13624
  form.appendChild(zoomGroup);
13625
+ this.buildingsHint = document.createElement('p');
13626
+ this.buildingsHint.className = 'm-0 text-sm text-amber-700 dark:text-amber-400 hidden';
13627
+ this.buildingsHint.setAttribute('role', 'status');
13628
+ this.buildingsHint.textContent = t('regionForm.buildingsHint');
13629
+ form.appendChild(this.buildingsHint);
13630
+ this.maxZoomInput.addEventListener('input', () => this.updateBuildingsHint());
12491
13631
  // Style URL input
12492
13632
  const styleGroup = document.createElement('div');
12493
13633
  styleGroup.innerHTML = `
@@ -12500,9 +13640,13 @@ class RegionFormModal {
12500
13640
  this.styleUrlInput.value = styleUrl;
12501
13641
  this.styleUrlInput.className =
12502
13642
  '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';
12503
- this.styleUrlInput.addEventListener('input', () => this.handleStyleUrlChange());
13643
+ this.styleUrlInput.addEventListener('input', () => {
13644
+ this.handleStyleUrlChange();
13645
+ this.updateBuildingsHint();
13646
+ });
12504
13647
  styleGroup.appendChild(this.styleUrlInput);
12505
13648
  form.appendChild(styleGroup);
13649
+ this.updateBuildingsHint();
12506
13650
  // Provider selection
12507
13651
  const providerGroup = document.createElement('div');
12508
13652
  providerGroup.innerHTML = `
@@ -12560,6 +13704,7 @@ class RegionFormModal {
12560
13704
  }
12561
13705
  // Initialize provider detection
12562
13706
  this.detectProviderFromUrl();
13707
+ linkLabelsToFields(form);
12563
13708
  return form;
12564
13709
  }
12565
13710
  /**
@@ -12674,6 +13819,11 @@ class RegionFormModal {
12674
13819
  /**
12675
13820
  * Handle style URL changes to auto-detect provider
12676
13821
  */
13822
+ /** Warn when a region would be saved without the style's buildings. */
13823
+ updateBuildingsHint() {
13824
+ const missesBuildings = regionMissesBuildings(this.styleUrlInput?.value ?? '', Number(this.maxZoomInput?.value));
13825
+ this.buildingsHint?.classList.toggle('hidden', !missesBuildings);
13826
+ }
12677
13827
  handleStyleUrlChange() {
12678
13828
  this.detectProviderFromUrl();
12679
13829
  }
@@ -12750,10 +13900,21 @@ class RegionFormModal {
12750
13900
  footer.appendChild(saveButton.getElement());
12751
13901
  return footer;
12752
13902
  }
13903
+ /** Check the zoom fields, showing the browser's message on the first bad one. */
13904
+ zoomRangeIsValid() {
13905
+ const { minZoomInput: min, maxZoomInput: max } = this;
13906
+ if (!min || !max)
13907
+ return true;
13908
+ const reversed = min.value !== '' && max.value !== '' && Number(min.value) > Number(max.value);
13909
+ max.setCustomValidity(reversed ? t('regionForm.zoomRangeError') : '');
13910
+ return min.reportValidity() && max.reportValidity();
13911
+ }
12753
13912
  /**
12754
13913
  * Handle form save
12755
13914
  */
12756
13915
  async handleSave() {
13916
+ if (!this.zoomRangeIsValid())
13917
+ return;
12757
13918
  try {
12758
13919
  const selectedSources = this.getSelectedExtraSources();
12759
13920
  const formData = {
@@ -12870,14 +14031,14 @@ class RegionControl {
12870
14031
  this.cancelButton = document.createElement('button');
12871
14032
  this.cancelButton.type = 'button';
12872
14033
  const prefix = this.options.cssPrefix || 'maplibregl';
12873
- 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`;
14034
+ 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`;
12874
14035
  this.cancelButton.innerHTML = icons.x({ size: 16, color: 'white' });
12875
14036
  this.cancelButton.title = 'Cancel Selection';
12876
14037
  this.cancelButton.addEventListener('click', () => this.cancelSelection());
12877
14038
  // Create save button (green checkmark)
12878
14039
  this.saveButton = document.createElement('button');
12879
14040
  this.saveButton.type = 'button';
12880
- 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`;
14041
+ 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`;
12881
14042
  this.saveButton.innerHTML = icons.check({ size: 16, color: 'white' });
12882
14043
  this.saveButton.title = 'Save Selected Region';
12883
14044
  this.saveButton.addEventListener('click', () => this.handleSaveClick());
@@ -13107,6 +14268,8 @@ class DownloadManager {
13107
14268
  offlineManager;
13108
14269
  /** Map of region IDs to their download progress */
13109
14270
  currentDownloads = new Map();
14271
+ /** Aborts each running download, by region id. */
14272
+ controllers = new Map();
13110
14273
  options;
13111
14274
  /**
13112
14275
  * Create a new DownloadManager instance.
@@ -13121,14 +14284,16 @@ class DownloadManager {
13121
14284
  * Calculates percentage and notifies listeners via the onProgressUpdate callback.
13122
14285
  * @internal
13123
14286
  */
13124
- updateProgress(regionId, regionName, phase, completed, total, currentResource) {
13125
- const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
14287
+ updateProgress(regionId, regionName, phase, completed, total, currentResource, overallPercentage) {
14288
+ const percentage = Math.round(overallPercentage);
14289
+ const phasePercentage = total > 0 ? Math.round((completed / total) * 100) : 0;
13126
14290
  this.currentDownloads.set(regionId, {
13127
14291
  regionId,
13128
14292
  regionName,
13129
14293
  completed,
13130
14294
  total,
13131
14295
  percentage,
14296
+ phasePercentage,
13132
14297
  currentResource,
13133
14298
  phase,
13134
14299
  });
@@ -13163,14 +14328,20 @@ class DownloadManager {
13163
14328
  extraSources: formData.extraSources,
13164
14329
  };
13165
14330
  const regionId = regionConfig.id;
14331
+ const controller = new AbortController();
14332
+ this.controllers.set(regionId, controller);
13166
14333
  this.updateUIForDownloadStart();
13167
14334
  try {
13168
14335
  await this.offlineManager.downloadRegion(regionConfig, {
13169
14336
  provider: formData.provider || 'auto',
13170
14337
  accessToken: formData.accessToken,
14338
+ signal: controller.signal,
13171
14339
  onProgress: progress => {
14340
+ // A cancelled download keeps reporting until it stops; don't show it again.
14341
+ if (controller.signal.aborted)
14342
+ return;
13172
14343
  const uiPhase = progress.phase === 'metadata' ? undefined : progress.phase;
13173
- this.updateProgress(regionId, regionConfig.name, uiPhase, progress.completed, progress.total, progress.message ?? `Downloading ${progress.phase}`);
14344
+ this.updateProgress(regionId, regionConfig.name, uiPhase, progress.completed, progress.total, progress.message ?? `Downloading ${progress.phase}`, progress.overallPercentage);
13174
14345
  },
13175
14346
  tileOptions: {
13176
14347
  maxConcurrency: 10,
@@ -13179,8 +14350,16 @@ class DownloadManager {
13179
14350
  this.handleDownloadComplete(regionId);
13180
14351
  }
13181
14352
  catch (error) {
13182
- downloadLogger.error('Error downloading region:', error);
13183
- this.handleDownloadError(regionId, error);
14353
+ if (controller.signal.aborted) {
14354
+ downloadLogger.debug(`Download of ${regionId} was cancelled`);
14355
+ }
14356
+ else {
14357
+ downloadLogger.error('Error downloading region:', error);
14358
+ this.handleDownloadError(regionId, error);
14359
+ }
14360
+ }
14361
+ finally {
14362
+ this.controllers.delete(regionId);
13184
14363
  }
13185
14364
  }
13186
14365
  /**
@@ -13203,6 +14382,8 @@ class DownloadManager {
13203
14382
  * @param regionId - The ID of the region download to cancel
13204
14383
  */
13205
14384
  cancelDownload(regionId) {
14385
+ // Stops the download; downloadRegion then saves nothing and removes its partial tiles.
14386
+ this.controllers.get(regionId)?.abort();
13206
14387
  this.currentDownloads.delete(regionId);
13207
14388
  if (this.currentDownloads.size === 0) {
13208
14389
  this.resetUI();
@@ -13214,6 +14395,8 @@ class DownloadManager {
13214
14395
  * Clears all download tracking and resets the UI.
13215
14396
  */
13216
14397
  cancelAllDownloads() {
14398
+ for (const controller of this.controllers.values())
14399
+ controller.abort();
13217
14400
  this.currentDownloads.clear();
13218
14401
  this.resetUI();
13219
14402
  this.options.onProgressUpdate?.(this.currentDownloads);
@@ -13363,6 +14546,9 @@ class OfflineManagerControl {
13363
14546
  regionControl;
13364
14547
  downloadManager;
13365
14548
  modalManager = new ModalManager();
14549
+ /** Percentages of this batch's downloads, finished ones at 100, for the badge. */
14550
+ badgeBatch = new Map();
14551
+ badgePercentage = 0;
13366
14552
  /** Whether the bounding box visualization layer has been added to the map */
13367
14553
  bboxLayerAdded = false;
13368
14554
  /** Map library module for protocol registration (null if not provided) */
@@ -13417,8 +14603,11 @@ class OfflineManagerControl {
13417
14603
  this.mapLib.addProtocol('idb', async (params) => {
13418
14604
  const response = await idbFetchHandler(params.url);
13419
14605
  if (!response.ok) {
13420
- controlLogger.warn(`addProtocol: IDB fetch failed for ${params.url} (type=${params.type})`);
13421
- throw new Error(`IDB fetch failed: ${response.statusText}`);
14606
+ // MapLibre treats a 404 as "no tile here", which is normal once the map
14607
+ // moves outside a saved region. Any other error gets logged for every tile.
14608
+ throw Object.assign(new Error(`IDB fetch failed (${response.status}): ${params.url}`), {
14609
+ status: response.status,
14610
+ });
13422
14611
  }
13423
14612
  // MapLibre's makeRequest bypasses JSON parsing for protocol handlers,
13424
14613
  // so we must parse JSON responses ourselves (e.g. sprite atlas, TileJSON).
@@ -13674,43 +14863,27 @@ class OfflineManagerControl {
13674
14863
  * Handle progress updates from download manager
13675
14864
  */
13676
14865
  handleProgressUpdate(downloads) {
13677
- // Update the progress badge with the current download percentage
13678
- if (downloads.size > 0) {
13679
- // Get the first download's info (or average percentage if multiple)
13680
- const firstDownload = downloads.values().next().value;
13681
- let displayText = '';
13682
- if (downloads.size === 1 && firstDownload) {
13683
- // Show phase-specific progress for single download
13684
- const phase = firstDownload.phase;
13685
- const percentage = firstDownload.percentage;
13686
- switch (phase) {
13687
- case 'style':
13688
- displayText = 'Style';
13689
- break;
13690
- case 'sprites':
13691
- displayText = `Spr ${percentage}%`;
13692
- break;
13693
- case 'glyphs':
13694
- displayText = `Gly ${percentage}%`;
13695
- break;
13696
- case 'tiles':
13697
- displayText = `${percentage}%`;
13698
- break;
13699
- default:
13700
- displayText = `${percentage}%`;
13701
- }
13702
- }
13703
- else {
13704
- // Multiple downloads - show average
13705
- let totalPercentage = 0;
13706
- for (const download of downloads.values()) {
13707
- totalPercentage += download.percentage;
13708
- }
13709
- const avgPercentage = Math.round(totalPercentage / downloads.size);
13710
- displayText = `${avgPercentage}%`;
13711
- }
13712
- this.updateProgressBadge(displayText, true);
14866
+ // The panel has the detail for each phase; the badge only has room for one
14867
+ // figure. With several downloads it averages all of this batch's, counting
14868
+ // finished ones as 100, so one finishing doesn't drop the figure, and never
14869
+ // goes back: a download added mid-batch holds it until the average catches up.
14870
+ for (const [regionId, download] of downloads) {
14871
+ this.badgeBatch.set(regionId, download.percentage);
14872
+ }
14873
+ for (const regionId of this.badgeBatch.keys()) {
14874
+ if (!downloads.has(regionId))
14875
+ this.badgeBatch.set(regionId, 100);
14876
+ }
14877
+ if (downloads.size === 0) {
14878
+ this.badgeBatch.clear();
14879
+ this.badgePercentage = 0;
14880
+ }
14881
+ else {
14882
+ const total = [...this.badgeBatch.values()].reduce((sum, value) => sum + value, 0);
14883
+ this.badgePercentage = Math.max(this.badgePercentage, Math.round(total / this.badgeBatch.size));
14884
+ this.updateProgressBadge(`${this.badgePercentage}%`, true);
13713
14885
  }
14886
+ this.panelRenderer?.updateDownloadProgress(downloads);
13714
14887
  }
13715
14888
  /**
13716
14889
  * Handle download completion
@@ -13726,6 +14899,7 @@ class OfflineManagerControl {
13726
14899
  */
13727
14900
  handleDownloadError(regionId, error) {
13728
14901
  controlLogger.error(`Download error for region ${regionId}:`, error);
14902
+ alert(`${t('alert.downloadFailed')}: ${error instanceof Error ? error.message : t('alert.unknownError')}`);
13729
14903
  // Use the panel's refresh method instead of renderPanel to avoid conflicts
13730
14904
  if (this.panelRenderer) {
13731
14905
  this.panelRenderer.refresh();
@@ -13869,7 +15043,7 @@ class OfflineManagerControl {
13869
15043
  type: 'line',
13870
15044
  source: sourceId,
13871
15045
  paint: {
13872
- 'line-color': '#3B82F6', // Blue color
15046
+ 'line-color': '#A8116F', // Revision magenta, the UI's accent
13873
15047
  'line-width': 3,
13874
15048
  'line-opacity': 0.8,
13875
15049
  },
@@ -13922,6 +15096,10 @@ class OfflineManagerControl {
13922
15096
  controlLogger.error(`Style ${styleId} not found in IndexedDB`);
13923
15097
  return;
13924
15098
  }
15099
+ if (needsMapboxGl(styleEntry.style) && !isMapboxGlMap(this.map)) {
15100
+ controlLogger.error(`Style ${styleId} needs Mapbox GL JS, so this map can't show it`);
15101
+ return;
15102
+ }
13925
15103
  // The style is already patched with idb:// URLs by addRegion() in regionService.
13926
15104
  // DO NOT call patchStyleForOffline() again — double-patching rewrites the downloadId
13927
15105
  // in URLs and can break tile/glyph lookups.
@@ -13980,9 +15158,9 @@ class OfflineManagerControl {
13980
15158
  ${styles
13981
15159
  .map(style => `
13982
15160
  <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)}">
13983
- <div class="font-medium text-gray-900 dark:text-white">${escapeHtml$1(style.style.name || style.key)}</div>
15161
+ <div class="font-medium text-gray-900 dark:text-white">${escapeHtml$1(styleDisplayName(style))}</div>
13984
15162
  <div class="text-sm text-gray-500 dark:text-gray-400">
13985
- ${style.regions?.length || 0} regions •
15163
+ ${regionCountLabel(style.regions?.length || 0)} •
13986
15164
  ${Object.keys(style.style.sources || {}).length} sources
13987
15165
  </div>
13988
15166
  </button>
@@ -14080,6 +15258,52 @@ class OfflineManagerControl {
14080
15258
  }
14081
15259
  }
14082
15260
 
15261
+ const assetLogger = logger.scope('RuntimeAssets');
15262
+ const ASSET_TIMEOUT_MS = 30000;
15263
+ /**
15264
+ * Cache the code Mapbox GL fetches from api.mapbox.com while rendering: the
15265
+ * Draco and meshopt decoders 3D models need, plus the RTL text and procedural
15266
+ * building modules. The Service Worker serves them back from this cache. They
15267
+ * live in the Cache API rather than IndexedDB because they belong to the
15268
+ * renderer, not to any style or region.
15269
+ *
15270
+ * Failures only log: the rest of a region is still usable without these.
15271
+ *
15272
+ * @returns how many assets are now cached
15273
+ */
15274
+ async function cacheMapboxRuntimeAssets() {
15275
+ if (typeof caches === 'undefined')
15276
+ return 0;
15277
+ let cache;
15278
+ try {
15279
+ cache = await caches.open(MAPBOX_RUNTIME_CACHE);
15280
+ }
15281
+ catch (error) {
15282
+ assetLogger.warn('Cache API unavailable, skipping runtime assets:', error);
15283
+ return 0;
15284
+ }
15285
+ const results = await Promise.all(MAPBOX_RUNTIME_ASSETS.map(async (path) => {
15286
+ const url = `${MAPBOX_RUNTIME_ORIGIN}${path}`;
15287
+ try {
15288
+ if (await cache.match(url))
15289
+ return true;
15290
+ const response = await fetchWithRetry(url, { retries: 0, timeout: ASSET_TIMEOUT_MS });
15291
+ await cache.put(url, response);
15292
+ return true;
15293
+ }
15294
+ catch (error) {
15295
+ assetLogger.warn(`Failed to cache ${url}:`, error);
15296
+ return false;
15297
+ }
15298
+ }));
15299
+ return results.filter(Boolean).length;
15300
+ }
15301
+
15302
+ var runtimeAssetService = /*#__PURE__*/Object.freeze({
15303
+ __proto__: null,
15304
+ cacheMapboxRuntimeAssets: cacheMapboxRuntimeAssets
15305
+ });
15306
+
14083
15307
  exports.AnalyticsService = AnalyticsService;
14084
15308
  exports.CONTENT_TYPES = CONTENT_TYPES;
14085
15309
  exports.CategorizedError = CategorizedError;
@@ -14175,6 +15399,7 @@ exports.getSpriteAnalytics = getSpriteAnalytics;
14175
15399
  exports.getSpriteStats = getSpriteStats;
14176
15400
  exports.getSqlJs = getSqlJs;
14177
15401
  exports.getStyleStats = getStyleStats;
15402
+ exports.getStyleStorageSizes = getStyleStorageSizes;
14178
15403
  exports.getTileAnalytics = getTileAnalytics;
14179
15404
  exports.getTileStats = getTileStats;
14180
15405
  exports.getUrlHostname = getUrlHostname;
@@ -14185,6 +15410,8 @@ exports.hostMatches = hostMatches;
14185
15410
  exports.i18n = i18n;
14186
15411
  exports.icons = icons;
14187
15412
  exports.idbFetchHandler = idbFetchHandler;
15413
+ exports.injectExtraSources = injectExtraSources;
15414
+ exports.isMapboxGlMap = isMapboxGlMap;
14188
15415
  exports.isMapboxHost = isMapboxHost;
14189
15416
  exports.isMapboxProtocol = isMapboxProtocol;
14190
15417
  exports.isStyleDownloaded = isStyleDownloaded;
@@ -14195,6 +15422,7 @@ exports.loadStyles = loadStyles;
14195
15422
  exports.logger = logger;
14196
15423
  exports.modelKeyBelongsToStyle = modelKeyBelongsToStyle;
14197
15424
  exports.modelService = modelService;
15425
+ exports.needsMapboxGl = needsMapboxGl;
14198
15426
  exports.normalizeSpriteProperty = normalizeSpriteProperty;
14199
15427
  exports.normalizeStyleUrl = normalizeStyleUrl;
14200
15428
  exports.optimizeStorage = optimizeStorage;
@@ -14209,12 +15437,17 @@ exports.resetOfflineMapDB = resetOfflineMapDB;
14209
15437
  exports.resolveImports = resolveImports;
14210
15438
  exports.resolveMapboxUrl = resolveMapboxUrl;
14211
15439
  exports.resourceKeyBelongsToStyle = resourceKeyBelongsToStyle;
15440
+ exports.restoreUnstoredModels = restoreUnstoredModels;
14212
15441
  exports.rewriteMapboxCdnTileUrl = rewriteMapboxCdnTileUrl;
14213
15442
  exports.safeExecute = safeExecute;
14214
15443
  exports.sanitizeIndoorExpressions = sanitizeIndoorExpressions;
15444
+ exports.selectTileJsonVariant = selectTileJsonVariant;
14215
15445
  exports.setupAutoCleanup = setupAutoCleanup;
14216
15446
  exports.spriteService = spriteService;
15447
+ exports.stashOriginalModels = stashOriginalModels;
14217
15448
  exports.stopAutoCleanup = stopAutoCleanup;
15449
+ exports.storedModelNames = storedModelNames;
15450
+ exports.styleDisplayName = styleDisplayName;
14218
15451
  exports.t = t;
14219
15452
  exports.tileService = tileService;
14220
15453
  exports.unregisterOfflineServiceWorker = unregisterOfflineServiceWorker;