map-gl-offline 0.8.9 → 0.9.1
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.
- package/README.md +62 -192
- package/dist/idb-offline-sw.js +78 -11
- package/dist/index.d.ts +3 -3
- package/dist/index.esm.js +2062 -811
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +2069 -809
- package/dist/index.js.map +1 -1
- package/dist/index.umd.js +2401 -940
- package/dist/index.umd.js.map +1 -1
- package/dist/main.d.ts +3 -0
- package/dist/managers/offlineMapManager/analyticsManagement.d.ts +1 -1
- package/dist/managers/offlineMapManager/base.d.ts +5 -5
- package/dist/managers/offlineMapManager/cleanupManagement.d.ts +6 -1
- package/dist/managers/offlineMapManager/importExportManagement.d.ts +1 -1
- package/dist/managers/offlineMapManager/maintenanceManagement.d.ts +1 -1
- package/dist/managers/offlineMapManager/regionManagement.d.ts +2 -2
- package/dist/managers/offlineMapManager/resourceManagement.d.ts +1 -1
- package/dist/managers/offlineMapManager/styleManagement.d.ts +1 -1
- package/dist/services/analyticsService.d.ts +1 -1
- package/dist/services/cleanupService.d.ts +15 -1
- package/dist/services/fontService.d.ts +1 -1
- package/dist/services/glyphService.d.ts +1 -1
- package/dist/services/importExportService.d.ts +1 -1
- package/dist/services/maintenanceService.d.ts +2 -2
- package/dist/services/modelService.d.ts +3 -1
- package/dist/services/regionService.d.ts +21 -11
- package/dist/services/resourceService.d.ts +2 -2
- package/dist/services/runtimeAssetService.d.ts +12 -0
- package/dist/services/spriteService.d.ts +1 -1
- package/dist/services/styleService.d.ts +14 -1
- package/dist/services/tileService.d.ts +1 -1
- package/dist/sheet/collar.d.ts +34 -0
- package/dist/storage/indexedDbManager.d.ts +1 -1
- package/dist/style.css +2 -2
- package/dist/sw/shared.d.ts +31 -3
- package/dist/types/glyph.d.ts +2 -0
- package/dist/types/import-export.d.ts +5 -0
- package/dist/types/maintenance.d.ts +3 -1
- package/dist/types/model.d.ts +2 -0
- package/dist/types/region.d.ts +29 -2
- package/dist/types/sprite.d.ts +2 -0
- package/dist/types/style.d.ts +4 -0
- package/dist/types/tile.d.ts +15 -0
- package/dist/ui/components/PanelActions.d.ts +1 -1
- package/dist/ui/components/PanelHeader.d.ts +1 -1
- package/dist/ui/components/RegionList.d.ts +1 -1
- package/dist/ui/components/shared/Button.d.ts +6 -0
- package/dist/ui/components/shared/LanguageSelector.d.ts +1 -1
- package/dist/ui/components/shared/MapControlButton.d.ts +1 -1
- package/dist/ui/components/shared/PanelContent.d.ts +3 -3
- package/dist/ui/components/shared/RegionDrawingTool.d.ts +2 -2
- package/dist/ui/controls/polygonControl.d.ts +7 -1
- package/dist/ui/controls/regionControl.d.ts +3 -3
- package/dist/ui/managers/ControlButtonManager.d.ts +1 -1
- package/dist/ui/managers/PanelManager.d.ts +16 -3
- package/dist/ui/managers/downloadManager.d.ts +7 -3
- package/dist/ui/modals/mbtilesModal.d.ts +1 -1
- package/dist/ui/modals/regionDetailsModal.d.ts +1 -1
- package/dist/ui/modals/regionFormModal.d.ts +11 -1
- package/dist/ui/offlineManagerControl.d.ts +4 -1
- package/dist/ui/translations/ar.d.ts +16 -1
- package/dist/ui/translations/en.d.ts +16 -1
- package/dist/ui/translations/index.d.ts +2 -0
- package/dist/utils/constants.d.ts +4 -2
- package/dist/utils/convertStyleForSW.d.ts +1 -1
- package/dist/utils/download.d.ts +1 -1
- package/dist/utils/formatting.d.ts +11 -0
- package/dist/utils/importResolver.d.ts +1 -1
- package/dist/utils/styleConfig.d.ts +39 -0
- package/dist/utils/styleProviderUtils.d.ts +21 -8
- package/dist/utils/styleUtils.d.ts +61 -10
- package/dist/utils/tileRange.d.ts +63 -0
- package/dist/utils/validation.d.ts +1 -1
- 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
|
|
190
|
-
*
|
|
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
|
-
|
|
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
|
|
1319
|
-
*
|
|
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
|
-
*
|
|
1502
|
-
*
|
|
1503
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
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
|
-
|
|
1719
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
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}
|
|
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}
|
|
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
|
|
2013
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${path}.json${query}`;
|
|
1896
2014
|
}
|
|
1897
2015
|
/**
|
|
1898
|
-
*
|
|
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
|
|
1901
|
-
*
|
|
1902
|
-
*
|
|
1903
|
-
*
|
|
1904
|
-
*
|
|
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://
|
|
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
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
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
|
-
|
|
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 -
|
|
2151
|
-
* @param maxZoom -
|
|
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 -
|
|
2154
|
-
*
|
|
2155
|
-
*
|
|
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 =
|
|
2406
|
+
source.tiles = originalTiles.map((url) => {
|
|
2174
2407
|
const ext = tileExtension ?? extractTileExtensionFromUrl(url);
|
|
2175
|
-
return `idb://${
|
|
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
|
|
2184
|
-
// don't raise it above the tileset's actual range (e.g.
|
|
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.
|
|
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://${
|
|
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://${
|
|
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
|
|
2278
|
-
*
|
|
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
|
-
//
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
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
|
-
//
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
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
|
-
|
|
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
|
|
3058
|
-
if
|
|
3059
|
-
|
|
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:
|
|
3077
|
-
|
|
3078
|
-
|
|
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
|
-
*
|
|
3269
|
-
*
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
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
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
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.
|
|
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,590 @@ async function getSqlJs() {
|
|
|
3356
3715
|
return sqlJsPromise;
|
|
3357
3716
|
}
|
|
3358
3717
|
|
|
3359
|
-
const
|
|
3360
|
-
|
|
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
|
-
|
|
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 =
|
|
3365
|
-
const
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3765
|
+
const { onProgress, batchSize = 4, maxRetries = 3, skipExisting = true, timeoutMs = 30000, signal, } = 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
|
|
3378
|
-
const
|
|
3379
|
-
|
|
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
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
const
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3788
|
+
await processBatch(entries, async ([modelName, url]) => {
|
|
3789
|
+
// Cancelled: let the remaining models go by without fetching them.
|
|
3790
|
+
if (signal?.aborted)
|
|
3791
|
+
return;
|
|
3792
|
+
const key = modelKey(styleId, modelName);
|
|
3793
|
+
const label = `${styleId}::${modelName}`;
|
|
3794
|
+
if (skipExisting && existingKeys.has(key)) {
|
|
3795
|
+
result.skippedModels++;
|
|
3796
|
+
progressTracker.update(1, label);
|
|
3797
|
+
emit();
|
|
3798
|
+
return;
|
|
3398
3799
|
}
|
|
3399
|
-
}
|
|
3400
|
-
// Process fonts in batches with concurrency control
|
|
3401
|
-
await processBatch(urlsToDownload, async (fontUrl) => {
|
|
3402
3800
|
try {
|
|
3403
|
-
const
|
|
3404
|
-
const response = await fetchResourceWithRetry(fontUrl, {
|
|
3801
|
+
const response = await fetchResourceWithRetry(url, {
|
|
3405
3802
|
retries: maxRetries,
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
proxyType: 'fonts',
|
|
3803
|
+
timeout: timeoutMs,
|
|
3804
|
+
proxyType: 'tiles',
|
|
3409
3805
|
});
|
|
3410
3806
|
if (response.type === 'json') {
|
|
3411
|
-
throw new Error(
|
|
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);
|
|
3807
|
+
throw new Error('Unexpected JSON response for model');
|
|
3419
3808
|
}
|
|
3420
|
-
|
|
3421
|
-
const
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
data: fontData,
|
|
3809
|
+
const data = response.data;
|
|
3810
|
+
const contentType = ('contentType' in response && response.contentType) || 'model/gltf-binary';
|
|
3811
|
+
const entry = {
|
|
3812
|
+
key,
|
|
3813
|
+
data,
|
|
3426
3814
|
contentType,
|
|
3427
|
-
|
|
3428
|
-
|
|
3815
|
+
size: data.byteLength,
|
|
3816
|
+
url,
|
|
3817
|
+
styleId,
|
|
3818
|
+
modelName,
|
|
3429
3819
|
lastModified: Date.now(),
|
|
3430
3820
|
downloadedAt: new Date().toISOString(),
|
|
3431
|
-
downloadId: styleName,
|
|
3432
|
-
metadata: {
|
|
3433
|
-
userAgent: navigator.userAgent,
|
|
3434
|
-
downloadTimestamp: Date.now(),
|
|
3435
|
-
},
|
|
3436
3821
|
expires: response.expires,
|
|
3437
3822
|
};
|
|
3438
|
-
await db.put('
|
|
3439
|
-
|
|
3440
|
-
totalSize +=
|
|
3441
|
-
|
|
3442
|
-
// Track font types
|
|
3443
|
-
const fontType = this.detectFontType(contentType, fontUrl);
|
|
3444
|
-
fontsByType[fontType] = (fontsByType[fontType] || 0) + 1;
|
|
3823
|
+
await db.put('models', entry);
|
|
3824
|
+
result.downloadedModels++;
|
|
3825
|
+
result.totalSize += data.byteLength;
|
|
3826
|
+
progressTracker.update(1, label);
|
|
3445
3827
|
}
|
|
3446
|
-
catch (
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
errors.push({
|
|
3450
|
-
|
|
3451
|
-
|
|
3828
|
+
catch (err) {
|
|
3829
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3830
|
+
result.failedModels++;
|
|
3831
|
+
result.errors.push({ url, error: message });
|
|
3832
|
+
modelLogger.warn(`Failed to download model "${modelName}" from ${url}:`, err);
|
|
3833
|
+
progressTracker.update(1, label, message);
|
|
3834
|
+
}
|
|
3835
|
+
emit();
|
|
3836
|
+
}, { batchSize });
|
|
3837
|
+
modelLogger.info(`Models downloaded for style ${styleId}: ${result.downloadedModels} new, ${result.skippedModels} skipped, ${result.failedModels} failed`);
|
|
3838
|
+
return result;
|
|
3839
|
+
}
|
|
3840
|
+
/** Retrieve a single model by `{styleId, modelName}`. */
|
|
3841
|
+
async getModel(styleId, modelName) {
|
|
3842
|
+
const db = await this.db;
|
|
3843
|
+
return db.get('models', modelKey(styleId, modelName));
|
|
3844
|
+
}
|
|
3845
|
+
/** Aggregate stats across all stored models. */
|
|
3846
|
+
async getModelStats() {
|
|
3847
|
+
const db = await this.db;
|
|
3848
|
+
const stats = {
|
|
3849
|
+
count: 0,
|
|
3850
|
+
totalSize: 0,
|
|
3851
|
+
averageSize: 0,
|
|
3852
|
+
models: [],
|
|
3853
|
+
modelsByStyle: {},
|
|
3854
|
+
};
|
|
3855
|
+
const tx = db.transaction('models', 'readonly');
|
|
3856
|
+
for await (const cursor of tx.store) {
|
|
3857
|
+
const m = cursor.value;
|
|
3858
|
+
stats.count++;
|
|
3859
|
+
stats.totalSize += m.size;
|
|
3860
|
+
stats.models.push({ name: m.modelName, size: m.size, lastModified: m.lastModified });
|
|
3861
|
+
stats.modelsByStyle[m.styleId] = (stats.modelsByStyle[m.styleId] ?? 0) + 1;
|
|
3862
|
+
}
|
|
3863
|
+
stats.averageSize = stats.count > 0 ? stats.totalSize / stats.count : 0;
|
|
3864
|
+
return stats;
|
|
3865
|
+
}
|
|
3866
|
+
/**
|
|
3867
|
+
* Delete models older than `maxAge` days. Defaults to 30.
|
|
3868
|
+
*/
|
|
3869
|
+
async cleanupOldModels(maxAge = 30) {
|
|
3870
|
+
const db = await this.db;
|
|
3871
|
+
const cutoff = Date.now() - maxAge * 24 * 60 * 60 * 1000;
|
|
3872
|
+
let deleted = 0;
|
|
3873
|
+
const tx = db.transaction('models', 'readwrite');
|
|
3874
|
+
for await (const cursor of tx.store) {
|
|
3875
|
+
if (cursor.value.lastModified < cutoff) {
|
|
3876
|
+
await cursor.delete();
|
|
3877
|
+
deleted++;
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
return deleted;
|
|
3881
|
+
}
|
|
3882
|
+
/**
|
|
3883
|
+
* Basic integrity check: remove entries with empty/missing data.
|
|
3884
|
+
*/
|
|
3885
|
+
async verifyAndRepairModels() {
|
|
3886
|
+
const db = await this.db;
|
|
3887
|
+
let verified = 0;
|
|
3888
|
+
let removed = 0;
|
|
3889
|
+
const tx = db.transaction('models', 'readwrite');
|
|
3890
|
+
for await (const cursor of tx.store) {
|
|
3891
|
+
const m = cursor.value;
|
|
3892
|
+
if (!m.data || m.data.byteLength === 0) {
|
|
3893
|
+
await cursor.delete();
|
|
3894
|
+
removed++;
|
|
3895
|
+
}
|
|
3896
|
+
else {
|
|
3897
|
+
verified++;
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
return { verified, repaired: 0, removed };
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
// Singleton + convenience exports, matching other service modules.
|
|
3904
|
+
const modelService = new ModelService();
|
|
3905
|
+
const downloadModels = (models, styleId, options) => modelService.downloadModels(models, styleId, options);
|
|
3906
|
+
const getModel = (styleId, modelName) => modelService.getModel(styleId, modelName);
|
|
3907
|
+
const getModelStats = () => modelService.getModelStats();
|
|
3908
|
+
const cleanupOldModels = (maxAge) => modelService.cleanupOldModels(maxAge);
|
|
3909
|
+
const verifyAndRepairModels = () => modelService.verifyAndRepairModels();
|
|
3910
|
+
|
|
3911
|
+
var modelService$1 = /*#__PURE__*/Object.freeze({
|
|
3912
|
+
__proto__: null,
|
|
3913
|
+
ModelService: ModelService,
|
|
3914
|
+
cleanupOldModels: cleanupOldModels,
|
|
3915
|
+
downloadModels: downloadModels,
|
|
3916
|
+
getModel: getModel,
|
|
3917
|
+
getModelStats: getModelStats,
|
|
3918
|
+
modelKeyBelongsToStyle: modelKeyBelongsToStyle,
|
|
3919
|
+
modelService: modelService,
|
|
3920
|
+
storedModelNames: storedModelNames,
|
|
3921
|
+
verifyAndRepairModels: verifyAndRepairModels
|
|
3922
|
+
});
|
|
3923
|
+
|
|
3924
|
+
/**
|
|
3925
|
+
* Mapbox GL v3 style-config helpers.
|
|
3926
|
+
*
|
|
3927
|
+
* Mapbox Standard is one style with switchable features: most of its layers set
|
|
3928
|
+
* `layout.visibility` from `["config", key]` expressions backed by `style.schema`
|
|
3929
|
+
* defaults. These helpers work out which sources the style actually needs for
|
|
3930
|
+
* the current config, so features that are switched off aren't downloaded and
|
|
3931
|
+
* features that are switched on aren't skipped.
|
|
3932
|
+
*/
|
|
3933
|
+
const configLogger = logger.scope('StyleConfig');
|
|
3934
|
+
/** Sentinel for an expression that can't be decided from config alone. */
|
|
3935
|
+
const UNKNOWN = Symbol('unknown');
|
|
3936
|
+
/**
|
|
3937
|
+
* True for styles whose feature set is config-driven: they declare a `schema`,
|
|
3938
|
+
* or were flattened from `imports` (whose config is already resolved into the
|
|
3939
|
+
* layers). Other styles are never filtered by config.
|
|
3940
|
+
*/
|
|
3941
|
+
function isConfigDrivenStyle(style) {
|
|
3942
|
+
const s = style;
|
|
3943
|
+
if (!s)
|
|
3944
|
+
return false;
|
|
3945
|
+
return ((s.schema !== undefined && Object.keys(s.schema).length > 0) ||
|
|
3946
|
+
(Array.isArray(s.imports) && s.imports.length > 0));
|
|
3947
|
+
}
|
|
3948
|
+
function evaluate(expr, config) {
|
|
3949
|
+
if (!Array.isArray(expr))
|
|
3950
|
+
return expr;
|
|
3951
|
+
const [op, ...args] = expr;
|
|
3952
|
+
switch (op) {
|
|
3953
|
+
case 'literal':
|
|
3954
|
+
return args[0];
|
|
3955
|
+
case 'config': {
|
|
3956
|
+
const key = args[0];
|
|
3957
|
+
return typeof key === 'string' && key in config ? config[key] : UNKNOWN;
|
|
3958
|
+
}
|
|
3959
|
+
case '!': {
|
|
3960
|
+
const value = evaluate(args[0], config);
|
|
3961
|
+
return typeof value === 'boolean' ? !value : UNKNOWN;
|
|
3962
|
+
}
|
|
3963
|
+
case 'all':
|
|
3964
|
+
case 'any': {
|
|
3965
|
+
const decisive = op === 'any';
|
|
3966
|
+
let sawUnknown = false;
|
|
3967
|
+
for (const arg of args) {
|
|
3968
|
+
const value = evaluate(arg, config);
|
|
3969
|
+
if (value === decisive)
|
|
3970
|
+
return decisive;
|
|
3971
|
+
if (typeof value !== 'boolean')
|
|
3972
|
+
sawUnknown = true;
|
|
3973
|
+
}
|
|
3974
|
+
return sawUnknown ? UNKNOWN : !decisive;
|
|
3975
|
+
}
|
|
3976
|
+
case '==':
|
|
3977
|
+
case '!=': {
|
|
3978
|
+
const a = evaluate(args[0], config);
|
|
3979
|
+
const b = evaluate(args[1], config);
|
|
3980
|
+
if (a === UNKNOWN || b === UNKNOWN)
|
|
3981
|
+
return UNKNOWN;
|
|
3982
|
+
return op === '==' ? a === b : a !== b;
|
|
3983
|
+
}
|
|
3984
|
+
case 'case': {
|
|
3985
|
+
// ["case", cond, out, cond, out, ..., fallback]
|
|
3986
|
+
for (let i = 0; i < args.length - 1; i += 2) {
|
|
3987
|
+
const condition = evaluate(args[i], config);
|
|
3988
|
+
if (typeof condition !== 'boolean')
|
|
3989
|
+
return UNKNOWN;
|
|
3990
|
+
if (condition)
|
|
3991
|
+
return evaluate(args[i + 1], config);
|
|
3992
|
+
}
|
|
3993
|
+
return evaluate(args[args.length - 1], config);
|
|
3994
|
+
}
|
|
3995
|
+
case 'match': {
|
|
3996
|
+
// ["match", input, label, out, label, out, ..., fallback]
|
|
3997
|
+
const input = evaluate(args[0], config);
|
|
3998
|
+
if (input === UNKNOWN)
|
|
3999
|
+
return UNKNOWN;
|
|
4000
|
+
for (let i = 1; i < args.length - 1; i += 2) {
|
|
4001
|
+
const label = args[i];
|
|
4002
|
+
if (Array.isArray(label) ? label.includes(input) : label === input) {
|
|
4003
|
+
return evaluate(args[i + 1], config);
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
return evaluate(args[args.length - 1], config);
|
|
4007
|
+
}
|
|
4008
|
+
default:
|
|
4009
|
+
return UNKNOWN;
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
function collectIconsetIds(value, ids) {
|
|
4013
|
+
if (Array.isArray(value)) {
|
|
4014
|
+
for (const item of value)
|
|
4015
|
+
collectIconsetIds(item, ids);
|
|
4016
|
+
}
|
|
4017
|
+
else if (value && typeof value === 'object') {
|
|
4018
|
+
const record = value;
|
|
4019
|
+
const iconset = record.iconset;
|
|
4020
|
+
if (iconset && typeof iconset.id === 'string')
|
|
4021
|
+
ids.add(iconset.id);
|
|
4022
|
+
for (const item of Object.values(record))
|
|
4023
|
+
collectIconsetIds(item, ids);
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
/**
|
|
4027
|
+
* Sources a config-driven style uses under its current config (`active`), and
|
|
4028
|
+
* those it references but only from hidden layers or unused iconsets (`inactive`).
|
|
4029
|
+
*
|
|
4030
|
+
* Deliberately errs towards keeping sources: a visibility expression that can't
|
|
4031
|
+
* be decided counts as visible. Sources nothing in the style references
|
|
4032
|
+
* (`extraSources`, ones a caller adds at runtime) are in neither set.
|
|
4033
|
+
*/
|
|
4034
|
+
function classifySources(style) {
|
|
4035
|
+
const inactive = new Set();
|
|
4036
|
+
const active = new Set();
|
|
4037
|
+
if (!isConfigDrivenStyle(style))
|
|
4038
|
+
return { active, inactive };
|
|
4039
|
+
const s = style;
|
|
4040
|
+
const config = {};
|
|
4041
|
+
for (const [key, entry] of Object.entries(s.schema ?? {})) {
|
|
4042
|
+
if (entry && typeof entry === 'object' && 'default' in entry)
|
|
4043
|
+
config[key] = entry.default;
|
|
4044
|
+
}
|
|
4045
|
+
const activeIconsets = new Set();
|
|
4046
|
+
for (const layer of (s.layers ?? [])) {
|
|
4047
|
+
const visible = evaluate(layer?.layout?.visibility ?? 'visible', config) !== 'none';
|
|
4048
|
+
if (typeof layer?.source === 'string') {
|
|
4049
|
+
inactive.add(layer.source);
|
|
4050
|
+
if (visible)
|
|
4051
|
+
active.add(layer.source);
|
|
4052
|
+
}
|
|
4053
|
+
if (visible) {
|
|
4054
|
+
// The whole layer, since `appearances` can also name an iconset.
|
|
4055
|
+
collectIconsetIds(layer, activeIconsets);
|
|
4056
|
+
}
|
|
4057
|
+
}
|
|
4058
|
+
for (const [id, iconset] of Object.entries(s.iconsets ?? {})) {
|
|
4059
|
+
if (typeof iconset?.source !== 'string')
|
|
4060
|
+
continue;
|
|
4061
|
+
inactive.add(iconset.source);
|
|
4062
|
+
if (activeIconsets.has(id))
|
|
4063
|
+
active.add(iconset.source);
|
|
4064
|
+
}
|
|
4065
|
+
if (typeof s.terrain?.source === 'string')
|
|
4066
|
+
active.add(s.terrain.source);
|
|
4067
|
+
for (const id of active)
|
|
4068
|
+
inactive.delete(id);
|
|
4069
|
+
return { active, inactive };
|
|
4070
|
+
}
|
|
4071
|
+
/**
|
|
4072
|
+
* Write config values into a style's `schema` defaults. `["config", key]` reads
|
|
4073
|
+
* those defaults at render time, so this one edit drives both what gets
|
|
4074
|
+
* downloaded and what the stored style shows offline. Keys the schema doesn't
|
|
4075
|
+
* declare are ignored with a warning.
|
|
4076
|
+
*/
|
|
4077
|
+
function applyStyleConfig(style, config) {
|
|
4078
|
+
const schema = style.schema;
|
|
4079
|
+
for (const [key, value] of Object.entries(config)) {
|
|
4080
|
+
const entry = schema?.[key];
|
|
4081
|
+
if (entry && typeof entry === 'object') {
|
|
4082
|
+
entry.default = value;
|
|
4083
|
+
}
|
|
4084
|
+
else {
|
|
4085
|
+
configLogger.warn(`Ignoring styleConfig "${key}": the style's schema doesn't declare it`);
|
|
4086
|
+
}
|
|
4087
|
+
}
|
|
4088
|
+
}
|
|
4089
|
+
|
|
4090
|
+
/**
|
|
4091
|
+
* XYZ tile range covering `bounds` at zoom `z`. Shared by the download
|
|
4092
|
+
* pipeline (which fetches exactly this range) and region-scoped consumers
|
|
4093
|
+
* like MBTiles export, so both agree on which tiles belong to a region.
|
|
4094
|
+
*/
|
|
4095
|
+
function getTileRangeAtZoom(bounds, z) {
|
|
4096
|
+
const minTile = pointToTileClamped(bounds[0][0], bounds[0][1], z);
|
|
4097
|
+
const maxTile = pointToTileClamped(bounds[1][0], bounds[1][1], z);
|
|
4098
|
+
return {
|
|
4099
|
+
minX: Math.min(minTile[0], maxTile[0]),
|
|
4100
|
+
maxX: Math.max(minTile[0], maxTile[0]),
|
|
4101
|
+
minY: Math.min(minTile[1], maxTile[1]),
|
|
4102
|
+
maxY: Math.max(minTile[1], maxTile[1]),
|
|
4103
|
+
};
|
|
4104
|
+
}
|
|
4105
|
+
/** Web Mercator's latitude limit; tiles don't exist beyond it. */
|
|
4106
|
+
const MAX_MERCATOR_LAT = 85.0511287798066;
|
|
4107
|
+
/**
|
|
4108
|
+
* `tilebelt.pointToTile`, kept on the tile grid. tilebelt wraps longitude 180
|
|
4109
|
+
* to x = 0, so a region whose east edge is the antimeridian became the range
|
|
4110
|
+
* 0..maxX — nearly the whole world at every zoom. Latitudes beyond the
|
|
4111
|
+
* Mercator limit likewise fall off the grid.
|
|
4112
|
+
*/
|
|
4113
|
+
function pointToTileClamped(lon, lat, z) {
|
|
4114
|
+
const maxIndex = 2 ** z - 1;
|
|
4115
|
+
const clampedLon = Math.max(-180, Math.min(180, lon));
|
|
4116
|
+
const clampedLat = Math.max(-MAX_MERCATOR_LAT, Math.min(MAX_MERCATOR_LAT, lat));
|
|
4117
|
+
const [x, y] = tilebelt__namespace.pointToTile(clampedLon, clampedLat, z);
|
|
4118
|
+
return [
|
|
4119
|
+
clampedLon === 180 ? maxIndex : Math.max(0, Math.min(maxIndex, x)),
|
|
4120
|
+
Math.max(0, Math.min(maxIndex, y)),
|
|
4121
|
+
];
|
|
4122
|
+
}
|
|
4123
|
+
/**
|
|
4124
|
+
* Whether tile `z/x/y` falls inside `bounds` and `[minZoom, maxZoom]`.
|
|
4125
|
+
*/
|
|
4126
|
+
function isTileInRegion(z, x, y, region) {
|
|
4127
|
+
if (z < region.minZoom || z > region.maxZoom)
|
|
4128
|
+
return false;
|
|
4129
|
+
const { minX, maxX, minY, maxY } = getTileRangeAtZoom(region.bounds, z);
|
|
4130
|
+
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
|
4131
|
+
}
|
|
4132
|
+
/**
|
|
4133
|
+
* Zoom levels to download for one source in `region`: the overlap of the
|
|
4134
|
+
* region's and the source's zoom ranges. When they don't overlap, the source
|
|
4135
|
+
* still gets the zooms the map needs from it:
|
|
4136
|
+
* - source ends below the region (`maxzoom` < region min) → its `maxzoom`,
|
|
4137
|
+
* which the renderer overzooms for the region's zooms;
|
|
4138
|
+
* - source starts above the region (`minzoom` > region max) → `minzoom`
|
|
4139
|
+
* through `maxzoom` (or just `minzoom` if unbounded).
|
|
4140
|
+
*/
|
|
4141
|
+
function sourceZoomRange(region, sourceMinZoom, sourceMaxZoom) {
|
|
4142
|
+
const srcMin = sourceMinZoom !== undefined ? Math.ceil(sourceMinZoom) : undefined;
|
|
4143
|
+
const srcMax = sourceMaxZoom !== undefined ? Math.floor(sourceMaxZoom) : undefined;
|
|
4144
|
+
if (srcMax !== undefined && srcMax < region.minZoom) {
|
|
4145
|
+
return { min: srcMax, max: srcMax };
|
|
4146
|
+
}
|
|
4147
|
+
if (srcMin !== undefined && srcMin > region.maxZoom) {
|
|
4148
|
+
return { min: srcMin, max: srcMax ?? srcMin };
|
|
4149
|
+
}
|
|
4150
|
+
return {
|
|
4151
|
+
min: Math.max(region.minZoom, srcMin ?? region.minZoom),
|
|
4152
|
+
max: Math.min(region.maxZoom, srcMax ?? region.maxZoom),
|
|
4153
|
+
};
|
|
4154
|
+
}
|
|
4155
|
+
/**
|
|
4156
|
+
* A stored source's upstream `maxzoom`. Patched styles cap `maxzoom` for
|
|
4157
|
+
* rendering and stash the upstream value under `__originalMaxzoom`.
|
|
4158
|
+
*/
|
|
4159
|
+
function upstreamMaxzoom(source) {
|
|
4160
|
+
if (source?.__originalMaxzoom !== undefined)
|
|
4161
|
+
return source.__originalMaxzoom ?? undefined;
|
|
4162
|
+
return source?.maxzoom;
|
|
4163
|
+
}
|
|
4164
|
+
function hasValidBounds(region) {
|
|
4165
|
+
const b = region.bounds;
|
|
4166
|
+
return (Array.isArray(b) &&
|
|
4167
|
+
b.length === 2 &&
|
|
4168
|
+
b.every(corner => Array.isArray(corner) && corner.length === 2));
|
|
4169
|
+
}
|
|
4170
|
+
/**
|
|
4171
|
+
* Whether a stored tile was downloaded for `region`: inside its bounds, at a
|
|
4172
|
+
* zoom the download pipeline fetches for the tile's source (which can fall
|
|
4173
|
+
* outside the region's own range — see `sourceZoomRange`). `sources` is the
|
|
4174
|
+
* stored style's `sources`. Regions with malformed bounds match nothing.
|
|
4175
|
+
*/
|
|
4176
|
+
function tileBelongsToRegion(tile, region, sources) {
|
|
4177
|
+
if (!hasValidBounds(region))
|
|
4178
|
+
return false;
|
|
4179
|
+
const source = tile.sourceId !== undefined ? sources[tile.sourceId] : undefined;
|
|
4180
|
+
const { min, max } = sourceZoomRange(region, source?.minzoom, upstreamMaxzoom(source));
|
|
4181
|
+
return isTileInRegion(tile.z, tile.x, tile.y, {
|
|
4182
|
+
bounds: region.bounds,
|
|
4183
|
+
minZoom: min,
|
|
4184
|
+
maxZoom: max,
|
|
4185
|
+
});
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
const fontLogger = logger.scope('FontService');
|
|
4189
|
+
class FontService {
|
|
4190
|
+
db = dbPromise;
|
|
4191
|
+
async downloadFonts(fontUrls, styleName, options = {}) {
|
|
4192
|
+
const db = await this.db;
|
|
4193
|
+
const { batchSize = 10, maxRetries = 3, skipExisting = true, retryDelay = 1000, timeout = 30000, validateFonts = true, storageQuotaCheck = true, quietMode = false, onProgress, } = options;
|
|
4194
|
+
const startTime = Date.now();
|
|
4195
|
+
let totalSize = 0;
|
|
4196
|
+
let downloadedFonts = 0;
|
|
4197
|
+
let skippedFonts = 0;
|
|
4198
|
+
let failedFonts = 0;
|
|
4199
|
+
const errors = [];
|
|
4200
|
+
const fontsByType = {};
|
|
4201
|
+
// Create progress tracker
|
|
4202
|
+
const progressTracker = createProgressTracker(fontUrls.length);
|
|
4203
|
+
const reportProgress = (message) => {
|
|
4204
|
+
if (!onProgress)
|
|
4205
|
+
return;
|
|
4206
|
+
const progress = progressTracker.getProgress();
|
|
4207
|
+
onProgress({
|
|
4208
|
+
completed: progress.completed,
|
|
4209
|
+
total: progress.total,
|
|
4210
|
+
percentage: progress.percentage,
|
|
4211
|
+
message,
|
|
4212
|
+
errors: errors.map(e => e.error),
|
|
4213
|
+
});
|
|
4214
|
+
};
|
|
4215
|
+
// Filter existing fonts if skipExisting is true
|
|
4216
|
+
let urlsToDownload = fontUrls;
|
|
4217
|
+
if (skipExisting) {
|
|
4218
|
+
const existingFonts = new Set();
|
|
4219
|
+
const tx = db.transaction(['fonts'], 'readonly');
|
|
4220
|
+
let cursor = await tx.objectStore('fonts').openCursor();
|
|
4221
|
+
while (cursor) {
|
|
4222
|
+
existingFonts.add(cursor.value.key);
|
|
4223
|
+
cursor = await cursor.continue();
|
|
4224
|
+
}
|
|
4225
|
+
urlsToDownload = fontUrls.filter(url => {
|
|
4226
|
+
const key = this.createFontKey(url, styleName);
|
|
4227
|
+
return !existingFonts.has(key);
|
|
4228
|
+
});
|
|
4229
|
+
skippedFonts = fontUrls.length - urlsToDownload.length;
|
|
4230
|
+
// Skipped fonts count toward the total, so mark them done up front.
|
|
4231
|
+
if (skippedFonts > 0) {
|
|
4232
|
+
progressTracker.update(skippedFonts);
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
4235
|
+
// Check storage quota if enabled
|
|
4236
|
+
if (storageQuotaCheck && 'storage' in navigator && 'estimate' in navigator.storage) {
|
|
4237
|
+
const estimate = await navigator.storage.estimate();
|
|
4238
|
+
const usedSpace = estimate.usage || 0;
|
|
4239
|
+
const availableSpace = (estimate.quota || 0) - usedSpace;
|
|
4240
|
+
if (availableSpace < 50 * 1024 * 1024) {
|
|
4241
|
+
// Less than 50MB
|
|
4242
|
+
throw new Error('Insufficient storage space for font download');
|
|
4243
|
+
}
|
|
4244
|
+
}
|
|
4245
|
+
// Process fonts in batches with concurrency control
|
|
4246
|
+
await processBatch(urlsToDownload, async (fontUrl) => {
|
|
4247
|
+
try {
|
|
4248
|
+
const fontKey = this.createFontKey(fontUrl, styleName);
|
|
4249
|
+
const response = await fetchResourceWithRetry(fontUrl, {
|
|
4250
|
+
retries: maxRetries,
|
|
4251
|
+
retryDelay,
|
|
4252
|
+
timeout,
|
|
4253
|
+
proxyType: 'fonts',
|
|
4254
|
+
});
|
|
4255
|
+
if (response.type === 'json') {
|
|
4256
|
+
throw new Error(`Unexpected JSON response when fetching font: ${fontUrl}`);
|
|
4257
|
+
}
|
|
4258
|
+
const fontData = response.data;
|
|
4259
|
+
const contentType = response.contentType ||
|
|
4260
|
+
(response.type === 'other' ? 'font/opentype' : `font/${response.type}`);
|
|
4261
|
+
// Validate font if enabled
|
|
4262
|
+
if (validateFonts) {
|
|
4263
|
+
await this.validateFont(fontData, contentType);
|
|
4264
|
+
}
|
|
4265
|
+
// Store font in database
|
|
4266
|
+
const fontEntry = {
|
|
4267
|
+
key: fontKey,
|
|
4268
|
+
url: fontUrl,
|
|
4269
|
+
originalUrl: fontUrl,
|
|
4270
|
+
data: fontData,
|
|
4271
|
+
contentType,
|
|
4272
|
+
type: this.detectFontType(contentType, fontUrl),
|
|
4273
|
+
size: fontData.byteLength,
|
|
4274
|
+
lastModified: Date.now(),
|
|
4275
|
+
downloadedAt: new Date().toISOString(),
|
|
4276
|
+
downloadId: styleName,
|
|
4277
|
+
metadata: {
|
|
4278
|
+
userAgent: navigator.userAgent,
|
|
4279
|
+
downloadTimestamp: Date.now(),
|
|
4280
|
+
},
|
|
4281
|
+
expires: response.expires,
|
|
4282
|
+
};
|
|
4283
|
+
await db.put('fonts', fontEntry);
|
|
4284
|
+
progressTracker.update(1, fontKey);
|
|
4285
|
+
reportProgress(`Downloaded ${fontKey}`);
|
|
4286
|
+
totalSize += fontData.byteLength;
|
|
4287
|
+
downloadedFonts++;
|
|
4288
|
+
// Track font types
|
|
4289
|
+
const fontType = this.detectFontType(contentType, fontUrl);
|
|
4290
|
+
fontsByType[fontType] = (fontsByType[fontType] || 0) + 1;
|
|
4291
|
+
}
|
|
4292
|
+
catch (error) {
|
|
4293
|
+
failedFonts++;
|
|
4294
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
4295
|
+
errors.push({
|
|
4296
|
+
url: fontUrl,
|
|
4297
|
+
error: errorMessage,
|
|
3452
4298
|
});
|
|
4299
|
+
// A failed font is finished too; don't let the bar stall on it.
|
|
4300
|
+
progressTracker.update(1, fontUrl);
|
|
4301
|
+
reportProgress(`Failed: ${fontUrl}`);
|
|
3453
4302
|
// Only log detailed errors if not in quiet mode
|
|
3454
4303
|
if (!quietMode) {
|
|
3455
4304
|
fontLogger.error(`Failed to download font ${fontUrl}:`, errorMessage);
|
|
@@ -3462,6 +4311,7 @@ class FontService {
|
|
|
3462
4311
|
}, { batchSize });
|
|
3463
4312
|
const downloadTime = Date.now() - startTime;
|
|
3464
4313
|
const averageSpeed = downloadTime > 0 ? (totalSize / downloadTime) * 1000 : 0;
|
|
4314
|
+
reportProgress(`Font download complete: ${downloadedFonts} downloaded, ${skippedFonts} skipped, ${failedFonts} failed`);
|
|
3465
4315
|
return {
|
|
3466
4316
|
totalFonts: fontUrls.length,
|
|
3467
4317
|
downloadedFonts,
|
|
@@ -3740,7 +4590,7 @@ class SpriteService {
|
|
|
3740
4590
|
*/
|
|
3741
4591
|
async downloadSprites(spriteUrls, styleName, options = {}) {
|
|
3742
4592
|
const db = await this.db;
|
|
3743
|
-
const { onProgress, batchSize = 10, maxRetries = 3, skipExisting = true, timeoutMs = 15000, storageQuotaCheck = true, prioritySprites = [], bandwidthLimit, enableValidation = true, namePrefix, } = options;
|
|
4593
|
+
const { onProgress, batchSize = 10, maxRetries = 3, skipExisting = true, timeoutMs = 15000, storageQuotaCheck = true, prioritySprites = [], bandwidthLimit, enableValidation = true, namePrefix, signal, } = options;
|
|
3744
4594
|
const startTime = Date.now();
|
|
3745
4595
|
let totalSize = 0;
|
|
3746
4596
|
let downloadedSprites = 0;
|
|
@@ -3788,6 +4638,10 @@ class SpriteService {
|
|
|
3788
4638
|
return !existingSprites.has(key);
|
|
3789
4639
|
});
|
|
3790
4640
|
skippedSprites = spriteUrls.length - urlsToDownload.length;
|
|
4641
|
+
// Skipped sprites count toward the total, so mark them done up front.
|
|
4642
|
+
if (skippedSprites > 0) {
|
|
4643
|
+
progressTracker.update(skippedSprites);
|
|
4644
|
+
}
|
|
3791
4645
|
}
|
|
3792
4646
|
// Check storage quota if enabled
|
|
3793
4647
|
if (storageQuotaCheck && 'storage' in navigator && 'estimate' in navigator.storage) {
|
|
@@ -3808,6 +4662,9 @@ class SpriteService {
|
|
|
3808
4662
|
spriteLogger.debug(` ${url} -> ${key}`);
|
|
3809
4663
|
});
|
|
3810
4664
|
await processBatch(urlsToDownload, async (spriteUrl) => {
|
|
4665
|
+
// Cancelled: let the remaining sprites go by without fetching them.
|
|
4666
|
+
if (signal?.aborted)
|
|
4667
|
+
return;
|
|
3811
4668
|
try {
|
|
3812
4669
|
const spriteName = namePrefix
|
|
3813
4670
|
? this.buildSpriteName(spriteUrl, namePrefix)
|
|
@@ -3903,7 +4760,9 @@ class SpriteService {
|
|
|
3903
4760
|
error: errorMessage,
|
|
3904
4761
|
});
|
|
3905
4762
|
spriteLogger.error(`Failed to download sprite ${spriteUrl}:`, error);
|
|
3906
|
-
//
|
|
4763
|
+
// Advance progress on failure too — a failed item is finished, and
|
|
4764
|
+
// otherwise the bar stalls, then jumps to 100% at the end.
|
|
4765
|
+
progressTracker.update(1, this.extractSpriteName(spriteUrl));
|
|
3907
4766
|
if (onProgress) {
|
|
3908
4767
|
const progress = progressTracker.getProgress();
|
|
3909
4768
|
onProgress({
|
|
@@ -4217,7 +5076,74 @@ var spriteService$1 = /*#__PURE__*/Object.freeze({
|
|
|
4217
5076
|
verifyAndRepairSprites: verifyAndRepairSprites
|
|
4218
5077
|
});
|
|
4219
5078
|
|
|
4220
|
-
|
|
5079
|
+
/** A style URL's identity, ignoring the access token (same style, different token). */
|
|
5080
|
+
function styleUrlIdentity(url) {
|
|
5081
|
+
try {
|
|
5082
|
+
const parsed = new URL(url);
|
|
5083
|
+
parsed.searchParams.delete('access_token');
|
|
5084
|
+
return parsed.toString();
|
|
5085
|
+
}
|
|
5086
|
+
catch {
|
|
5087
|
+
return url;
|
|
5088
|
+
}
|
|
5089
|
+
}
|
|
5090
|
+
/**
|
|
5091
|
+
* Pick the storage key for a style downloaded from `styleUrl`. `baseId` comes
|
|
5092
|
+
* from the style's `id`/`name`, which unrelated styles can share ("Streets"),
|
|
5093
|
+
* so a key already held by a style from a different URL is never reused:
|
|
5094
|
+
* fall back to the key this URL was stored under before, else a free
|
|
5095
|
+
* `${baseId}-N`. Entries without `originalUrl` (saved with
|
|
5096
|
+
* `includeMetadata: false`) are assumed to be the same style, so re-downloads
|
|
5097
|
+
* don't pile up duplicates; `carryOverRegions` keeps their regions either way.
|
|
5098
|
+
*/
|
|
5099
|
+
async function resolveStyleKey(db, baseId, styleUrl) {
|
|
5100
|
+
const target = styleUrlIdentity(styleUrl);
|
|
5101
|
+
const isSameStyle = (entry) => !!entry.originalUrl && styleUrlIdentity(entry.originalUrl) === target;
|
|
5102
|
+
const existing = await db.get('styles', baseId);
|
|
5103
|
+
if (!existing || !existing.originalUrl || isSameStyle(existing))
|
|
5104
|
+
return baseId;
|
|
5105
|
+
const all = await db.getAll('styles');
|
|
5106
|
+
const previous = all.find(isSameStyle);
|
|
5107
|
+
if (previous)
|
|
5108
|
+
return previous.key;
|
|
5109
|
+
const taken = new Set(all.map(entry => entry.key));
|
|
5110
|
+
let n = 2;
|
|
5111
|
+
while (taken.has(`${baseId}-${n}`))
|
|
5112
|
+
n++;
|
|
5113
|
+
logger.warn(`Style key "${baseId}" is used by a style from another URL; storing ${styleUrl} as "${baseId}-${n}"`);
|
|
5114
|
+
return `${baseId}-${n}`;
|
|
5115
|
+
}
|
|
5116
|
+
/**
|
|
5117
|
+
* Re-downloading a style replaces its stored entry. Carry over the regions
|
|
5118
|
+
* already attached to it and re-patch the fresh style for them — otherwise
|
|
5119
|
+
* every existing region is dropped and their tiles are orphaned. Patches a
|
|
5120
|
+
* copy, so callers can keep using the unpatched style for resource downloads.
|
|
5121
|
+
*/
|
|
5122
|
+
async function carryOverRegions(db, entry) {
|
|
5123
|
+
const existing = await db.get('styles', entry.key);
|
|
5124
|
+
const regions = existing?.regions;
|
|
5125
|
+
// Config set through `styleConfig` decides what renders and what's planned,
|
|
5126
|
+
// so it has to survive a fresh copy of the style.
|
|
5127
|
+
if (existing?.styleConfig) {
|
|
5128
|
+
entry.style = JSON.parse(JSON.stringify(entry.style));
|
|
5129
|
+
applyStyleConfig(entry.style, existing.styleConfig);
|
|
5130
|
+
entry.styleConfig = existing.styleConfig;
|
|
5131
|
+
}
|
|
5132
|
+
if (!Array.isArray(regions) || regions.length === 0)
|
|
5133
|
+
return;
|
|
5134
|
+
entry.regions = regions;
|
|
5135
|
+
entry.style = JSON.parse(JSON.stringify(entry.style));
|
|
5136
|
+
// Regions' extra sources were added to the old copy, so add them to this one.
|
|
5137
|
+
for (const region of regions) {
|
|
5138
|
+
injectExtraSources(entry.style, region.extraSources);
|
|
5139
|
+
}
|
|
5140
|
+
stashOriginalModels(entry);
|
|
5141
|
+
patchStyleForOffline(entry.style, entry.key, Math.max(...regions.map(region => region.maxZoom)), undefined, entry.key);
|
|
5142
|
+
const { storedModelNames } = await Promise.resolve().then(function () { return modelService$1; });
|
|
5143
|
+
restoreUnstoredModels(entry, await storedModelNames(entry.key));
|
|
5144
|
+
logger.debug(`Carried ${regions.length} region(s) over to re-downloaded style ${entry.key}`);
|
|
5145
|
+
}
|
|
5146
|
+
// Helper functions to work with StyleEntry structure
|
|
4221
5147
|
function createStyleEntry(key, style, provider = 'auto', metadata = {}) {
|
|
4222
5148
|
return {
|
|
4223
5149
|
key,
|
|
@@ -4319,8 +5245,11 @@ async function downloadStyles(stylesUrl, options = {}) {
|
|
|
4319
5245
|
layerCount: style.layers ? style.layers.length : 0,
|
|
4320
5246
|
});
|
|
4321
5247
|
}
|
|
5248
|
+
// Mapbox Standard now arrives flat, so resolveImports may not have run.
|
|
5249
|
+
sanitizeIndoorExpressions(style);
|
|
4322
5250
|
// Check if style already exists in the database
|
|
4323
5251
|
const db = await dbPromise;
|
|
5252
|
+
style.id = await resolveStyleKey(db, style.id, stylesUrl);
|
|
4324
5253
|
if (skipExisting) {
|
|
4325
5254
|
const existingStyle = await db.get('styles', style.id);
|
|
4326
5255
|
if (existingStyle && typeof existingStyle === 'object') {
|
|
@@ -4384,7 +5313,7 @@ async function downloadStyles(stylesUrl, options = {}) {
|
|
|
4384
5313
|
timeout: timeoutMs,
|
|
4385
5314
|
retryDelay: 1000,
|
|
4386
5315
|
});
|
|
4387
|
-
const sourceData = await sourceResponse.json();
|
|
5316
|
+
const sourceData = selectTileJsonVariant(await sourceResponse.json());
|
|
4388
5317
|
style.sources[sourceKey] = { ...source, ...sourceData }; // Embed source data
|
|
4389
5318
|
sourcesEmbedded++;
|
|
4390
5319
|
logger.debug(`Embedded source data for ${sourceKey}`);
|
|
@@ -4451,6 +5380,7 @@ async function downloadStyles(stylesUrl, options = {}) {
|
|
|
4451
5380
|
}
|
|
4452
5381
|
: {});
|
|
4453
5382
|
// Save the style
|
|
5383
|
+
await carryOverRegions(db, styleStorageItem);
|
|
4454
5384
|
await db.put('styles', styleStorageItem);
|
|
4455
5385
|
logger.debug('Style with sources saved successfully');
|
|
4456
5386
|
onProgress?.({
|
|
@@ -4724,18 +5654,26 @@ async function loadStyleById(styleId) {
|
|
|
4724
5654
|
return null;
|
|
4725
5655
|
}
|
|
4726
5656
|
}
|
|
5657
|
+
/** Delete every style and everything stored for them. */
|
|
4727
5658
|
async function deleteStyles() {
|
|
4728
5659
|
try {
|
|
4729
5660
|
const db = await dbPromise;
|
|
4730
|
-
await
|
|
5661
|
+
await Promise.all(['tiles', 'fonts', 'glyphs', 'sprites', 'models', 'styles'].map(store => db.clear(store)));
|
|
5662
|
+
clearAllCaches();
|
|
4731
5663
|
logger.debug('All styles deleted successfully');
|
|
4732
5664
|
}
|
|
4733
5665
|
catch (error) {
|
|
4734
5666
|
logger.error('Error deleting styles:', error);
|
|
4735
5667
|
}
|
|
4736
5668
|
}
|
|
5669
|
+
/**
|
|
5670
|
+
* Delete a style, its regions and everything stored for it. The entry goes last,
|
|
5671
|
+
* so a failure part-way leaves the style listed and able to be deleted again.
|
|
5672
|
+
*/
|
|
4737
5673
|
async function deleteStyleById(styleId) {
|
|
4738
5674
|
try {
|
|
5675
|
+
const { RegionService } = await Promise.resolve().then(function () { return regionService; });
|
|
5676
|
+
await new RegionService().deleteStyleData(styleId);
|
|
4739
5677
|
const db = await dbPromise;
|
|
4740
5678
|
await db.delete('styles', styleId);
|
|
4741
5679
|
logger.debug(`Style with ID ${styleId} deleted successfully`);
|
|
@@ -4744,6 +5682,24 @@ async function deleteStyleById(styleId) {
|
|
|
4744
5682
|
logger.error(`Error deleting style with ID ${styleId}:`, error);
|
|
4745
5683
|
}
|
|
4746
5684
|
}
|
|
5685
|
+
/**
|
|
5686
|
+
* Bytes stored for each style, by style id: its tiles, and its other resources
|
|
5687
|
+
* (glyphs, sprites, models and fonts). Tiles shared by regions count once.
|
|
5688
|
+
*/
|
|
5689
|
+
async function getStyleStorageSizes() {
|
|
5690
|
+
const db = await dbPromise;
|
|
5691
|
+
const sizes = {};
|
|
5692
|
+
for (const storeName of ['tiles', 'glyphs', 'sprites', 'models', 'fonts']) {
|
|
5693
|
+
for await (const cursor of db.transaction(storeName).store) {
|
|
5694
|
+
const { styleId, size } = cursor.value;
|
|
5695
|
+
if (!styleId)
|
|
5696
|
+
continue;
|
|
5697
|
+
sizes[styleId] ??= { tiles: 0, resources: 0 };
|
|
5698
|
+
sizes[styleId][storeName === 'tiles' ? 'tiles' : 'resources'] += size ?? 0;
|
|
5699
|
+
}
|
|
5700
|
+
}
|
|
5701
|
+
return sizes;
|
|
5702
|
+
}
|
|
4747
5703
|
/**
|
|
4748
5704
|
* Get enhanced style statistics
|
|
4749
5705
|
*/
|
|
@@ -4969,9 +5925,10 @@ async function isStyleDownloaded(styleId, styleUrl) {
|
|
|
4969
5925
|
* Enhanced style download that supports both Mapbox GL and MapLibre GL
|
|
4970
5926
|
*/
|
|
4971
5927
|
async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
4972
|
-
const { provider: explicitProvider, accessToken: explicitAccessToken, forceProvider = false, skipExisting = false, enableSourceEmbedding = true, maxRetries = 3, timeoutMs = 30000, } = options;
|
|
5928
|
+
const { provider: explicitProvider, accessToken: explicitAccessToken, forceProvider = false, skipExisting = false, enableSourceEmbedding = true, maxRetries = 3, timeoutMs = 30000, onProgress, } = options;
|
|
4973
5929
|
const startTime = Date.now();
|
|
4974
5930
|
let sourcesEmbedded = 0;
|
|
5931
|
+
const emit = (percentage, message) => onProgress?.({ completed: percentage, total: 100, percentage, message, errors: [] });
|
|
4975
5932
|
logger.debug(`Downloading style from: ${styleUrl}`);
|
|
4976
5933
|
try {
|
|
4977
5934
|
// Auto-detect provider if not explicitly set
|
|
@@ -4992,6 +5949,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
4992
5949
|
fetchUrl = normalizeStyleUrl(styleUrl, extractedToken);
|
|
4993
5950
|
}
|
|
4994
5951
|
// Fetch the style
|
|
5952
|
+
emit(0, 'Fetching style');
|
|
4995
5953
|
const response = await fetchWithRetry(fetchUrl, {
|
|
4996
5954
|
timeout: timeoutMs,
|
|
4997
5955
|
retries: maxRetries,
|
|
@@ -5000,6 +5958,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5000
5958
|
throw new Error(`Failed to fetch style: ${response.status} ${response.statusText}`);
|
|
5001
5959
|
}
|
|
5002
5960
|
const style = (await response.json());
|
|
5961
|
+
emit(20, 'Style fetched');
|
|
5003
5962
|
// Resolve imports (Mapbox Standard / compositional styles)
|
|
5004
5963
|
if (hasImports(style)) {
|
|
5005
5964
|
const importToken = extractedToken || '';
|
|
@@ -5013,13 +5972,19 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5013
5972
|
layerCount: style.layers ? style.layers.length : 0,
|
|
5014
5973
|
});
|
|
5015
5974
|
}
|
|
5975
|
+
// Mapbox Standard now arrives flat, so resolveImports may not have run.
|
|
5976
|
+
sanitizeIndoorExpressions(style);
|
|
5977
|
+
emit(40, 'Processing style sources');
|
|
5016
5978
|
// Process style for the detected provider
|
|
5017
5979
|
const processedStyle = processStyleSources(style, detectedProvider, extractedToken);
|
|
5018
5980
|
// Embed TileJSON data into sources for reliable offline tile downloads
|
|
5019
5981
|
// This fetches each source's TileJSON URL and merges the result (including tiles array,
|
|
5020
5982
|
// minzoom, maxzoom) into the source config, so tile downloads don't need extra fetches
|
|
5021
5983
|
if (enableSourceEmbedding && processedStyle.sources) {
|
|
5022
|
-
|
|
5984
|
+
const sourceEntries = Object.entries(processedStyle.sources);
|
|
5985
|
+
let sourcesVisited = 0;
|
|
5986
|
+
for (const [sourceKey, sourceConfig] of sourceEntries) {
|
|
5987
|
+
emit(40 + Math.round((sourcesVisited++ / Math.max(1, sourceEntries.length)) * 40), `Embedding source ${sourceKey}`);
|
|
5023
5988
|
const source = sourceConfig;
|
|
5024
5989
|
if (source.url && typeof source.url === 'string' && !source.tiles) {
|
|
5025
5990
|
const sourceUrl = source.url;
|
|
@@ -5032,7 +5997,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5032
5997
|
timeout: timeoutMs,
|
|
5033
5998
|
retryDelay: 1000,
|
|
5034
5999
|
});
|
|
5035
|
-
const sourceData = await sourceResponse.json();
|
|
6000
|
+
const sourceData = selectTileJsonVariant(await sourceResponse.json());
|
|
5036
6001
|
// Only copy valid MapLibre/Mapbox source properties from TileJSON
|
|
5037
6002
|
// Copying unknown properties (like "name", "tilejson") causes
|
|
5038
6003
|
// MapLibre to throw "unknown property" errors on setStyle()
|
|
@@ -5066,6 +6031,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5066
6031
|
}
|
|
5067
6032
|
}
|
|
5068
6033
|
}
|
|
6034
|
+
emit(80, 'Saving style');
|
|
5069
6035
|
// Set default glyphs URL if not present
|
|
5070
6036
|
if (!processedStyle.glyphs) {
|
|
5071
6037
|
processedStyle.glyphs = GLYPH_CONFIG.DEFAULT_URL;
|
|
@@ -5077,13 +6043,17 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5077
6043
|
throw new Error(`Style validation failed: ${validation.errors.join(', ')}`);
|
|
5078
6044
|
}
|
|
5079
6045
|
// Generate style ID
|
|
5080
|
-
const styleId = processedStyle.name?.toLowerCase().replace(/\s+/g, '-') || `style-${Date.now()}`;
|
|
5081
|
-
// Check if style already exists
|
|
5082
6046
|
const db = await dbPromise;
|
|
6047
|
+
const styleId = await resolveStyleKey(db,
|
|
6048
|
+
// The key goes into resource keys ("{key}:...") and idb:// URLs, so ":" or "/"
|
|
6049
|
+
// in a style's name would make its data look like another style's or break its URLs.
|
|
6050
|
+
processedStyle.name?.toLowerCase().replace(/[\s:/\\?#%]+/g, '-') || `style-${Date.now()}`, styleUrl);
|
|
6051
|
+
// Check if style already exists
|
|
5083
6052
|
if (skipExisting) {
|
|
5084
6053
|
const existingStyle = await db.get('styles', styleId);
|
|
5085
6054
|
if (existingStyle) {
|
|
5086
6055
|
logger.debug(`Style ${styleId} already exists, skipping`);
|
|
6056
|
+
emit(100, 'Style already exists');
|
|
5087
6057
|
return {
|
|
5088
6058
|
styleId,
|
|
5089
6059
|
success: true,
|
|
@@ -5114,7 +6084,9 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5114
6084
|
accessToken: extractedToken,
|
|
5115
6085
|
});
|
|
5116
6086
|
// Save style
|
|
6087
|
+
await carryOverRegions(db, styleEntry);
|
|
5117
6088
|
await db.put('styles', styleEntry);
|
|
6089
|
+
emit(100, 'Style saved');
|
|
5118
6090
|
const downloadTime = Date.now() - startTime;
|
|
5119
6091
|
logger.info(`Style ${styleId} downloaded in ${downloadTime}ms`);
|
|
5120
6092
|
return {
|
|
@@ -5163,12 +6135,38 @@ var styleService = /*#__PURE__*/Object.freeze({
|
|
|
5163
6135
|
downloadStyleWithProvider: downloadStyleWithProvider,
|
|
5164
6136
|
downloadStyles: downloadStyles,
|
|
5165
6137
|
getStyleStats: getStyleStats,
|
|
6138
|
+
getStyleStorageSizes: getStyleStorageSizes,
|
|
5166
6139
|
isStyleDownloaded: isStyleDownloaded,
|
|
5167
6140
|
loadStyleById: loadStyleById,
|
|
5168
6141
|
loadStyles: loadStyles
|
|
5169
6142
|
});
|
|
5170
6143
|
|
|
5171
6144
|
const regionLogger$1 = logger.scope('RegionService');
|
|
6145
|
+
/**
|
|
6146
|
+
* Where each phase sits in a download's overall progress, as [start, end] in
|
|
6147
|
+
* percent. Tiles get the most because they're usually most of the bytes.
|
|
6148
|
+
*/
|
|
6149
|
+
const PHASE_PROGRESS_RANGES = {
|
|
6150
|
+
style: [0, 5],
|
|
6151
|
+
sprites: [5, 10],
|
|
6152
|
+
glyphs: [10, 25],
|
|
6153
|
+
models: [25, 30],
|
|
6154
|
+
tiles: [30, 99],
|
|
6155
|
+
metadata: [99, 100],
|
|
6156
|
+
};
|
|
6157
|
+
/** True for a Mapbox-hosted style sprite, which has an `iconset.pbf` beside it. */
|
|
6158
|
+
function hasMapboxIconset(spriteUrl) {
|
|
6159
|
+
try {
|
|
6160
|
+
const { hostname, pathname } = new URL(spriteUrl);
|
|
6161
|
+
return (hostname === 'api.mapbox.com' &&
|
|
6162
|
+
pathname.startsWith('/styles/v1/') &&
|
|
6163
|
+
pathname.endsWith('/sprite'));
|
|
6164
|
+
}
|
|
6165
|
+
catch {
|
|
6166
|
+
// Not an absolute URL, so not a Mapbox-hosted sprite.
|
|
6167
|
+
return false;
|
|
6168
|
+
}
|
|
6169
|
+
}
|
|
5172
6170
|
/**
|
|
5173
6171
|
* True when `key` belongs to the given styleId.
|
|
5174
6172
|
*
|
|
@@ -5185,44 +6183,10 @@ function resourceKeyBelongsToStyle(key, styleId) {
|
|
|
5185
6183
|
}
|
|
5186
6184
|
class RegionService {
|
|
5187
6185
|
/**
|
|
5188
|
-
*
|
|
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
|
|
5218
|
-
*/
|
|
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
|
|
6186
|
+
* Delete tiles no remaining region needs — outside every remaining region's
|
|
6187
|
+
* bounds, or at a zoom none of them downloads for that tile's source.
|
|
5224
6188
|
*/
|
|
5225
|
-
async deleteNonOverlappingTiles(styleId, remainingRegions) {
|
|
6189
|
+
async deleteNonOverlappingTiles(styleId, remainingRegions, sources) {
|
|
5226
6190
|
const db = await dbPromise;
|
|
5227
6191
|
let deletedCount = 0;
|
|
5228
6192
|
regionLogger$1.debug(`Checking tiles for style ${styleId} against ${remainingRegions.length} remaining regions`);
|
|
@@ -5239,9 +6203,13 @@ class RegionService {
|
|
|
5239
6203
|
regionLogger$1.warn(`Invalid tile key format: ${tileEntry.key}`);
|
|
5240
6204
|
continue;
|
|
5241
6205
|
}
|
|
5242
|
-
const
|
|
5243
|
-
|
|
5244
|
-
|
|
6206
|
+
const tile = {
|
|
6207
|
+
sourceId: tileEntry.sourceId ?? parsed.sourceId,
|
|
6208
|
+
z: parsed.z,
|
|
6209
|
+
x: parsed.x,
|
|
6210
|
+
y: parsed.y,
|
|
6211
|
+
};
|
|
6212
|
+
if (!remainingRegions.some(region => tileBelongsToRegion(tile, region, sources))) {
|
|
5245
6213
|
regionLogger$1.debug(`Deleting non-overlapping tile: ${tileEntry.key}`);
|
|
5246
6214
|
await cursor.delete();
|
|
5247
6215
|
deletedCount++;
|
|
@@ -5295,6 +6263,46 @@ class RegionService {
|
|
|
5295
6263
|
/**
|
|
5296
6264
|
* Delete all tiles for a style
|
|
5297
6265
|
*/
|
|
6266
|
+
/**
|
|
6267
|
+
* Delete everything stored for a style except its own entry: tiles, fonts,
|
|
6268
|
+
* glyphs, sprites and models.
|
|
6269
|
+
*/
|
|
6270
|
+
/**
|
|
6271
|
+
* Undo what a failed or cancelled download left behind: its partly stored
|
|
6272
|
+
* tiles and its config change. A style this download created, which no other
|
|
6273
|
+
* region uses, goes entirely, and so does a style deleted during the download.
|
|
6274
|
+
*/
|
|
6275
|
+
async cleanUpFailedDownload(styleId, createdStyle, configBefore) {
|
|
6276
|
+
try {
|
|
6277
|
+
const db = await dbPromise;
|
|
6278
|
+
const entry = await db.get('styles', styleId);
|
|
6279
|
+
if (!entry) {
|
|
6280
|
+
await this.deleteStyleData(styleId);
|
|
6281
|
+
return;
|
|
6282
|
+
}
|
|
6283
|
+
const regions = Array.isArray(entry.regions) ? entry.regions : [];
|
|
6284
|
+
if (createdStyle && regions.length === 0) {
|
|
6285
|
+
await this.deleteStyleData(styleId);
|
|
6286
|
+
await db.delete('styles', styleId);
|
|
6287
|
+
return;
|
|
6288
|
+
}
|
|
6289
|
+
if (configBefore) {
|
|
6290
|
+
entry.style.schema = configBefore.schema;
|
|
6291
|
+
entry.styleConfig = configBefore.values;
|
|
6292
|
+
await db.put('styles', entry);
|
|
6293
|
+
}
|
|
6294
|
+
await this.deleteNonOverlappingTiles(styleId, regions, (entry.style?.sources ?? {}));
|
|
6295
|
+
clearAllCaches();
|
|
6296
|
+
}
|
|
6297
|
+
catch (error) {
|
|
6298
|
+
regionLogger$1.warn(`Could not clean up after the failed download on ${styleId}:`, error);
|
|
6299
|
+
}
|
|
6300
|
+
}
|
|
6301
|
+
async deleteStyleData(styleId) {
|
|
6302
|
+
await this.deleteAllStyleTiles(styleId);
|
|
6303
|
+
await this.deleteStyleResources(styleId);
|
|
6304
|
+
clearAllCaches();
|
|
6305
|
+
}
|
|
5298
6306
|
async deleteAllStyleTiles(styleId) {
|
|
5299
6307
|
const db = await dbPromise;
|
|
5300
6308
|
let deletedCount = 0;
|
|
@@ -5341,23 +6349,15 @@ class RegionService {
|
|
|
5341
6349
|
styleEntry.regions = [];
|
|
5342
6350
|
}
|
|
5343
6351
|
// Inject extra sources into the style so they get patched for offline use
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5349
|
-
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
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);
|
|
6352
|
+
injectExtraSources(styleEntry.style, region.extraSources);
|
|
6353
|
+
stashOriginalModels(styleEntry);
|
|
6354
|
+
// Patch style for offline use. URLs are keyed by styleId (resources are
|
|
6355
|
+
// stored per style), and maxzoom is capped at the deepest region on the
|
|
6356
|
+
// style — not just this one — so adding a shallow region doesn't hide a
|
|
6357
|
+
// deeper sibling's tiles.
|
|
6358
|
+
const otherRegions = styleEntry.regions.filter((r) => r.id !== region.id && r.regionId !== region.id);
|
|
6359
|
+
patchStyleForOffline(styleEntry.style, region.id, Math.max(region.maxZoom, ...otherRegions.map(r => r.maxZoom)), region.tileExtension, styleId);
|
|
6360
|
+
restoreUnstoredModels(styleEntry, await storedModelNames(styleId));
|
|
5361
6361
|
// Add or update region metadata on the style entry.
|
|
5362
6362
|
// `expiry` is stored as an absolute timestamp (per the type doc and all
|
|
5363
6363
|
// readers in cleanupService/cleanupManagement/RegionList). If the caller
|
|
@@ -5385,6 +6385,8 @@ class RegionService {
|
|
|
5385
6385
|
styleEntry.regions.push(regionWithMeta);
|
|
5386
6386
|
}
|
|
5387
6387
|
await db.put('styles', styleEntry);
|
|
6388
|
+
// Drop in-memory region→style / tile caches so the fetch handler sees the new region.
|
|
6389
|
+
clearAllCaches();
|
|
5388
6390
|
}
|
|
5389
6391
|
async loadRegion(region, options = {}) {
|
|
5390
6392
|
return this.downloadRegion(region, options);
|
|
@@ -5398,17 +6400,22 @@ class RegionService {
|
|
|
5398
6400
|
if (!region.styleUrl) {
|
|
5399
6401
|
throw new Error('Region must have a styleUrl');
|
|
5400
6402
|
}
|
|
5401
|
-
|
|
6403
|
+
validateZoomLevels(region.minZoom, region.maxZoom);
|
|
6404
|
+
const { onProgress, provider = 'auto', accessToken, skipGlyphs = false, skipSprites = false, skipModels = false, glyphRanges, tileOptions, styleConfig, signal, } = options;
|
|
6405
|
+
let overallPercentage = 0;
|
|
5402
6406
|
const emit = (phase, completed, total, message) => {
|
|
5403
6407
|
if (!onProgress)
|
|
5404
6408
|
return;
|
|
5405
6409
|
const safeTotal = total > 0 ? total : 1;
|
|
5406
6410
|
const clamped = Math.min(completed, safeTotal);
|
|
6411
|
+
const [start, end] = PHASE_PROGRESS_RANGES[phase];
|
|
6412
|
+
overallPercentage = Math.max(overallPercentage, start + (end - start) * (clamped / safeTotal));
|
|
5407
6413
|
onProgress({
|
|
5408
6414
|
phase,
|
|
5409
6415
|
completed: clamped,
|
|
5410
|
-
total
|
|
6416
|
+
total,
|
|
5411
6417
|
percentage: (clamped / safeTotal) * 100,
|
|
6418
|
+
overallPercentage,
|
|
5412
6419
|
message,
|
|
5413
6420
|
});
|
|
5414
6421
|
};
|
|
@@ -5447,9 +6454,22 @@ class RegionService {
|
|
|
5447
6454
|
const originalSpriteUrl = (styleEntry.originalSpriteUrl ??
|
|
5448
6455
|
storedStyle.sprite);
|
|
5449
6456
|
const originalGlyphsUrl = styleEntry.originalGlyphsUrl ?? storedStyle.glyphs;
|
|
6457
|
+
// Applied before the resource phases, which all plan from the style's config.
|
|
6458
|
+
// Kept so a failed download can put the config back as it was.
|
|
6459
|
+
const configBefore = styleConfig
|
|
6460
|
+
? JSON.parse(JSON.stringify({ schema: storedStyle.schema, values: styleEntry.styleConfig }))
|
|
6461
|
+
: undefined;
|
|
6462
|
+
if (styleConfig) {
|
|
6463
|
+
applyStyleConfig(storedStyle, styleConfig);
|
|
6464
|
+
styleEntry.styleConfig = { ...styleEntry.styleConfig, ...styleConfig };
|
|
6465
|
+
}
|
|
6466
|
+
// A reused style may predate download-time sanitizing.
|
|
6467
|
+
sanitizeIndoorExpressions(storedStyle);
|
|
6468
|
+
await (await dbPromise).put('styles', styleEntry);
|
|
5450
6469
|
// 2. Sprites
|
|
5451
6470
|
const spriteResults = [];
|
|
5452
|
-
|
|
6471
|
+
// After a cancel, later phases don't start; the tile step then cleans up.
|
|
6472
|
+
if (!skipSprites && !signal?.aborted) {
|
|
5453
6473
|
const spriteSources = normalizeSpriteProperty(originalSpriteUrl);
|
|
5454
6474
|
if (spriteSources.length > 0) {
|
|
5455
6475
|
const { downloadSprites } = await Promise.resolve().then(function () { return spriteService$1; });
|
|
@@ -5457,16 +6477,17 @@ class RegionService {
|
|
|
5457
6477
|
// sibling is also served under the same /styles/v1/.../<hash>/ path
|
|
5458
6478
|
// — we detect that case per-source below and append it to the list.
|
|
5459
6479
|
const baseSuffixes = ['.json', '.png', '@2x.json', '@2x.png'];
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
6480
|
+
const resolveSpriteBase = (url) => isMapboxProtocol(url) && effectiveAccessToken
|
|
6481
|
+
? resolveMapboxUrl(url, effectiveAccessToken)
|
|
6482
|
+
: url;
|
|
6483
|
+
const totalFiles = spriteSources.reduce((sum, source) => {
|
|
6484
|
+
const base = resolveSpriteBase(source.url).split('?')[0];
|
|
6485
|
+
return sum + baseSuffixes.length + (hasMapboxIconset(base) ? 1 : 0);
|
|
6486
|
+
}, 0);
|
|
5463
6487
|
let completed = 0;
|
|
5464
6488
|
emit('sprites', 0, totalFiles, 'Downloading sprites');
|
|
5465
6489
|
for (const source of spriteSources) {
|
|
5466
|
-
|
|
5467
|
-
if (isMapboxProtocol(spriteBase) && effectiveAccessToken) {
|
|
5468
|
-
spriteBase = resolveMapboxUrl(spriteBase, effectiveAccessToken);
|
|
5469
|
-
}
|
|
6490
|
+
const spriteBase = resolveSpriteBase(source.url);
|
|
5470
6491
|
const qIndex = spriteBase.indexOf('?');
|
|
5471
6492
|
const suffixes = [...baseSuffixes];
|
|
5472
6493
|
// Mapbox Standard serves an iconset.pbf alongside the sprite under
|
|
@@ -5474,18 +6495,7 @@ class RegionService {
|
|
|
5474
6495
|
// /styles/v1/{owner}/{style}/{hash}/iconset.pbf. The last path
|
|
5475
6496
|
// segment is `sprite`, so replacing it with `iconset.pbf` works.
|
|
5476
6497
|
const pathWithoutQuery = qIndex !== -1 ? spriteBase.slice(0, qIndex) : spriteBase;
|
|
5477
|
-
|
|
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) {
|
|
6498
|
+
if (hasMapboxIconset(pathWithoutQuery)) {
|
|
5489
6499
|
// The path-rewrite suffix replaces the trailing `sprite` segment.
|
|
5490
6500
|
suffixes.push('__ICONSET__');
|
|
5491
6501
|
}
|
|
@@ -5501,6 +6511,7 @@ class RegionService {
|
|
|
5501
6511
|
});
|
|
5502
6512
|
try {
|
|
5503
6513
|
const result = await downloadSprites(spriteUrls, styleId, {
|
|
6514
|
+
signal,
|
|
5504
6515
|
enableValidation: true,
|
|
5505
6516
|
skipExisting: false,
|
|
5506
6517
|
namePrefix: source.id !== 'sprite' ? source.id : undefined,
|
|
@@ -5520,7 +6531,10 @@ class RegionService {
|
|
|
5520
6531
|
}
|
|
5521
6532
|
// 3. Glyphs
|
|
5522
6533
|
let glyphResult;
|
|
5523
|
-
if (!skipGlyphs &&
|
|
6534
|
+
if (!skipGlyphs &&
|
|
6535
|
+
!signal?.aborted &&
|
|
6536
|
+
originalGlyphsUrl &&
|
|
6537
|
+
!originalGlyphsUrl.startsWith('idb://')) {
|
|
5524
6538
|
const fontFamilies = new Set(extractAllFontNames(storedStyle));
|
|
5525
6539
|
if (fontFamilies.size > 0) {
|
|
5526
6540
|
const { downloadGlyphs } = await Promise.resolve().then(function () { return glyphService$1; });
|
|
@@ -5534,6 +6548,7 @@ class RegionService {
|
|
|
5534
6548
|
// jump when the real total came in).
|
|
5535
6549
|
try {
|
|
5536
6550
|
glyphResult = await downloadGlyphs(glyphsUrl, Array.from(fontFamilies), styleId, ranges, {
|
|
6551
|
+
signal,
|
|
5537
6552
|
onProgress: (progress) => {
|
|
5538
6553
|
emit('glyphs', progress.completed, progress.total, 'Downloading glyphs');
|
|
5539
6554
|
},
|
|
@@ -5548,15 +6563,17 @@ class RegionService {
|
|
|
5548
6563
|
// turbine .glb assets. Two value shapes exist in the wild
|
|
5549
6564
|
// (plain string or `{ uri }`) — we accept both.
|
|
5550
6565
|
let modelResult;
|
|
5551
|
-
if (!skipModels && storedStyle.models) {
|
|
5552
|
-
|
|
6566
|
+
if (!skipModels && !signal?.aborted && storedStyle.models) {
|
|
6567
|
+
// Upstream URLs, from before patching, so models that failed last time are retried.
|
|
6568
|
+
const rawModels = (styleEntry.originalModels ?? storedStyle.models);
|
|
6569
|
+
const alreadyStored = await storedModelNames(styleId);
|
|
5553
6570
|
const resolved = {};
|
|
5554
6571
|
for (const [name, value] of Object.entries(rawModels)) {
|
|
5555
6572
|
const uri = typeof value === 'string' ? value : value?.uri;
|
|
5556
|
-
if (!uri)
|
|
6573
|
+
if (!uri || alreadyStored.has(name))
|
|
5557
6574
|
continue;
|
|
5558
6575
|
if (uri.startsWith('idb://'))
|
|
5559
|
-
continue; //
|
|
6576
|
+
continue; // patched before upstream URLs were kept
|
|
5560
6577
|
const httpUrl = isMapboxProtocol(uri) && effectiveAccessToken
|
|
5561
6578
|
? resolveMapboxUrl(uri, effectiveAccessToken)
|
|
5562
6579
|
: uri;
|
|
@@ -5569,6 +6586,7 @@ class RegionService {
|
|
|
5569
6586
|
emit('models', 0, Object.keys(resolved).length, 'Downloading 3D models');
|
|
5570
6587
|
try {
|
|
5571
6588
|
modelResult = await downloadModels(resolved, styleId, {
|
|
6589
|
+
signal,
|
|
5572
6590
|
onProgress: (progress) => {
|
|
5573
6591
|
emit('models', progress.completed, progress.total, 'Downloading 3D models');
|
|
5574
6592
|
},
|
|
@@ -5579,29 +6597,57 @@ class RegionService {
|
|
|
5579
6597
|
}
|
|
5580
6598
|
}
|
|
5581
6599
|
}
|
|
6600
|
+
// Renderer code fetched from api.mapbox.com at render time (3D decoders,
|
|
6601
|
+
// RTL text, procedural buildings). Never throws; see cacheMapboxRuntimeAssets.
|
|
6602
|
+
let runtimeAssetsCached;
|
|
6603
|
+
if (!signal?.aborted &&
|
|
6604
|
+
/mapbox:\/\/|api\.mapbox\.com/.test(`${region.styleUrl ?? ''} ${originalGlyphsUrl ?? ''}`)) {
|
|
6605
|
+
const { cacheMapboxRuntimeAssets } = await Promise.resolve().then(function () { return runtimeAssetService; });
|
|
6606
|
+
runtimeAssetsCached = await cacheMapboxRuntimeAssets();
|
|
6607
|
+
}
|
|
5582
6608
|
// 5. Tiles — use the stored (source-embedded) style, which still has HTTP tile URLs
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
6609
|
+
let tileResult;
|
|
6610
|
+
try {
|
|
6611
|
+
signal?.throwIfAborted();
|
|
6612
|
+
const { downloadTiles } = await Promise.resolve().then(function () { return tileService$1; });
|
|
6613
|
+
const regionForTiles = { ...region, styleId };
|
|
6614
|
+
// The tile count isn't known until the plan is made, which can take a while.
|
|
6615
|
+
emit('tiles', 0, 0, 'Planning tiles');
|
|
6616
|
+
tileResult = await downloadTiles(regionForTiles, storedStyle, styleId, {
|
|
6617
|
+
skipExisting: false,
|
|
6618
|
+
batchSize: 20,
|
|
6619
|
+
...tileOptions,
|
|
6620
|
+
signal,
|
|
6621
|
+
onProgress: progress => {
|
|
6622
|
+
emit('tiles', progress.completed, progress.total, progress.message ?? 'Downloading tiles');
|
|
6623
|
+
tileOptions?.onProgress?.(progress);
|
|
6624
|
+
},
|
|
6625
|
+
});
|
|
6626
|
+
signal?.throwIfAborted();
|
|
6627
|
+
// A region with no tiles at all isn't worth saving as if it had worked, as happens
|
|
6628
|
+
// with an expired token. Partial failures still save: tileResult reports them.
|
|
6629
|
+
const { failedTiles, downloadedTiles, skippedTiles } = tileResult;
|
|
6630
|
+
if (failedTiles > 0 && downloadedTiles === 0 && skippedTiles === 0) {
|
|
6631
|
+
throw new Error(`None of the ${failedTiles} tiles could be downloaded. Check your connection and access token.`);
|
|
6632
|
+
}
|
|
6633
|
+
// 6. Metadata — must run last, since addRegion patches style URLs to idb://.
|
|
6634
|
+
// Do NOT auto-fill tileExtension from tileResult: that's only the first
|
|
6635
|
+
// source's extension, and addRegion feeds it to patchStyleForOffline which
|
|
6636
|
+
// would override ALL sources — breaking mixed raster+vector styles. The
|
|
6637
|
+
// patcher extracts per-source extensions from tile URL patterns when
|
|
6638
|
+
// tileExtension is undefined. Callers who need to force an extension can
|
|
6639
|
+
// still pass `region.tileExtension` explicitly.
|
|
6640
|
+
emit('metadata', 0, 1, 'Saving region metadata');
|
|
6641
|
+
if (!(await (await dbPromise).get('styles', styleId))) {
|
|
6642
|
+
throw new Error('The style was deleted while this region was downloading.');
|
|
6643
|
+
}
|
|
6644
|
+
await this.addRegion({ ...region, styleId });
|
|
6645
|
+
emit('metadata', 1, 1, 'Region ready');
|
|
6646
|
+
}
|
|
6647
|
+
catch (error) {
|
|
6648
|
+
await this.cleanUpFailedDownload(styleId, Boolean(styleResult), configBefore);
|
|
6649
|
+
throw error;
|
|
6650
|
+
}
|
|
5605
6651
|
return {
|
|
5606
6652
|
regionId: region.id,
|
|
5607
6653
|
styleId,
|
|
@@ -5609,6 +6655,7 @@ class RegionService {
|
|
|
5609
6655
|
spriteResults,
|
|
5610
6656
|
glyphResult,
|
|
5611
6657
|
modelResult,
|
|
6658
|
+
runtimeAssetsCached,
|
|
5612
6659
|
tileResult,
|
|
5613
6660
|
};
|
|
5614
6661
|
}
|
|
@@ -5633,12 +6680,20 @@ class RegionService {
|
|
|
5633
6680
|
}
|
|
5634
6681
|
return undefined;
|
|
5635
6682
|
}
|
|
5636
|
-
|
|
6683
|
+
/**
|
|
6684
|
+
* Delete a region and the tiles/resources only it needed.
|
|
6685
|
+
* @param styleId - The style holding the region. Region ids are only unique
|
|
6686
|
+
* within a style, so pass it whenever known; without it the first style
|
|
6687
|
+
* containing `regionId` is used.
|
|
6688
|
+
*/
|
|
6689
|
+
async deleteRegion(regionId, styleId) {
|
|
5637
6690
|
const db = await dbPromise;
|
|
5638
|
-
regionLogger$1.debug(`Deleting region: ${regionId}`);
|
|
6691
|
+
regionLogger$1.debug(`Deleting region: ${regionId}${styleId ? ` from style ${styleId}` : ''}`);
|
|
5639
6692
|
try {
|
|
5640
6693
|
// Find which style contains this region
|
|
5641
|
-
const allStyles =
|
|
6694
|
+
const allStyles = styleId
|
|
6695
|
+
? [await db.get('styles', styleId)].filter((entry) => entry !== undefined)
|
|
6696
|
+
: await db.getAll('styles');
|
|
5642
6697
|
regionLogger$1.debug(`Found ${allStyles.length} styles to search through`);
|
|
5643
6698
|
let foundStyle = null;
|
|
5644
6699
|
let foundRegion = null;
|
|
@@ -5657,7 +6712,7 @@ class RegionService {
|
|
|
5657
6712
|
}
|
|
5658
6713
|
}
|
|
5659
6714
|
if (!foundStyle || !foundRegion) {
|
|
5660
|
-
regionLogger$1.warn(`Region ${regionId} not found in any style`);
|
|
6715
|
+
regionLogger$1.warn(`Region ${regionId} not found in ${styleId ? `style ${styleId}` : 'any style'}`);
|
|
5661
6716
|
return;
|
|
5662
6717
|
}
|
|
5663
6718
|
regionLogger$1.debug(`Found region in style: ${foundStyle.key}`, foundRegion);
|
|
@@ -5668,11 +6723,7 @@ class RegionService {
|
|
|
5668
6723
|
// If this was the last region for the style, delete the entire style and all its resources
|
|
5669
6724
|
if (remainingRegions.length === 0) {
|
|
5670
6725
|
regionLogger$1.info(`Style ${foundStyle.key} has no remaining regions, deleting entire style and all resources`);
|
|
5671
|
-
|
|
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);
|
|
6726
|
+
await this.deleteStyleData(foundStyle.key);
|
|
5676
6727
|
// Delete the style entry itself
|
|
5677
6728
|
await db.delete('styles', foundStyle.key);
|
|
5678
6729
|
regionLogger$1.info(`Completely deleted style ${foundStyle.key} and all associated resources`);
|
|
@@ -5680,11 +6731,18 @@ class RegionService {
|
|
|
5680
6731
|
else {
|
|
5681
6732
|
regionLogger$1.debug(`Style ${foundStyle.key} has ${remainingRegions.length} remaining regions, cleaning up non-overlapping tiles`);
|
|
5682
6733
|
// Delete tiles that don't overlap with any remaining regions
|
|
5683
|
-
const deletedTileCount = await this.deleteNonOverlappingTiles(foundStyle.key, remainingRegions);
|
|
6734
|
+
const deletedTileCount = await this.deleteNonOverlappingTiles(foundStyle.key, remainingRegions, (foundStyle.style?.sources ?? {}));
|
|
6735
|
+
// Re-patch for the remaining regions: shrink maxzoom to the deepest
|
|
6736
|
+
// survivor, and re-key URLs by styleId. Styles patched before URLs
|
|
6737
|
+
// were style-keyed still point at `idb://{deletedRegionId}/...`,
|
|
6738
|
+
// which no longer resolves once the region is gone.
|
|
6739
|
+
patchStyleForOffline(foundStyle.style, foundStyle.key, Math.max(...remainingRegions.map((r) => r.maxZoom)), undefined, foundStyle.key);
|
|
6740
|
+
restoreUnstoredModels(foundStyle, await storedModelNames(foundStyle.key));
|
|
5684
6741
|
// Update the style entry with the remaining regions
|
|
5685
6742
|
await db.put('styles', foundStyle);
|
|
5686
6743
|
regionLogger$1.info(`Region ${regionId} deleted successfully from style ${foundStyle.key}, cleaned up ${deletedTileCount} non-overlapping tiles`);
|
|
5687
6744
|
}
|
|
6745
|
+
clearAllCaches();
|
|
5688
6746
|
regionLogger$1.info(`Region deletion completed successfully for: ${regionId}`);
|
|
5689
6747
|
}
|
|
5690
6748
|
catch (error) {
|
|
@@ -5735,6 +6793,13 @@ async function loadAllStoredRegions() {
|
|
|
5735
6793
|
}
|
|
5736
6794
|
}
|
|
5737
6795
|
|
|
6796
|
+
var regionService = /*#__PURE__*/Object.freeze({
|
|
6797
|
+
__proto__: null,
|
|
6798
|
+
RegionService: RegionService,
|
|
6799
|
+
loadAllStoredRegions: loadAllStoredRegions,
|
|
6800
|
+
resourceKeyBelongsToStyle: resourceKeyBelongsToStyle
|
|
6801
|
+
});
|
|
6802
|
+
|
|
5738
6803
|
const cleanupLogger = logger.scope('CleanupService');
|
|
5739
6804
|
class CleanupService {
|
|
5740
6805
|
db = dbPromise;
|
|
@@ -5833,9 +6898,9 @@ class CleanupService {
|
|
|
5833
6898
|
let deletedCount = 0;
|
|
5834
6899
|
for (const region of uniqueRegionsToDelete) {
|
|
5835
6900
|
try {
|
|
5836
|
-
const
|
|
6901
|
+
const bytesBefore = await this.getStyleStoredBytes(region.styleId);
|
|
5837
6902
|
await this.deleteRegionCallback(region.id, region.styleId);
|
|
5838
|
-
result.freedSpace +=
|
|
6903
|
+
result.freedSpace += Math.max(0, bytesBefore - (await this.getStyleStoredBytes(region.styleId)));
|
|
5839
6904
|
deletedCount++;
|
|
5840
6905
|
onProgress?.({
|
|
5841
6906
|
phase: 'cleaning',
|
|
@@ -5899,7 +6964,7 @@ class CleanupService {
|
|
|
5899
6964
|
neverExpiring: 0,
|
|
5900
6965
|
};
|
|
5901
6966
|
for (const region of regions) {
|
|
5902
|
-
const regionSize = await this.getRegionSize(region.id);
|
|
6967
|
+
const regionSize = await this.getRegionSize(region.id, region.styleId);
|
|
5903
6968
|
totalSize += regionSize;
|
|
5904
6969
|
// Track by style
|
|
5905
6970
|
const styleId = region.styleId || 'unknown';
|
|
@@ -6001,8 +7066,8 @@ class CleanupService {
|
|
|
6001
7066
|
async getExpiredResourceCount() {
|
|
6002
7067
|
const db = await this.db;
|
|
6003
7068
|
const now = Date.now();
|
|
6004
|
-
const counts = { tiles: 0, fonts: 0, sprites: 0, glyphs: 0, total: 0 };
|
|
6005
|
-
const stores = ['tiles', 'fonts', 'sprites', 'glyphs'];
|
|
7069
|
+
const counts = { tiles: 0, fonts: 0, sprites: 0, glyphs: 0, models: 0, total: 0 };
|
|
7070
|
+
const stores = ['tiles', 'fonts', 'sprites', 'glyphs', 'models'];
|
|
6006
7071
|
for (const storeName of stores) {
|
|
6007
7072
|
try {
|
|
6008
7073
|
const tx = db.transaction([storeName], 'readonly');
|
|
@@ -6048,46 +7113,77 @@ class CleanupService {
|
|
|
6048
7113
|
async getAllRegions() {
|
|
6049
7114
|
return loadAllStoredRegions();
|
|
6050
7115
|
}
|
|
7116
|
+
/**
|
|
7117
|
+
* Bytes of the tiles stored for a region: tiles on its style inside its
|
|
7118
|
+
* bounds, at the zooms the download pipeline fetched for each source. Tiles
|
|
7119
|
+
* are stored per style, so regions that overlap share (and both count)
|
|
7120
|
+
* their common tiles; shared style resources are not included.
|
|
7121
|
+
*/
|
|
6051
7122
|
async getRegionSize(regionId, styleIdParam) {
|
|
6052
7123
|
const db = await this.db;
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
let
|
|
6057
|
-
|
|
6058
|
-
|
|
6059
|
-
|
|
6060
|
-
|
|
6061
|
-
|
|
6062
|
-
|
|
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
|
-
}
|
|
7124
|
+
const candidates = styleIdParam
|
|
7125
|
+
? [await db.get('styles', styleIdParam)]
|
|
7126
|
+
: await db.getAll('styles');
|
|
7127
|
+
let styleEntry;
|
|
7128
|
+
let region;
|
|
7129
|
+
for (const entry of candidates) {
|
|
7130
|
+
region = entry?.regions?.find((r) => r.id === regionId || r.regionId === regionId);
|
|
7131
|
+
if (region) {
|
|
7132
|
+
styleEntry = entry;
|
|
7133
|
+
break;
|
|
6071
7134
|
}
|
|
6072
7135
|
}
|
|
6073
|
-
if (!
|
|
7136
|
+
if (!styleEntry || !region) {
|
|
6074
7137
|
return 0;
|
|
6075
7138
|
}
|
|
6076
|
-
|
|
7139
|
+
const styleId = styleEntry.key;
|
|
7140
|
+
const sources = (styleEntry.style?.sources ?? {});
|
|
7141
|
+
let totalSize = 0;
|
|
6077
7142
|
const tx = db.transaction(['tiles'], 'readonly');
|
|
6078
7143
|
for await (const cursor of tx.objectStore('tiles')) {
|
|
6079
7144
|
const tile = cursor.value;
|
|
6080
7145
|
if (tile.styleId !== styleId)
|
|
6081
7146
|
continue;
|
|
6082
|
-
// Filter by zoom range when possible
|
|
6083
7147
|
const parsed = parseTileKey(tile.key);
|
|
6084
|
-
if (parsed
|
|
7148
|
+
if (!parsed)
|
|
6085
7149
|
continue;
|
|
7150
|
+
const coords = {
|
|
7151
|
+
sourceId: tile.sourceId ?? parsed.sourceId,
|
|
7152
|
+
z: parsed.z,
|
|
7153
|
+
x: parsed.x,
|
|
7154
|
+
y: parsed.y,
|
|
7155
|
+
};
|
|
7156
|
+
if (tileBelongsToRegion(coords, region, sources)) {
|
|
7157
|
+
totalSize += tile.size || 0;
|
|
6086
7158
|
}
|
|
6087
|
-
totalSize += tile.size || 0;
|
|
6088
7159
|
}
|
|
6089
7160
|
return totalSize;
|
|
6090
7161
|
}
|
|
7162
|
+
/**
|
|
7163
|
+
* Bytes stored for a style: its tiles plus the fonts, glyphs, sprites and
|
|
7164
|
+
* models keyed to it. Measured before and after a deletion to report the
|
|
7165
|
+
* space actually freed (a region's own size over-counts tiles it shares).
|
|
7166
|
+
*/
|
|
7167
|
+
async getStyleStoredBytes(styleId) {
|
|
7168
|
+
if (!styleId)
|
|
7169
|
+
return 0;
|
|
7170
|
+
const db = await this.db;
|
|
7171
|
+
let total = 0;
|
|
7172
|
+
const tx = db.transaction(['tiles'], 'readonly');
|
|
7173
|
+
for await (const cursor of tx.objectStore('tiles')) {
|
|
7174
|
+
if (cursor.value.styleId === styleId)
|
|
7175
|
+
total += cursor.value.size || 0;
|
|
7176
|
+
}
|
|
7177
|
+
for (const storeName of ['fonts', 'glyphs', 'sprites', 'models']) {
|
|
7178
|
+
const storeTx = db.transaction([storeName], 'readonly');
|
|
7179
|
+
for await (const cursor of storeTx.objectStore(storeName)) {
|
|
7180
|
+
const entry = cursor.value;
|
|
7181
|
+
if (resourceKeyBelongsToStyle(entry.key, styleId))
|
|
7182
|
+
total += entry.size || 0;
|
|
7183
|
+
}
|
|
7184
|
+
}
|
|
7185
|
+
return total;
|
|
7186
|
+
}
|
|
6091
7187
|
async calculateTotalStorageSize() {
|
|
6092
7188
|
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
|
6093
7189
|
const estimate = await navigator.storage.estimate();
|
|
@@ -6101,6 +7197,7 @@ class CleanupService {
|
|
|
6101
7197
|
'fonts',
|
|
6102
7198
|
'sprites',
|
|
6103
7199
|
'glyphs',
|
|
7200
|
+
'models',
|
|
6104
7201
|
'styles',
|
|
6105
7202
|
];
|
|
6106
7203
|
for (const storeName of stores) {
|
|
@@ -6142,7 +7239,7 @@ class CleanupService {
|
|
|
6142
7239
|
if (currentSize >= targetSize)
|
|
6143
7240
|
break;
|
|
6144
7241
|
selected.push(region);
|
|
6145
|
-
const regionSize = await this.getRegionSize(region.id);
|
|
7242
|
+
const regionSize = await this.getRegionSize(region.id, region.styleId);
|
|
6146
7243
|
currentSize += regionSize > 0 ? regionSize : 10 * 1024 * 1024; // Fallback to 10MB if size unknown
|
|
6147
7244
|
}
|
|
6148
7245
|
return selected;
|
|
@@ -6233,7 +7330,7 @@ class TileService {
|
|
|
6233
7330
|
*/
|
|
6234
7331
|
async downloadTiles(region, style, styleId, options = {}) {
|
|
6235
7332
|
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;
|
|
7333
|
+
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
7334
|
const startTime = Date.now();
|
|
6238
7335
|
let totalSize = 0;
|
|
6239
7336
|
let downloadedTiles = 0;
|
|
@@ -6274,31 +7371,31 @@ class TileService {
|
|
|
6274
7371
|
if (tileSources.size === 0) {
|
|
6275
7372
|
throw new Error('No valid tile sources found in style definition');
|
|
6276
7373
|
}
|
|
6277
|
-
//
|
|
6278
|
-
//
|
|
7374
|
+
// Features a config-driven style has switched off (Mapbox Standard's HD
|
|
7375
|
+
// roads, indoor, landmark icons...) don't need their tiles.
|
|
7376
|
+
const { active: activeSources, inactive: inactiveSources } = classifySources(style);
|
|
7377
|
+
if (skipInactiveSources) {
|
|
7378
|
+
for (const sourceId of inactiveSources) {
|
|
7379
|
+
if (tileSources.delete(sourceId)) {
|
|
7380
|
+
tileLogger.info(`Skipping source "${sourceId}" — nothing visible uses it under the style's config`);
|
|
7381
|
+
}
|
|
7382
|
+
}
|
|
7383
|
+
}
|
|
7384
|
+
// Some sources have zoom ranges entirely outside the user's region (e.g.
|
|
7385
|
+
// procedural-buildings at z15 when the user requested z0-z14, or a z14-capped
|
|
7386
|
+
// basemap for a z15-z17 region). Generate the extra coordinates they need so
|
|
6279
7387
|
// these sources aren't silently skipped.
|
|
7388
|
+
const generatedZooms = new Set();
|
|
7389
|
+
for (let z = region.minZoom; z <= region.maxZoom; z++)
|
|
7390
|
+
generatedZooms.add(z);
|
|
6280
7391
|
for (const [sourceId, sourceConfig] of tileSources) {
|
|
6281
|
-
const
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
|
|
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);
|
|
7392
|
+
const { min, max } = sourceZoomRange(region, sourceConfig.minzoom, sourceConfig.maxzoom);
|
|
7393
|
+
for (let z = min; z <= max; z++) {
|
|
7394
|
+
if (generatedZooms.has(z))
|
|
7395
|
+
continue;
|
|
7396
|
+
generatedZooms.add(z);
|
|
7397
|
+
tileLogger.debug(`Source ${sourceId} needs zoom ${z} outside region ${region.minZoom}-${region.maxZoom}, generating extra tiles`);
|
|
7398
|
+
tileCoords.push(...this.generateTileCoordinates({ ...region, minZoom: z, maxZoom: z }));
|
|
6302
7399
|
}
|
|
6303
7400
|
}
|
|
6304
7401
|
// Sort by priority zoom levels if requested (lower zoom first)
|
|
@@ -6341,14 +7438,16 @@ class TileService {
|
|
|
6341
7438
|
// issuing any network request. They 404 for nearly every coord in a
|
|
6342
7439
|
// typical region; the downstream probe step would catch them, but the
|
|
6343
7440
|
// probe itself logs 404s in devtools. Matching here keeps the console
|
|
6344
|
-
// clean.
|
|
6345
|
-
|
|
6346
|
-
|
|
7441
|
+
// clean. A sparse source a switched-on feature needs is kept instead, and
|
|
7442
|
+
// not probed, because its tiles cluster and the three probe tiles could
|
|
7443
|
+
// easily all miss.
|
|
7444
|
+
const knownSparse = tiles.some(template => urlReferencesKnownSparseTileset(template));
|
|
7445
|
+
const neededSparseSource = knownSparse && activeSources.has(sourceId);
|
|
7446
|
+
if (skipKnownSparseSources && knownSparse && !neededSparseSource) {
|
|
6347
7447
|
tileLogger.info(`Skipping source "${sourceId}" — references a known sparse Mapbox Standard sub-tileset (no probe/download issued)`);
|
|
6348
7448
|
continue;
|
|
6349
7449
|
}
|
|
6350
|
-
const sourceMinZ =
|
|
6351
|
-
const sourceMaxZ = Math.floor(sourceConfig.maxzoom ?? region.maxZoom);
|
|
7450
|
+
const { min: sourceMinZ, max: sourceMaxZ } = sourceZoomRange(region, sourceConfig.minzoom, sourceConfig.maxzoom);
|
|
6352
7451
|
let coordsForSource = tileCoords.filter(coord => coord.z >= sourceMinZ && coord.z <= sourceMaxZ);
|
|
6353
7452
|
tileLogger.debug(`After zoom filter (${sourceMinZ}-${sourceMaxZ}): ${coordsForSource.length} tiles`);
|
|
6354
7453
|
if (coordsForSource.length === 0) {
|
|
@@ -6382,7 +7481,7 @@ class TileService {
|
|
|
6382
7481
|
// A single probe on the wrong coord would false-pass and cause a
|
|
6383
7482
|
// flood of 404s during the download. Probing start/middle/end of
|
|
6384
7483
|
// the plan catches this case.
|
|
6385
|
-
if (probeSourcesBeforeDownload) {
|
|
7484
|
+
if (probeSourcesBeforeDownload && !neededSparseSource) {
|
|
6386
7485
|
const picks = [
|
|
6387
7486
|
coordsForSource[0],
|
|
6388
7487
|
coordsForSource[Math.floor(coordsForSource.length / 2)],
|
|
@@ -6441,6 +7540,9 @@ class TileService {
|
|
|
6441
7540
|
.map(([z, count]) => `Z${z}: ${count} tiles`)
|
|
6442
7541
|
.join(', '));
|
|
6443
7542
|
await processBatch(plan.coords, async (coord) => {
|
|
7543
|
+
// Cancelled: let the remaining tiles go by without fetching them.
|
|
7544
|
+
if (signal?.aborted)
|
|
7545
|
+
return;
|
|
6444
7546
|
const { x, y, z } = coord;
|
|
6445
7547
|
const label = `${plan.sourceId}:${z}/${x}/${y}`;
|
|
6446
7548
|
let tileUrl = '';
|
|
@@ -6550,7 +7652,9 @@ class TileService {
|
|
|
6550
7652
|
const errorObject = _error;
|
|
6551
7653
|
errorMessage = errorObject instanceof Error ? errorObject.message : String(errorObject);
|
|
6552
7654
|
// 404s on sparse tilesets (landmarks, POIs) are expected — don't count as failures
|
|
6553
|
-
|
|
7655
|
+
// Match the start only: the message goes on to include the URL, whose
|
|
7656
|
+
// coordinates or token can contain "404".
|
|
7657
|
+
const is404 = /^(Resource not found \(404\)|HTTP 404\b)/.test(errorMessage);
|
|
6554
7658
|
const isExpected = errorMessage.includes('NonRetryableError');
|
|
6555
7659
|
if (is404) {
|
|
6556
7660
|
skippedTiles++;
|
|
@@ -6728,13 +7832,7 @@ class TileService {
|
|
|
6728
7832
|
tileLogger.debug(`Approximate area: ${areaApproxKm2.toFixed(2)} km² (improved calculation)`);
|
|
6729
7833
|
tileLogger.debug(`Region dimensions: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km`);
|
|
6730
7834
|
for (let z = region.minZoom; z <= region.maxZoom; z++) {
|
|
6731
|
-
const
|
|
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]);
|
|
7835
|
+
const { minX, maxX, minY, maxY } = getTileRangeAtZoom(region.bounds, z);
|
|
6738
7836
|
const tilesAtZoom = (maxX - minX + 1) * (maxY - minY + 1);
|
|
6739
7837
|
tilesByZoom[z] = tilesAtZoom;
|
|
6740
7838
|
tileLogger.debug(`Zoom ${z}: ${tilesAtZoom} tiles (X: ${minX}-${maxX}, Y: ${minY}-${maxY})`);
|
|
@@ -6768,7 +7866,10 @@ class TileService {
|
|
|
6768
7866
|
}
|
|
6769
7867
|
tileLogger.debug('Processing sources in extractTileSources:', Object.keys(style.sources));
|
|
6770
7868
|
for (const [sourceId, sourceConfig] of Object.entries(style.sources)) {
|
|
6771
|
-
|
|
7869
|
+
// Shallow copy: the maxzoom override below must not mutate the caller's style.
|
|
7870
|
+
const config = {
|
|
7871
|
+
...sourceConfig,
|
|
7872
|
+
};
|
|
6772
7873
|
tileLogger.debug(`Source ${sourceId} RAW DATA:`, JSON.stringify(config, null, 2));
|
|
6773
7874
|
tileLogger.debug(`Processing source ${sourceId}:`, {
|
|
6774
7875
|
type: config.type,
|
|
@@ -6779,18 +7880,26 @@ class TileService {
|
|
|
6779
7880
|
});
|
|
6780
7881
|
// Handle tile-based sources (vector, raster, raster-dem, batched-model,
|
|
6781
7882
|
// raster-array). `raster-array` is used by Mapbox Standard for layers
|
|
6782
|
-
// like `mapbox-landmarks` (mapbox.mapbox-landmark-icons-v1)
|
|
6783
|
-
//
|
|
6784
|
-
// TileJSON
|
|
7883
|
+
// like `mapbox-landmarks` (mapbox.mapbox-landmark-icons-v1). Each type has
|
|
7884
|
+
// its own tile endpoint (/v4/, /raster/v1/, /rasterarrays/v1/, /3dtiles/v1/),
|
|
7885
|
+
// but TileJSON gives the URL for all of them, so the resolution path below
|
|
7886
|
+
// handles them uniformly.
|
|
6785
7887
|
if (config.type === 'vector' ||
|
|
6786
7888
|
config.type === 'raster' ||
|
|
6787
7889
|
config.type === 'raster-dem' ||
|
|
6788
7890
|
config.type === 'raster-array' ||
|
|
6789
7891
|
config.type === 'batched-model') {
|
|
7892
|
+
// A style already patched for offline (every region after the first)
|
|
7893
|
+
// has idb:// tiles and a maxzoom capped to earlier regions; plan from
|
|
7894
|
+
// the upstream values patchStyleForOffline stashed instead.
|
|
7895
|
+
const sourceTiles = config.__originalTiles ?? config.tiles;
|
|
7896
|
+
if (config.__originalMaxzoom !== undefined) {
|
|
7897
|
+
config.maxzoom = config.__originalMaxzoom ?? undefined;
|
|
7898
|
+
}
|
|
6790
7899
|
// Handle direct tile URLs in the source config
|
|
6791
|
-
if (
|
|
7900
|
+
if (sourceTiles && Array.isArray(sourceTiles) && sourceTiles.length > 0) {
|
|
6792
7901
|
// Resolve mapbox:// tile URLs to HTTPS, then filter for HTTP(S) URLs
|
|
6793
|
-
const resolvedTiles =
|
|
7902
|
+
const resolvedTiles = sourceTiles.map((tile) => {
|
|
6794
7903
|
if (isMapboxProtocol(tile)) {
|
|
6795
7904
|
// Try to find an access token from the style or source URL
|
|
6796
7905
|
const accessToken = this.extractAccessTokenFromStyle(style);
|
|
@@ -6862,7 +7971,9 @@ class TileService {
|
|
|
6862
7971
|
});
|
|
6863
7972
|
tileLogger.debug(`TileJSON fetch response type: ${response.type}`);
|
|
6864
7973
|
if (response.type === 'json') {
|
|
6865
|
-
const jsonData = response.data
|
|
7974
|
+
const jsonData = response.data
|
|
7975
|
+
? selectTileJsonVariant(response.data)
|
|
7976
|
+
: null;
|
|
6866
7977
|
tileLogger.debug(`TileJSON data keys: ${Object.keys(jsonData || {}).join(', ')}`);
|
|
6867
7978
|
if (jsonData &&
|
|
6868
7979
|
typeof jsonData === 'object' &&
|
|
@@ -6966,16 +8077,9 @@ class TileService {
|
|
|
6966
8077
|
}
|
|
6967
8078
|
}
|
|
6968
8079
|
if (tileSources.size === 0) {
|
|
8080
|
+
// No fabricated fallback: a relative '{z}/{x}/{y}.pbf' template resolves
|
|
8081
|
+
// against the page and stores whatever the app server returns as tiles.
|
|
6969
8082
|
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
8083
|
}
|
|
6980
8084
|
return tileSources;
|
|
6981
8085
|
}
|
|
@@ -7133,7 +8237,7 @@ class GlyphService {
|
|
|
7133
8237
|
db = dbPromise;
|
|
7134
8238
|
async downloadGlyphs(glyphUrl, fontstacks, styleName, ranges = ['0-255'], options = {}) {
|
|
7135
8239
|
const db = await this.db;
|
|
7136
|
-
const { maxConcurrency = 5, retries = 3, timeout = 10000, onProgress, enableValidation = true, priorityFonts = [], } = options;
|
|
8240
|
+
const { maxConcurrency = 5, retries = 3, timeout = 10000, onProgress, enableValidation = true, priorityFonts = [], signal, } = options;
|
|
7137
8241
|
const startTime = Date.now();
|
|
7138
8242
|
let totalSize = 0;
|
|
7139
8243
|
let downloadedGlyphs = 0;
|
|
@@ -7165,7 +8269,8 @@ class GlyphService {
|
|
|
7165
8269
|
// Process glyphs with concurrency control
|
|
7166
8270
|
const semaphore = new Array(maxConcurrency).fill(0);
|
|
7167
8271
|
const promises = semaphore.map(async () => {
|
|
7168
|
-
|
|
8272
|
+
// Once cancelled, workers stop taking tasks; those in flight finish.
|
|
8273
|
+
while (glyphTasks.length > 0 && !signal?.aborted) {
|
|
7169
8274
|
const task = glyphTasks.shift();
|
|
7170
8275
|
if (!task)
|
|
7171
8276
|
break;
|
|
@@ -7385,339 +8490,144 @@ class GlyphService {
|
|
|
7385
8490
|
corruptionRate: stats.count > 0 ? (stats.corruptedGlyphs.length / stats.count) * 100 : 0,
|
|
7386
8491
|
},
|
|
7387
8492
|
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: {},
|
|
8493
|
+
oldestGlyph: stats.oldestGlyph,
|
|
8494
|
+
newestGlyph: stats.newestGlyph,
|
|
8495
|
+
ageSpan: stats.oldestGlyph && stats.newestGlyph
|
|
8496
|
+
? stats.newestGlyph.lastModified - stats.oldestGlyph.lastModified
|
|
8497
|
+
: 0,
|
|
8498
|
+
},
|
|
7654
8499
|
};
|
|
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
8500
|
}
|
|
7666
8501
|
/**
|
|
7667
|
-
*
|
|
8502
|
+
* Remove glyphs older than the specified age. When `options.styleId` is
|
|
8503
|
+
* provided, only glyphs belonging to that style (per
|
|
8504
|
+
* `resourceKeyBelongsToStyle`) are eligible.
|
|
7668
8505
|
*/
|
|
7669
|
-
async
|
|
8506
|
+
async cleanupOldGlyphs(maxAge = 30, options = {}) {
|
|
7670
8507
|
const db = await this.db;
|
|
7671
|
-
const
|
|
7672
|
-
|
|
7673
|
-
|
|
8508
|
+
const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
|
|
8509
|
+
const { styleId } = options;
|
|
8510
|
+
let deletedCount = 0;
|
|
8511
|
+
const tx = db.transaction('glyphs', 'readwrite');
|
|
7674
8512
|
for await (const cursor of tx.store) {
|
|
7675
|
-
|
|
8513
|
+
const glyphEntry = cursor.value;
|
|
8514
|
+
const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
|
|
8515
|
+
if (belongs && glyphEntry.lastModified < cutoffTime) {
|
|
7676
8516
|
await cursor.delete();
|
|
7677
|
-
|
|
8517
|
+
deletedCount++;
|
|
7678
8518
|
}
|
|
7679
8519
|
}
|
|
7680
|
-
return
|
|
8520
|
+
return deletedCount;
|
|
7681
8521
|
}
|
|
7682
|
-
|
|
7683
|
-
* Basic integrity check: remove entries with empty/missing data.
|
|
7684
|
-
*/
|
|
7685
|
-
async verifyAndRepairModels() {
|
|
8522
|
+
async verifyAndRepairGlyphs() {
|
|
7686
8523
|
const db = await this.db;
|
|
7687
8524
|
let verified = 0;
|
|
8525
|
+
// eslint-disable-next-line prefer-const -- will be incremented when repair logic is added
|
|
8526
|
+
let repaired = 0;
|
|
7688
8527
|
let removed = 0;
|
|
7689
|
-
const tx = db.transaction('
|
|
8528
|
+
const tx = db.transaction('glyphs', 'readwrite');
|
|
7690
8529
|
for await (const cursor of tx.store) {
|
|
7691
|
-
const
|
|
7692
|
-
|
|
8530
|
+
const glyphEntry = cursor.value;
|
|
8531
|
+
try {
|
|
8532
|
+
// Basic validation
|
|
8533
|
+
if (!glyphEntry.data || glyphEntry.size === 0) {
|
|
8534
|
+
await cursor.delete();
|
|
8535
|
+
removed++;
|
|
8536
|
+
}
|
|
8537
|
+
else {
|
|
8538
|
+
this.validateGlyphData(glyphEntry.data);
|
|
8539
|
+
verified++;
|
|
8540
|
+
}
|
|
8541
|
+
}
|
|
8542
|
+
catch {
|
|
8543
|
+
// Remove corrupted glyphs (they can be re-downloaded)
|
|
7693
8544
|
await cursor.delete();
|
|
7694
8545
|
removed++;
|
|
7695
8546
|
}
|
|
7696
|
-
|
|
7697
|
-
|
|
8547
|
+
}
|
|
8548
|
+
return { verified, repaired, removed };
|
|
8549
|
+
}
|
|
8550
|
+
validateGlyphData(data) {
|
|
8551
|
+
if (data.byteLength < 10) {
|
|
8552
|
+
throw new Error('Glyph data too short');
|
|
8553
|
+
}
|
|
8554
|
+
// Basic Protocol Buffer validation
|
|
8555
|
+
const view = new Uint8Array(data);
|
|
8556
|
+
// Check for valid protobuf structure (basic check)
|
|
8557
|
+
if (view[0] === 0x00 && view[1] === 0x00 && view[2] === 0x00 && view[3] === 0x00) {
|
|
8558
|
+
throw new Error('Invalid glyph data - appears to be empty');
|
|
8559
|
+
}
|
|
8560
|
+
}
|
|
8561
|
+
normalizeFontstack(fontstack) {
|
|
8562
|
+
try {
|
|
8563
|
+
return decodeURIComponent(fontstack);
|
|
8564
|
+
}
|
|
8565
|
+
catch {
|
|
8566
|
+
return fontstack;
|
|
8567
|
+
}
|
|
8568
|
+
}
|
|
8569
|
+
buildGlyphPath(fontstack, range) {
|
|
8570
|
+
const normalizedFontstack = this.normalizeFontstack(fontstack);
|
|
8571
|
+
return `${normalizedFontstack}/${range}.pbf`;
|
|
8572
|
+
}
|
|
8573
|
+
parseGlyphKey(key) {
|
|
8574
|
+
let remaining = key;
|
|
8575
|
+
let styleId;
|
|
8576
|
+
if (remaining.includes('::')) {
|
|
8577
|
+
const [prefix, ...rest] = remaining.split('::');
|
|
8578
|
+
styleId = prefix;
|
|
8579
|
+
remaining = rest.join('::');
|
|
8580
|
+
}
|
|
8581
|
+
else if (remaining.includes(':') && !remaining.includes('/')) {
|
|
8582
|
+
const [prefix, rest] = remaining.split(':');
|
|
8583
|
+
styleId = styleId ?? prefix;
|
|
8584
|
+
remaining = rest;
|
|
8585
|
+
}
|
|
8586
|
+
const trimmed = remaining.endsWith('.pbf') ? remaining.slice(0, -4) : remaining;
|
|
8587
|
+
if (trimmed.includes('/')) {
|
|
8588
|
+
const [fontstack, range] = trimmed.split('/');
|
|
8589
|
+
if (fontstack && range) {
|
|
8590
|
+
return { styleId, fontstack, range };
|
|
7698
8591
|
}
|
|
7699
8592
|
}
|
|
7700
|
-
|
|
8593
|
+
if (trimmed.includes('_')) {
|
|
8594
|
+
const [fontstack, range] = trimmed.split('_');
|
|
8595
|
+
if (fontstack && range) {
|
|
8596
|
+
return { styleId, fontstack, range };
|
|
8597
|
+
}
|
|
8598
|
+
}
|
|
8599
|
+
return { styleId, fontstack: trimmed, range: '' };
|
|
8600
|
+
}
|
|
8601
|
+
createGlyphKey(fontstack, range, styleName) {
|
|
8602
|
+
// Create a consistent key from the style name, fontstack and range
|
|
8603
|
+
// Format: stylename::fontstack/range.pbf (if styleName provided) or fontstack/range.pbf
|
|
8604
|
+
const glyphPath = this.buildGlyphPath(fontstack, range);
|
|
8605
|
+
if (styleName) {
|
|
8606
|
+
return `${styleName}::${glyphPath}`;
|
|
8607
|
+
}
|
|
8608
|
+
// Fallback to original format for backward compatibility
|
|
8609
|
+
return glyphPath;
|
|
7701
8610
|
}
|
|
7702
8611
|
}
|
|
7703
|
-
//
|
|
7704
|
-
const
|
|
7705
|
-
const
|
|
7706
|
-
const
|
|
7707
|
-
const
|
|
7708
|
-
const
|
|
7709
|
-
const
|
|
8612
|
+
// Export singleton instance and functions for backward compatibility
|
|
8613
|
+
const glyphService = new GlyphService();
|
|
8614
|
+
const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => glyphService.downloadGlyphs(glyphUrl, fontstacks, styleName, ranges, options);
|
|
8615
|
+
const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
|
|
8616
|
+
const getGlyphStats = () => glyphService.getGlyphStats();
|
|
8617
|
+
const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
|
|
8618
|
+
const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
|
|
8619
|
+
const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
|
|
7710
8620
|
|
|
7711
|
-
var
|
|
8621
|
+
var glyphService$1 = /*#__PURE__*/Object.freeze({
|
|
7712
8622
|
__proto__: null,
|
|
7713
|
-
|
|
7714
|
-
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
8623
|
+
GlyphService: GlyphService,
|
|
8624
|
+
cleanupOldGlyphs: cleanupOldGlyphs,
|
|
8625
|
+
downloadGlyphs: downloadGlyphs,
|
|
8626
|
+
getGlyphAnalytics: getGlyphAnalytics,
|
|
8627
|
+
getGlyphStats: getGlyphStats,
|
|
8628
|
+
glyphService: glyphService,
|
|
8629
|
+
loadGlyphs: loadGlyphs,
|
|
8630
|
+
verifyAndRepairGlyphs: verifyAndRepairGlyphs
|
|
7721
8631
|
});
|
|
7722
8632
|
|
|
7723
8633
|
class ResourceService {
|
|
@@ -7817,23 +8727,26 @@ class AnalyticsService {
|
|
|
7817
8727
|
return getGlyphStats();
|
|
7818
8728
|
}
|
|
7819
8729
|
async getComprehensiveStorageAnalytics(getRegionAnalytics) {
|
|
7820
|
-
const [tileStats, fontStats, spriteStats, glyphStats, regionAnalytics] = await Promise.all([
|
|
8730
|
+
const [tileStats, fontStats, spriteStats, glyphStats, modelStats, styleStats, regionAnalytics] = await Promise.all([
|
|
7821
8731
|
this.getAllTileStats(),
|
|
7822
8732
|
this.getAllFontStats(),
|
|
7823
8733
|
this.getAllSpriteStats(),
|
|
7824
8734
|
this.getAllGlyphStats(),
|
|
8735
|
+
getModelStats(),
|
|
8736
|
+
getStyleStats(),
|
|
7825
8737
|
getRegionAnalytics(),
|
|
7826
8738
|
]);
|
|
7827
|
-
const totalStorageSize = (tileStats.totalSize || 0) +
|
|
7828
|
-
(fontStats.totalSize || 0) +
|
|
7829
|
-
(spriteStats.totalSize || 0) +
|
|
7830
|
-
(glyphStats.totalSize || 0);
|
|
7831
8739
|
const storageByType = {
|
|
8740
|
+
// The style documents themselves, which the panel's per-style totals include.
|
|
8741
|
+
styles: styleStats.totalSize || 0,
|
|
7832
8742
|
tiles: tileStats.totalSize || 0,
|
|
7833
8743
|
fonts: fontStats.totalSize || 0,
|
|
7834
8744
|
sprites: spriteStats.totalSize || 0,
|
|
7835
8745
|
glyphs: glyphStats.totalSize || 0,
|
|
8746
|
+
// Mapbox Standard 3D models (.glb) — often several MB each.
|
|
8747
|
+
models: modelStats.totalSize || 0,
|
|
7836
8748
|
};
|
|
8749
|
+
const totalStorageSize = Object.values(storageByType).reduce((sum, size) => sum + size, 0);
|
|
7837
8750
|
const recommendations = this.generateStorageRecommendations({
|
|
7838
8751
|
tiles: tileStats,
|
|
7839
8752
|
fonts: fontStats,
|
|
@@ -7847,6 +8760,7 @@ class AnalyticsService {
|
|
|
7847
8760
|
fonts: fontStats,
|
|
7848
8761
|
sprites: spriteStats,
|
|
7849
8762
|
glyphs: glyphStats,
|
|
8763
|
+
models: modelStats,
|
|
7850
8764
|
regions: regionAnalytics,
|
|
7851
8765
|
totalStorageSize,
|
|
7852
8766
|
storageByType,
|
|
@@ -7978,6 +8892,53 @@ function buildVectorJsonMetadata(style, sourceIds) {
|
|
|
7978
8892
|
return JSON.stringify({ vector_layers: merged });
|
|
7979
8893
|
}
|
|
7980
8894
|
const serviceLogger = logger.scope('ImportExportService');
|
|
8895
|
+
/** Map stored tile extensions to MBTiles 1.3 `format` values (`pbf`, `jpg`, ...). */
|
|
8896
|
+
function normalizeMBTilesFormat(ext) {
|
|
8897
|
+
const lower = ext.toLowerCase();
|
|
8898
|
+
if (lower === 'mvt')
|
|
8899
|
+
return 'pbf';
|
|
8900
|
+
if (lower === 'jpeg')
|
|
8901
|
+
return 'jpg';
|
|
8902
|
+
return lower;
|
|
8903
|
+
}
|
|
8904
|
+
/**
|
|
8905
|
+
* MBTiles holds one tile per z/x/y, so tiles from several style sources can't
|
|
8906
|
+
* share a file — they'd overwrite each other at every shared coordinate.
|
|
8907
|
+
* Keep only `sourceId` when given, otherwise the source with the most tiles
|
|
8908
|
+
* (the basemap in practice), and warn about what was left out.
|
|
8909
|
+
*/
|
|
8910
|
+
function selectSingleSource(tiles, sourceId) {
|
|
8911
|
+
const bySource = new Map();
|
|
8912
|
+
for (const tile of tiles) {
|
|
8913
|
+
const group = bySource.get(tile.sourceId);
|
|
8914
|
+
if (group)
|
|
8915
|
+
group.push(tile);
|
|
8916
|
+
else
|
|
8917
|
+
bySource.set(tile.sourceId, [tile]);
|
|
8918
|
+
}
|
|
8919
|
+
if (sourceId !== undefined) {
|
|
8920
|
+
const selected = bySource.get(sourceId);
|
|
8921
|
+
if (!selected) {
|
|
8922
|
+
const available = [...bySource.keys()].join(', ') || 'none';
|
|
8923
|
+
throw new Error(`Source "${sourceId}" has no tiles in this region (available: ${available})`);
|
|
8924
|
+
}
|
|
8925
|
+
return selected;
|
|
8926
|
+
}
|
|
8927
|
+
if (bySource.size <= 1)
|
|
8928
|
+
return tiles;
|
|
8929
|
+
let chosen = '';
|
|
8930
|
+
let chosenTiles = [];
|
|
8931
|
+
for (const [id, group] of bySource) {
|
|
8932
|
+
if (group.length > chosenTiles.length) {
|
|
8933
|
+
chosen = id;
|
|
8934
|
+
chosenTiles = group;
|
|
8935
|
+
}
|
|
8936
|
+
}
|
|
8937
|
+
const skipped = [...bySource.keys()].filter(id => id !== chosen);
|
|
8938
|
+
serviceLogger.warn(`Region has tiles from ${bySource.size} sources; MBTiles holds one. Exporting "${chosen}", ` +
|
|
8939
|
+
`skipping ${skipped.join(', ')}. Pass { sourceId } to choose another.`);
|
|
8940
|
+
return chosenTiles;
|
|
8941
|
+
}
|
|
7981
8942
|
class ImportExportService {
|
|
7982
8943
|
db = dbPromise;
|
|
7983
8944
|
constructor() {
|
|
@@ -8002,10 +8963,11 @@ class ImportExportService {
|
|
|
8002
8963
|
if (!region) {
|
|
8003
8964
|
throw new Error(`Region ${regionId} not found`);
|
|
8004
8965
|
}
|
|
8005
|
-
const
|
|
8006
|
-
|
|
8007
|
-
//
|
|
8008
|
-
|
|
8966
|
+
const regionTiles = await this.exportTiles(region, onProgress);
|
|
8967
|
+
const tiles = selectSingleSource(regionTiles, options.sourceId);
|
|
8968
|
+
// Pick format: caller override → stored tile extension → region.tileExtension
|
|
8969
|
+
// → default pbf. Drives both the metadata row and whether tile bytes get gzipped.
|
|
8970
|
+
const format = normalizeMBTilesFormat(String(options.format || tiles[0]?.format || region.tileExtension || 'pbf'));
|
|
8009
8971
|
const isVector = VECTOR_FORMATS.has(format);
|
|
8010
8972
|
onProgress({
|
|
8011
8973
|
stage: 'processing',
|
|
@@ -8230,11 +9192,19 @@ class ImportExportService {
|
|
|
8230
9192
|
/**
|
|
8231
9193
|
* Export tiles data
|
|
8232
9194
|
*/
|
|
8233
|
-
async exportTiles(
|
|
9195
|
+
async exportTiles(region, onProgress) {
|
|
8234
9196
|
const db = await this.db;
|
|
8235
|
-
|
|
8236
|
-
|
|
8237
|
-
|
|
9197
|
+
const styleId = region.styleId || region.id;
|
|
9198
|
+
// Tiles are stored per style, not per region. When the style holds other
|
|
9199
|
+
// regions too, keep only tiles inside this region's bounds and the zooms
|
|
9200
|
+
// the download pipeline fetches for each source (which can fall outside
|
|
9201
|
+
// the region's own range — see sourceZoomRange), or sibling regions' tiles
|
|
9202
|
+
// leak into the export. A sole region owns all of its style's tiles — and
|
|
9203
|
+
// an imported region's bounds come from MBTiles metadata, which can be
|
|
9204
|
+
// tighter than the tiles — so don't filter then.
|
|
9205
|
+
const styleEntry = await db.get('styles', styleId);
|
|
9206
|
+
const sharesStyle = (styleEntry?.regions?.length ?? 0) > 1;
|
|
9207
|
+
const styleSources = (styleEntry?.style?.sources ?? {});
|
|
8238
9208
|
const transaction = db.transaction(['tiles'], 'readonly');
|
|
8239
9209
|
const store = transaction.objectStore('tiles');
|
|
8240
9210
|
const tiles = [];
|
|
@@ -8243,22 +9213,31 @@ class ImportExportService {
|
|
|
8243
9213
|
let processed = 0;
|
|
8244
9214
|
while (cursor) {
|
|
8245
9215
|
const tile = cursor.value;
|
|
8246
|
-
|
|
8247
|
-
|
|
9216
|
+
const parsed = parseTileKey(String(tile.key));
|
|
9217
|
+
const z = tile.z ?? parsed?.z;
|
|
9218
|
+
const x = tile.x ?? parsed?.x;
|
|
9219
|
+
const y = tile.y ?? parsed?.y;
|
|
9220
|
+
if (tile.styleId === styleId &&
|
|
9221
|
+
z !== undefined &&
|
|
9222
|
+
x !== undefined &&
|
|
9223
|
+
y !== undefined &&
|
|
9224
|
+
(!sharesStyle ||
|
|
9225
|
+
tileBelongsToRegion({ sourceId: tile.sourceId ?? parsed?.sourceId, z, x, y }, region, styleSources))) {
|
|
8248
9226
|
tiles.push({
|
|
8249
|
-
z
|
|
8250
|
-
x
|
|
8251
|
-
y
|
|
9227
|
+
z,
|
|
9228
|
+
x,
|
|
9229
|
+
y,
|
|
8252
9230
|
data: tile.data,
|
|
8253
|
-
format: 'pbf',
|
|
8254
|
-
sourceId: tile.sourceId ?? 'default',
|
|
9231
|
+
format: (parsed?.ext || 'pbf').toLowerCase(),
|
|
9232
|
+
sourceId: tile.sourceId ?? parsed?.sourceId ?? 'default',
|
|
8255
9233
|
});
|
|
8256
9234
|
}
|
|
8257
9235
|
processed++;
|
|
8258
9236
|
if (onProgress && processed % 100 === 0) {
|
|
8259
9237
|
onProgress({
|
|
8260
9238
|
stage: 'exporting',
|
|
8261
|
-
|
|
9239
|
+
// Total is unknown while walking the cursor; approach 70% asymptotically.
|
|
9240
|
+
percentage: 30 + 40 * (1 - 1 / (1 + processed / 1000)),
|
|
8262
9241
|
message: `Exported ${processed} tiles...`,
|
|
8263
9242
|
currentItem: `${tile.z ?? 0}/${tile.x ?? 0}/${tile.y ?? 0}`,
|
|
8264
9243
|
completedItems: processed,
|
|
@@ -8439,7 +9418,7 @@ class ImportExportService {
|
|
|
8439
9418
|
sourceId,
|
|
8440
9419
|
downloadedAt: new Date().toISOString(),
|
|
8441
9420
|
size: tile.data instanceof ArrayBuffer ? tile.data.byteLength : 0,
|
|
8442
|
-
type: 'vector',
|
|
9421
|
+
type: VECTOR_FORMATS.has(ext) ? 'vector' : 'raster',
|
|
8443
9422
|
url: `tile://${tile.z}/${tile.x}/${tile.y}`,
|
|
8444
9423
|
lastModified: Date.now(),
|
|
8445
9424
|
});
|
|
@@ -8483,7 +9462,7 @@ const createRegionManagement = (services) => ({
|
|
|
8483
9462
|
addRegion: async (region) => services.regionService.addRegion(region),
|
|
8484
9463
|
downloadRegion: async (region, options) => services.regionService.downloadRegion(region, options),
|
|
8485
9464
|
loadRegion: async (region, options) => services.regionService.loadRegion(region, options),
|
|
8486
|
-
deleteRegion: async (regionId) => services.regionService.deleteRegion(regionId),
|
|
9465
|
+
deleteRegion: async (regionId, styleId) => services.regionService.deleteRegion(regionId, styleId),
|
|
8487
9466
|
listRegions: async () => services.regionService.listRegions(),
|
|
8488
9467
|
listStoredRegions: async () => services.regionService.listStoredRegions(),
|
|
8489
9468
|
getStoredRegion: async (regionId) => {
|
|
@@ -8505,7 +9484,7 @@ const createCleanupManagement = (services) => ({
|
|
|
8505
9484
|
let deletedCount = 0;
|
|
8506
9485
|
for (const region of allRegions) {
|
|
8507
9486
|
if (region.expiry && region.expiry < now) {
|
|
8508
|
-
await services.regionService.deleteRegion(region.id);
|
|
9487
|
+
await services.regionService.deleteRegion(region.id, region.styleId);
|
|
8509
9488
|
deletedCount++;
|
|
8510
9489
|
}
|
|
8511
9490
|
}
|
|
@@ -8956,7 +9935,7 @@ const en = {
|
|
|
8956
9935
|
'language.ar': 'Arabic',
|
|
8957
9936
|
// Header
|
|
8958
9937
|
'header.title': 'Offline Manager',
|
|
8959
|
-
'header.subtitle': '{{
|
|
9938
|
+
'header.subtitle': '{{regions}} • {{size}} total',
|
|
8960
9939
|
// Actions
|
|
8961
9940
|
'actions.addRegion': 'Add Region',
|
|
8962
9941
|
'actions.refresh': 'Refresh',
|
|
@@ -8972,6 +9951,8 @@ const en = {
|
|
|
8972
9951
|
'regionForm.namePlaceholder': 'e.g., Downtown District',
|
|
8973
9952
|
'regionForm.minZoom': 'Min Zoom',
|
|
8974
9953
|
'regionForm.maxZoom': 'Max Zoom',
|
|
9954
|
+
'regionForm.buildingsHint': "Mapbox Standard's buildings start at zoom 15. Set Max Zoom to 15 or more to include them.",
|
|
9955
|
+
'regionForm.zoomRangeError': 'Max Zoom must be at least Min Zoom.',
|
|
8975
9956
|
'regionForm.styleUrl': 'Style URL',
|
|
8976
9957
|
'regionForm.provider': 'Style Provider',
|
|
8977
9958
|
'regionForm.providerAuto': 'Auto-detect',
|
|
@@ -9001,7 +9982,10 @@ const en = {
|
|
|
9001
9982
|
'regionList.downloaded': 'Downloaded',
|
|
9002
9983
|
'regionList.size': 'Size',
|
|
9003
9984
|
'regionList.tiles': 'Tiles',
|
|
9985
|
+
'regionList.resources': 'Fonts, icons and models',
|
|
9004
9986
|
'regionList.styleData': 'Style',
|
|
9987
|
+
'regionList.regionCountOne': '1 region',
|
|
9988
|
+
'regionList.regionCount': '{{count}} regions',
|
|
9005
9989
|
'regionList.totalSize': 'Total',
|
|
9006
9990
|
'regionList.styleId': 'Style ID',
|
|
9007
9991
|
// Region Details
|
|
@@ -9087,6 +10071,14 @@ const en = {
|
|
|
9087
10071
|
'mbtiles.importFailed': 'Import failed. Please try again.',
|
|
9088
10072
|
// Active Downloads
|
|
9089
10073
|
'download.activeCount': 'Active Downloads ({{count}})',
|
|
10074
|
+
'download.status.style': 'Preparing style',
|
|
10075
|
+
'download.status.sprites': 'Sprites: {{completed}} of {{total}}',
|
|
10076
|
+
'download.status.glyphs': 'Fonts: {{completed}} of {{total}}',
|
|
10077
|
+
'download.status.models': '3D models: {{completed}} of {{total}}',
|
|
10078
|
+
'download.status.tiles': 'Tiles: {{completed}} of {{total}}',
|
|
10079
|
+
'download.status.planning': 'Planning tiles',
|
|
10080
|
+
'download.cancelRegion': 'Cancel downloading {{name}}',
|
|
10081
|
+
'download.status.saving': 'Saving region',
|
|
9090
10082
|
// Panel Manager additional strings
|
|
9091
10083
|
'panel.downloadedRegions': 'Downloaded Regions',
|
|
9092
10084
|
'panel.noExpiry': 'No expiry',
|
|
@@ -9108,6 +10100,8 @@ const en = {
|
|
|
9108
10100
|
'control.saveSelectedRegion': 'Save Selected Region',
|
|
9109
10101
|
// Alerts/Messages
|
|
9110
10102
|
'alert.unknownError': 'Unknown error occurred',
|
|
10103
|
+
'alert.downloadFailed': 'Download failed',
|
|
10104
|
+
'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
10105
|
'alert.downloadNotImplemented': 'Download feature not implemented in this refactored version',
|
|
9112
10106
|
'alert.mapNotAvailable': 'Map not available',
|
|
9113
10107
|
'alert.invalidBounds': 'Invalid bounds for region',
|
|
@@ -9164,7 +10158,7 @@ const ar = {
|
|
|
9164
10158
|
'language.ar': 'العربية',
|
|
9165
10159
|
// Header - الرأس
|
|
9166
10160
|
'header.title': 'مدير الخرائط غير المتصلة',
|
|
9167
|
-
'header.subtitle': '{{
|
|
10161
|
+
'header.subtitle': '{{regions}} • {{size}} إجمالي',
|
|
9168
10162
|
// Actions - الإجراءات
|
|
9169
10163
|
'actions.addRegion': 'إضافة منطقة',
|
|
9170
10164
|
'actions.refresh': 'تحديث',
|
|
@@ -9180,6 +10174,8 @@ const ar = {
|
|
|
9180
10174
|
'regionForm.namePlaceholder': 'مثال: وسط المدينة',
|
|
9181
10175
|
'regionForm.minZoom': 'أدنى تكبير',
|
|
9182
10176
|
'regionForm.maxZoom': 'أقصى تكبير',
|
|
10177
|
+
'regionForm.buildingsHint': 'تبدأ مباني Mapbox Standard عند مستوى التكبير 15. اضبط أقصى تكبير على 15 أو أكثر لتضمينها.',
|
|
10178
|
+
'regionForm.zoomRangeError': 'يجب ألا يقل أقصى تكبير عن أدنى تكبير.',
|
|
9183
10179
|
'regionForm.styleUrl': 'رابط النمط',
|
|
9184
10180
|
'regionForm.provider': 'مزود النمط',
|
|
9185
10181
|
'regionForm.providerAuto': 'اكتشاف تلقائي',
|
|
@@ -9209,7 +10205,10 @@ const ar = {
|
|
|
9209
10205
|
'regionList.downloaded': 'تم التحميل',
|
|
9210
10206
|
'regionList.size': 'الحجم',
|
|
9211
10207
|
'regionList.tiles': 'البلاطات',
|
|
10208
|
+
'regionList.resources': 'الخطوط والرموز والنماذج',
|
|
9212
10209
|
'regionList.styleData': 'النمط',
|
|
10210
|
+
'regionList.regionCountOne': 'منطقة واحدة',
|
|
10211
|
+
'regionList.regionCount': '{{count}} منطقة',
|
|
9213
10212
|
'regionList.totalSize': 'الإجمالي',
|
|
9214
10213
|
'regionList.styleId': 'معرف النمط',
|
|
9215
10214
|
// Region Details - تفاصيل المنطقة
|
|
@@ -9295,6 +10294,14 @@ const ar = {
|
|
|
9295
10294
|
'mbtiles.importFailed': 'فشل الاستيراد. يرجى المحاولة مرة أخرى.',
|
|
9296
10295
|
// Active Downloads - التحميلات النشطة
|
|
9297
10296
|
'download.activeCount': 'التحميلات النشطة ({{count}})',
|
|
10297
|
+
'download.status.style': 'جارٍ تجهيز النمط',
|
|
10298
|
+
'download.status.sprites': 'الرموز: {{completed}} من {{total}}',
|
|
10299
|
+
'download.status.glyphs': 'الخطوط: {{completed}} من {{total}}',
|
|
10300
|
+
'download.status.models': 'النماذج ثلاثية الأبعاد: {{completed}} من {{total}}',
|
|
10301
|
+
'download.status.tiles': 'البلاطات: {{completed}} من {{total}}',
|
|
10302
|
+
'download.status.planning': 'جارٍ تخطيط البلاطات',
|
|
10303
|
+
'download.cancelRegion': 'إلغاء تحميل {{name}}',
|
|
10304
|
+
'download.status.saving': 'جارٍ حفظ المنطقة',
|
|
9298
10305
|
// Panel Manager additional strings - سلاسل إضافية للوحة
|
|
9299
10306
|
'panel.downloadedRegions': 'المناطق المحملة',
|
|
9300
10307
|
'panel.noExpiry': 'بدون انتهاء',
|
|
@@ -9316,6 +10323,8 @@ const ar = {
|
|
|
9316
10323
|
'control.saveSelectedRegion': 'حفظ المنطقة المحددة',
|
|
9317
10324
|
// Alerts/Messages - التنبيهات/الرسائل
|
|
9318
10325
|
'alert.unknownError': 'حدث خطأ غير معروف',
|
|
10326
|
+
'alert.downloadFailed': 'فشل التحميل',
|
|
10327
|
+
'alert.needsMapboxGl': 'يستخدم هذا النمط ميزات Mapbox GL JS، مثل النماذج ثلاثية الأبعاد أو إعدادات النمط، لذا لا يمكن عرضه إلا على خريطة Mapbox GL.',
|
|
9319
10328
|
'alert.downloadNotImplemented': 'ميزة التحميل غير مطبقة في هذه النسخة',
|
|
9320
10329
|
'alert.mapNotAvailable': 'الخريطة غير متاحة',
|
|
9321
10330
|
'alert.invalidBounds': 'حدود غير صالحة للمنطقة',
|
|
@@ -9450,6 +10459,9 @@ const i18n = new I18nManager();
|
|
|
9450
10459
|
const t = (key, replacements) => {
|
|
9451
10460
|
return i18n.t(key, replacements);
|
|
9452
10461
|
};
|
|
10462
|
+
// Re-export i18next for advanced usage
|
|
10463
|
+
/** "1 region" or "3 regions", in the current language. */
|
|
10464
|
+
const regionCountLabel = (count) => count === 1 ? t('regionList.regionCountOne') : t('regionList.regionCount', { count });
|
|
9453
10465
|
|
|
9454
10466
|
/**
|
|
9455
10467
|
* Base Component Class
|
|
@@ -9670,7 +10682,7 @@ class Button extends BaseComponent {
|
|
|
9670
10682
|
createProgressBadge() {
|
|
9671
10683
|
this.progressBadge = document.createElement('span');
|
|
9672
10684
|
this.progressBadge.className =
|
|
9673
|
-
'absolute
|
|
10685
|
+
'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
10686
|
// Accessibility: badge ID for aria-describedby
|
|
9675
10687
|
const badgeId = `btn-badge-${Date.now()}`;
|
|
9676
10688
|
this.progressBadge.id = badgeId;
|
|
@@ -9687,6 +10699,20 @@ class Button extends BaseComponent {
|
|
|
9687
10699
|
else {
|
|
9688
10700
|
this.element.textContent = text;
|
|
9689
10701
|
}
|
|
10702
|
+
this.restoreProgressBadge();
|
|
10703
|
+
}
|
|
10704
|
+
/**
|
|
10705
|
+
* Replace the button's content, keeping its progress badge.
|
|
10706
|
+
*/
|
|
10707
|
+
setContent(html) {
|
|
10708
|
+
this.element.innerHTML = html;
|
|
10709
|
+
this.restoreProgressBadge();
|
|
10710
|
+
}
|
|
10711
|
+
/** Replacing the button's content removes the badge, so put it back. */
|
|
10712
|
+
restoreProgressBadge() {
|
|
10713
|
+
if (this.progressBadge) {
|
|
10714
|
+
this.element.appendChild(this.progressBadge);
|
|
10715
|
+
}
|
|
9690
10716
|
}
|
|
9691
10717
|
/**
|
|
9692
10718
|
* Update button state
|
|
@@ -9782,7 +10808,7 @@ class ButtonManager {
|
|
|
9782
10808
|
* Reset button to default state
|
|
9783
10809
|
*/
|
|
9784
10810
|
resetToDefault() {
|
|
9785
|
-
this.button.
|
|
10811
|
+
this.button.setContent(icons.cloud({ size: 20, color: 'black' }));
|
|
9786
10812
|
this.button.setDisabled(false);
|
|
9787
10813
|
this.button.getElement().title = 'Offline Map Manager';
|
|
9788
10814
|
this.button.hideProgressBadge();
|
|
@@ -9895,8 +10921,7 @@ class Modal extends BaseComponent {
|
|
|
9895
10921
|
titleSection.className = 'flex flex-col';
|
|
9896
10922
|
if (this.config.title) {
|
|
9897
10923
|
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';
|
|
10924
|
+
title.className = 'text-xl font-semibold text-gray-900 dark:text-white';
|
|
9900
10925
|
title.textContent = this.config.title;
|
|
9901
10926
|
titleSection.appendChild(title);
|
|
9902
10927
|
}
|
|
@@ -10075,14 +11100,14 @@ class RegionDetailsModal {
|
|
|
10075
11100
|
}
|
|
10076
11101
|
content.innerHTML = `
|
|
10077
11102
|
<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-
|
|
11103
|
+
<h3 class="m-0 text-xl font-semibold text-gray-900 dark:text-white">
|
|
10079
11104
|
${region.name}
|
|
10080
11105
|
</h3>
|
|
10081
11106
|
</div>
|
|
10082
11107
|
|
|
10083
11108
|
<div class="grid grid-cols-2 gap-4">
|
|
10084
11109
|
<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-
|
|
11110
|
+
<label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
|
|
10086
11111
|
${t('regionDetails.bounds')}
|
|
10087
11112
|
</label>
|
|
10088
11113
|
<div class="text-sm font-mono text-gray-700 dark:text-gray-300 leading-relaxed">
|
|
@@ -10092,7 +11117,7 @@ class RegionDetailsModal {
|
|
|
10092
11117
|
</div>
|
|
10093
11118
|
|
|
10094
11119
|
<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-
|
|
11120
|
+
<label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
|
|
10096
11121
|
${t('regionDetails.zoomRange')}
|
|
10097
11122
|
</label>
|
|
10098
11123
|
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
@@ -10104,7 +11129,7 @@ class RegionDetailsModal {
|
|
|
10104
11129
|
${region.created
|
|
10105
11130
|
? `
|
|
10106
11131
|
<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-
|
|
11132
|
+
<label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
|
|
10108
11133
|
${t('regionDetails.created')}
|
|
10109
11134
|
</label>
|
|
10110
11135
|
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
@@ -10859,11 +11884,13 @@ class PanelRenderer extends BaseComponent {
|
|
|
10859
11884
|
headerContainer;
|
|
10860
11885
|
actionButtonsContainer;
|
|
10861
11886
|
regionsList;
|
|
11887
|
+
awaitingProgressRender = false;
|
|
10862
11888
|
downloadProgressContainer;
|
|
10863
11889
|
languageSelector;
|
|
10864
11890
|
// Debounce mechanism
|
|
10865
11891
|
refreshTimeout;
|
|
10866
11892
|
isRefreshing = false;
|
|
11893
|
+
refreshQueued = false;
|
|
10867
11894
|
// Language change unsubscribe function
|
|
10868
11895
|
unsubscribeLanguage;
|
|
10869
11896
|
// Stored event handler for cleanup
|
|
@@ -10932,7 +11959,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
10932
11959
|
titleSection.innerHTML = `
|
|
10933
11960
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">${t('header.title')}</h2>
|
|
10934
11961
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
10935
|
-
${t('header.subtitle', {
|
|
11962
|
+
${t('header.subtitle', { regions: regionCountLabel(regions.length), size: formatBytes(analytics.totalStorageSize) })}
|
|
10936
11963
|
</p>
|
|
10937
11964
|
`;
|
|
10938
11965
|
// Actions section
|
|
@@ -11006,6 +12033,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
11006
12033
|
this.element.removeChild(this.downloadProgressContainer);
|
|
11007
12034
|
this.downloadProgressContainer = undefined;
|
|
11008
12035
|
}
|
|
12036
|
+
this.awaitingProgressRender = false;
|
|
11009
12037
|
// Only show if there are active downloads
|
|
11010
12038
|
if (this.downloadManager?.hasActiveDownloads()) {
|
|
11011
12039
|
this.downloadProgressContainer = document.createElement('div');
|
|
@@ -11014,6 +12042,15 @@ class PanelRenderer extends BaseComponent {
|
|
|
11014
12042
|
const downloads = this.downloadManager.getCurrentDownloads();
|
|
11015
12043
|
const progressHTML = this.createDownloadProgressHTML(downloads);
|
|
11016
12044
|
this.downloadProgressContainer.innerHTML = progressHTML;
|
|
12045
|
+
// The rows are redrawn on every update, so listen on the container.
|
|
12046
|
+
this.downloadProgressContainer.addEventListener('click', event => {
|
|
12047
|
+
const button = event.target.closest('[data-action="cancel-download"]');
|
|
12048
|
+
const regionId = button?.dataset.regionId;
|
|
12049
|
+
if (!regionId)
|
|
12050
|
+
return;
|
|
12051
|
+
this.downloadManager.cancelDownload(regionId);
|
|
12052
|
+
void this.refresh();
|
|
12053
|
+
});
|
|
11017
12054
|
this.element.appendChild(this.downloadProgressContainer);
|
|
11018
12055
|
}
|
|
11019
12056
|
}
|
|
@@ -11028,9 +12065,10 @@ class PanelRenderer extends BaseComponent {
|
|
|
11028
12065
|
}
|
|
11029
12066
|
try {
|
|
11030
12067
|
// Load styles from IndexedDB and get stats
|
|
11031
|
-
const { loadStyles, getStyleStats } = await Promise.resolve().then(function () { return styleService; });
|
|
12068
|
+
const { loadStyles, getStyleStats, getStyleStorageSizes } = await Promise.resolve().then(function () { return styleService; });
|
|
11032
12069
|
const styles = await loadStyles();
|
|
11033
12070
|
const statsResult = await getStyleStats();
|
|
12071
|
+
const storageSizes = await getStyleStorageSizes();
|
|
11034
12072
|
panelLogger.debug('Loaded styles:', styles);
|
|
11035
12073
|
panelLogger.debug('Style stats:', statsResult);
|
|
11036
12074
|
// Build style size map from stats
|
|
@@ -11053,12 +12091,14 @@ class PanelRenderer extends BaseComponent {
|
|
|
11053
12091
|
for (const style of styles) {
|
|
11054
12092
|
const styleRegions = regionsByStyle[style.key] || [];
|
|
11055
12093
|
const styleJsonSize = styleSizeMap[style.key] || 0;
|
|
11056
|
-
const regionTilesSize =
|
|
11057
|
-
const
|
|
12094
|
+
const regionTilesSize = storageSizes[style.key]?.tiles ?? 0;
|
|
12095
|
+
const resourcesSize = storageSizes[style.key]?.resources ?? 0;
|
|
12096
|
+
const totalSize = styleJsonSize + regionTilesSize + resourcesSize;
|
|
11058
12097
|
// Add style item with computed sizes
|
|
11059
12098
|
const styleWithSizes = {
|
|
11060
12099
|
...style,
|
|
11061
12100
|
styleJsonSize,
|
|
12101
|
+
resourcesSize,
|
|
11062
12102
|
regionTilesSize,
|
|
11063
12103
|
totalSize,
|
|
11064
12104
|
};
|
|
@@ -11174,7 +12214,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
11174
12214
|
<div class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
|
11175
12215
|
${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
12216
|
</div>
|
|
11177
|
-
<div class="flex items-center gap-4 text-xs text-slate-
|
|
12217
|
+
<div class="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400 mt-2">
|
|
11178
12218
|
<span>${t('regionList.zoom')}: ${region.minZoom}-${region.maxZoom}</span>
|
|
11179
12219
|
${region.downloadedAt ? `<span>${t('regionList.downloaded')}: ${new Date(region.downloadedAt).toLocaleDateString()}</span>` : ''}
|
|
11180
12220
|
${region.size ? `<span>${t('regionList.size')}: ${formatBytes(region.size)}</span>` : ''}
|
|
@@ -11197,16 +12237,16 @@ class PanelRenderer extends BaseComponent {
|
|
|
11197
12237
|
<div class="flex items-center justify-between">
|
|
11198
12238
|
<div class="flex-1">
|
|
11199
12239
|
<h3 class="font-semibold text-slate-800 dark:text-slate-100 text-base">
|
|
11200
|
-
${escapeHtml$1(
|
|
12240
|
+
${escapeHtml$1(styleDisplayName(style))}
|
|
11201
12241
|
</h3>
|
|
11202
12242
|
<p class="text-sm text-slate-600 dark:text-slate-300 mt-1">
|
|
11203
12243
|
${t('regionList.styleId')}: ${escapeHtml$1(style.key)}
|
|
11204
12244
|
</p>
|
|
11205
12245
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
|
11206
|
-
${regionCount}
|
|
12246
|
+
${regionCountLabel(regionCount)} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
|
|
11207
12247
|
</p>
|
|
11208
|
-
<p class="text-xs text-slate-
|
|
11209
|
-
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
12248
|
+
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
|
12249
|
+
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.resources')}: ${formatBytes(style.resourcesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
11210
12250
|
</p>
|
|
11211
12251
|
</div>
|
|
11212
12252
|
<div class="flex items-center gap-2">
|
|
@@ -11240,7 +12280,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
11240
12280
|
<div class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
|
11241
12281
|
${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
12282
|
</div>
|
|
11243
|
-
<div class="flex items-center gap-4 text-xs text-slate-
|
|
12283
|
+
<div class="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400 mt-2">
|
|
11244
12284
|
<span>${t('regionList.zoom')}: ${region.minZoom}-${region.maxZoom}</span>
|
|
11245
12285
|
${region.downloadedAt ? `<span>${t('regionList.downloaded')}: ${new Date(region.downloadedAt).toLocaleDateString()}</span>` : ''}
|
|
11246
12286
|
${region.tileSize ? `<span>${t('regionList.tiles')}: ${formatBytes(region.tileSize)}</span>` : ''}
|
|
@@ -11279,16 +12319,16 @@ class PanelRenderer extends BaseComponent {
|
|
|
11279
12319
|
<div class="flex items-center justify-between">
|
|
11280
12320
|
<div class="flex-1">
|
|
11281
12321
|
<h3 class="font-semibold text-blue-900 dark:text-blue-100 text-base">
|
|
11282
|
-
${escapeHtml$1(
|
|
12322
|
+
${escapeHtml$1(styleDisplayName(style))}
|
|
11283
12323
|
</h3>
|
|
11284
12324
|
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
|
11285
12325
|
${t('regionList.styleId')}: ${escapeHtml$1(style.key)}
|
|
11286
12326
|
</p>
|
|
11287
12327
|
<p class="text-sm text-blue-600 dark:text-blue-400 mt-1">
|
|
11288
|
-
${regionCount}
|
|
12328
|
+
${regionCountLabel(regionCount)} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
|
|
11289
12329
|
</p>
|
|
11290
12330
|
<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)}
|
|
12331
|
+
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.resources')}: ${formatBytes(style.resourcesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
11292
12332
|
</p>
|
|
11293
12333
|
</div>
|
|
11294
12334
|
<div class="flex items-center gap-2">
|
|
@@ -11328,6 +12368,39 @@ class PanelRenderer extends BaseComponent {
|
|
|
11328
12368
|
</div>
|
|
11329
12369
|
`;
|
|
11330
12370
|
}
|
|
12371
|
+
/**
|
|
12372
|
+
* Show the latest download progress. Called on every update, so it only
|
|
12373
|
+
* redraws the progress section rather than the whole panel.
|
|
12374
|
+
*/
|
|
12375
|
+
updateDownloadProgress(downloads) {
|
|
12376
|
+
if (downloads.size === 0)
|
|
12377
|
+
return;
|
|
12378
|
+
const container = this.downloadProgressContainer;
|
|
12379
|
+
if (container?.isConnected) {
|
|
12380
|
+
container.innerHTML = this.createDownloadProgressHTML(downloads);
|
|
12381
|
+
return;
|
|
12382
|
+
}
|
|
12383
|
+
// The download started while the panel was open, so it has no section yet.
|
|
12384
|
+
if (this.element.isConnected && !this.awaitingProgressRender) {
|
|
12385
|
+
this.awaitingProgressRender = true;
|
|
12386
|
+
void this.refresh();
|
|
12387
|
+
}
|
|
12388
|
+
}
|
|
12389
|
+
/** What the current phase is doing, such as "Tiles: 64 of 128". */
|
|
12390
|
+
describeDownloadPhase(download) {
|
|
12391
|
+
const phase = download.phase;
|
|
12392
|
+
if (phase === 'style')
|
|
12393
|
+
return t('download.status.style');
|
|
12394
|
+
if (phase === 'tiles' && !download.total)
|
|
12395
|
+
return t('download.status.planning');
|
|
12396
|
+
if (phase === 'sprites' || phase === 'glyphs' || phase === 'models' || phase === 'tiles') {
|
|
12397
|
+
return t(`download.status.${phase}`, {
|
|
12398
|
+
completed: Number(download.completed) || 0,
|
|
12399
|
+
total: Number(download.total) || 0,
|
|
12400
|
+
});
|
|
12401
|
+
}
|
|
12402
|
+
return t('download.status.saving');
|
|
12403
|
+
}
|
|
11331
12404
|
/**
|
|
11332
12405
|
* Create download progress HTML
|
|
11333
12406
|
*/
|
|
@@ -11341,13 +12414,17 @@ class PanelRenderer extends BaseComponent {
|
|
|
11341
12414
|
<div class="space-y-2">
|
|
11342
12415
|
${downloadArray
|
|
11343
12416
|
.map(download => `
|
|
11344
|
-
<div class="flex items-center justify-between text-sm">
|
|
11345
|
-
<
|
|
11346
|
-
|
|
11347
|
-
<div class="
|
|
11348
|
-
|
|
12417
|
+
<div class="flex items-center justify-between gap-4 text-sm">
|
|
12418
|
+
<div class="min-w-0">
|
|
12419
|
+
<div class="truncate text-blue-800 dark:text-blue-200">${escapeHtml$1(download.regionName || download.regionId)}</div>
|
|
12420
|
+
<div class="text-xs text-gray-600 dark:text-gray-300">${escapeHtml$1(this.describeDownloadPhase(download))}</div>
|
|
12421
|
+
</div>
|
|
12422
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
12423
|
+
<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)}">
|
|
12424
|
+
<div class="h-full bg-blue-600" style="width: ${download.percentage || 0}%"></div>
|
|
11349
12425
|
</div>
|
|
11350
12426
|
<span class="text-blue-700 dark:text-blue-300 min-w-12">${Math.round(download.percentage || 0)}%</span>
|
|
12427
|
+
<button type="button" class="px-2 py-1 rounded-md text-xs font-medium cursor-pointer text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600" data-action="cancel-download" data-region-id="${escapeHtml$1(download.regionId)}" aria-label="${t('download.cancelRegion', { name: String(download.regionName || download.regionId) })}">${t('app.cancel')}</button>
|
|
11351
12428
|
</div>
|
|
11352
12429
|
</div>
|
|
11353
12430
|
`)
|
|
@@ -11422,7 +12499,15 @@ class PanelRenderer extends BaseComponent {
|
|
|
11422
12499
|
cancelText: t('app.cancel'),
|
|
11423
12500
|
onConfirm: async () => {
|
|
11424
12501
|
try {
|
|
11425
|
-
|
|
12502
|
+
// Deleting a style's last region deletes the style too.
|
|
12503
|
+
const { loadStyleById } = await Promise.resolve().then(function () { return styleService; });
|
|
12504
|
+
const shownEntry = this.isStyleShown(region.styleId)
|
|
12505
|
+
? await loadStyleById(region.styleId)
|
|
12506
|
+
: null;
|
|
12507
|
+
await this.offlineManager.deleteRegion(regionId, region.styleId);
|
|
12508
|
+
if (shownEntry && !(await loadStyleById(region.styleId))) {
|
|
12509
|
+
this.showOnlineStyle(shownEntry);
|
|
12510
|
+
}
|
|
11426
12511
|
this.modalManager.close();
|
|
11427
12512
|
// Refresh the panel to show updated regions
|
|
11428
12513
|
await this.refresh();
|
|
@@ -11475,7 +12560,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
11475
12560
|
}
|
|
11476
12561
|
// Delete the existing region (and its tiles/resources)
|
|
11477
12562
|
panelLogger.debug('Deleting region before re-download:', regionId);
|
|
11478
|
-
await this.offlineManager.deleteRegion(regionId);
|
|
12563
|
+
await this.offlineManager.deleteRegion(regionId, region.styleId);
|
|
11479
12564
|
// Prepare form data for re-download
|
|
11480
12565
|
const formData = {
|
|
11481
12566
|
name: region.name,
|
|
@@ -11696,7 +12781,8 @@ class PanelRenderer extends BaseComponent {
|
|
|
11696
12781
|
clearTimeout(this.refreshTimeout);
|
|
11697
12782
|
}
|
|
11698
12783
|
if (this.isRefreshing) {
|
|
11699
|
-
|
|
12784
|
+
// Run again once this one finishes, or the panel misses what changed meanwhile.
|
|
12785
|
+
this.refreshQueued = true;
|
|
11700
12786
|
return;
|
|
11701
12787
|
}
|
|
11702
12788
|
this.refreshTimeout = setTimeout(async () => {
|
|
@@ -11740,6 +12826,10 @@ class PanelRenderer extends BaseComponent {
|
|
|
11740
12826
|
}
|
|
11741
12827
|
finally {
|
|
11742
12828
|
this.isRefreshing = false;
|
|
12829
|
+
if (this.refreshQueued) {
|
|
12830
|
+
this.refreshQueued = false;
|
|
12831
|
+
void this.refresh();
|
|
12832
|
+
}
|
|
11743
12833
|
}
|
|
11744
12834
|
}
|
|
11745
12835
|
/**
|
|
@@ -11877,6 +12967,20 @@ class PanelRenderer extends BaseComponent {
|
|
|
11877
12967
|
/**
|
|
11878
12968
|
* Handle loading a style to the map
|
|
11879
12969
|
*/
|
|
12970
|
+
/** Whether the map is drawing this stored style, through `idb://` or the Service Worker. */
|
|
12971
|
+
isStyleShown(styleKey) {
|
|
12972
|
+
const shown = this.map?.getStyle?.();
|
|
12973
|
+
if (!shown)
|
|
12974
|
+
return false;
|
|
12975
|
+
const text = JSON.stringify(shown);
|
|
12976
|
+
return text.includes(`idb://${styleKey}/`) || text.includes(`/__offline__/${styleKey}/`);
|
|
12977
|
+
}
|
|
12978
|
+
/** After its stored copy is deleted, show a style from its original URL, not a blank map. */
|
|
12979
|
+
showOnlineStyle(entry) {
|
|
12980
|
+
if (!entry.originalUrl)
|
|
12981
|
+
return;
|
|
12982
|
+
this.map?.setStyle?.(entry.originalUrl);
|
|
12983
|
+
}
|
|
11880
12984
|
async handleLoadStyle(styleData) {
|
|
11881
12985
|
if (!this.map) {
|
|
11882
12986
|
panelLogger.warn('Map not available for loading style');
|
|
@@ -11895,6 +12999,10 @@ class PanelRenderer extends BaseComponent {
|
|
|
11895
12999
|
alert(t('error.invalidStyleData'));
|
|
11896
13000
|
return;
|
|
11897
13001
|
}
|
|
13002
|
+
if (needsMapboxGl(styleEntry.style) && !isMapboxGlMap(this.map)) {
|
|
13003
|
+
alert(t('alert.needsMapboxGl'));
|
|
13004
|
+
return;
|
|
13005
|
+
}
|
|
11898
13006
|
panelLogger.debug('Loading style from IndexedDB:', {
|
|
11899
13007
|
key: styleEntry.key,
|
|
11900
13008
|
provider: styleEntry.provider,
|
|
@@ -12079,13 +13187,16 @@ class PanelRenderer extends BaseComponent {
|
|
|
12079
13187
|
const style = styleData;
|
|
12080
13188
|
const confirmModal = new ConfirmationModal({
|
|
12081
13189
|
title: t('delete.styleTitle'),
|
|
12082
|
-
message: t('delete.styleMessage', { name: style
|
|
13190
|
+
message: t('delete.styleMessage', { name: styleDisplayName(style) }),
|
|
12083
13191
|
confirmText: t('actions.deleteStyle'),
|
|
12084
13192
|
cancelText: t('app.cancel'),
|
|
12085
13193
|
onConfirm: async () => {
|
|
12086
13194
|
try {
|
|
12087
13195
|
const { deleteStyleById } = await Promise.resolve().then(function () { return styleService; });
|
|
13196
|
+
const wasShown = this.isStyleShown(style.key);
|
|
12088
13197
|
await deleteStyleById(style.key);
|
|
13198
|
+
if (wasShown)
|
|
13199
|
+
this.showOnlineStyle(styleData);
|
|
12089
13200
|
this.modalManager.close();
|
|
12090
13201
|
// Refresh the panel
|
|
12091
13202
|
await this.refresh();
|
|
@@ -12190,7 +13301,7 @@ class PolygonControl {
|
|
|
12190
13301
|
this.saveButton = document.createElement('button');
|
|
12191
13302
|
this.saveButton.type = 'button';
|
|
12192
13303
|
const prefix = this.options.cssPrefix || 'maplibregl';
|
|
12193
|
-
this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-
|
|
13304
|
+
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
13305
|
this.saveButton.innerHTML = icons.check({ size: 16, color: 'white' });
|
|
12195
13306
|
this.saveButton.title = 'Save Selected Region';
|
|
12196
13307
|
this.saveButton.addEventListener('click', () => this.handleSave());
|
|
@@ -12257,6 +13368,33 @@ class PolygonControl {
|
|
|
12257
13368
|
handleMapMove = () => {
|
|
12258
13369
|
this.updateVisualization();
|
|
12259
13370
|
};
|
|
13371
|
+
/**
|
|
13372
|
+
* The area to select: the middle of what's on screen. On a tilted or rotated map
|
|
13373
|
+
* `getBounds()` reaches far past the view, so insetting it would miss the part
|
|
13374
|
+
* nearest the camera. There, measure from the map's centre to the screen's edges.
|
|
13375
|
+
*/
|
|
13376
|
+
getSelectionBbox(minLng, minLat, maxLng, maxLat) {
|
|
13377
|
+
if (!this.map.getPitch() && !this.map.getBearing()) {
|
|
13378
|
+
const lngInset = (maxLng - minLng) * 0.2;
|
|
13379
|
+
const latInset = (maxLat - minLat) * 0.2;
|
|
13380
|
+
return [minLng + lngInset, minLat + latInset, maxLng - lngInset, maxLat - latInset];
|
|
13381
|
+
}
|
|
13382
|
+
const center = this.map.getCenter();
|
|
13383
|
+
const { clientWidth, clientHeight } = this.map.getContainer();
|
|
13384
|
+
const lngScale = Math.cos((center.lat * Math.PI) / 180);
|
|
13385
|
+
const degreesFromCenter = (point) => {
|
|
13386
|
+
const { lng, lat } = this.map.unproject(point);
|
|
13387
|
+
return Math.hypot((lng - center.lng) * lngScale, lat - center.lat);
|
|
13388
|
+
};
|
|
13389
|
+
const halfSize = 0.6 *
|
|
13390
|
+
Math.min(degreesFromCenter([0, clientHeight / 2]), degreesFromCenter([clientWidth / 2, clientHeight]));
|
|
13391
|
+
return [
|
|
13392
|
+
center.lng - halfSize / lngScale,
|
|
13393
|
+
center.lat - halfSize,
|
|
13394
|
+
center.lng + halfSize / lngScale,
|
|
13395
|
+
center.lat + halfSize,
|
|
13396
|
+
];
|
|
13397
|
+
}
|
|
12260
13398
|
/**
|
|
12261
13399
|
* Update polygon visualization based on current map bounds
|
|
12262
13400
|
*/
|
|
@@ -12266,9 +13404,7 @@ class PolygonControl {
|
|
|
12266
13404
|
return;
|
|
12267
13405
|
const [minLng, minLat] = bboxArray[0];
|
|
12268
13406
|
const [maxLng, maxLat] = bboxArray[1];
|
|
12269
|
-
const
|
|
12270
|
-
const latDiff = (maxLat - minLat) * 0.2;
|
|
12271
|
-
const clippedBbox = [minLng + lngDiff, minLat + latDiff, maxLng - lngDiff, maxLat - latDiff];
|
|
13407
|
+
const clippedBbox = this.getSelectionBbox(minLng, minLat, maxLng, maxLat);
|
|
12272
13408
|
// Store current bounds for later use
|
|
12273
13409
|
this.currentBounds = clippedBbox;
|
|
12274
13410
|
const clippedPolygon = bboxPolygon.bboxPolygon([
|
|
@@ -12335,7 +13471,7 @@ class PolygonControl {
|
|
|
12335
13471
|
source: 'selectionPolygon',
|
|
12336
13472
|
layout: {},
|
|
12337
13473
|
paint: {
|
|
12338
|
-
'line-color': '#
|
|
13474
|
+
'line-color': '#A8116F', // Revision magenta, the UI's accent
|
|
12339
13475
|
'line-width': 3,
|
|
12340
13476
|
'line-dasharray': [2, 2],
|
|
12341
13477
|
},
|
|
@@ -12369,6 +13505,30 @@ class PolygonControl {
|
|
|
12369
13505
|
*/
|
|
12370
13506
|
// import { MaplibreMap } from 'maplibre-gl';
|
|
12371
13507
|
const formLogger = logger.scope('RegionFormModal');
|
|
13508
|
+
/** Mapbox Standard's buildings start at this zoom. */
|
|
13509
|
+
const STANDARD_BUILDINGS_MIN_ZOOM = 15;
|
|
13510
|
+
let fieldCount = 0;
|
|
13511
|
+
/**
|
|
13512
|
+
* Point each label at the one field beside it, so screen readers name the field
|
|
13513
|
+
* and clicking the label focuses it. Groups with several fields are left alone.
|
|
13514
|
+
*/
|
|
13515
|
+
function linkLabelsToFields(form) {
|
|
13516
|
+
for (const label of Array.from(form.querySelectorAll('label'))) {
|
|
13517
|
+
const fields = label.parentElement?.querySelectorAll('input, select, textarea');
|
|
13518
|
+
if (label.htmlFor || fields?.length !== 1)
|
|
13519
|
+
continue;
|
|
13520
|
+
const field = fields[0];
|
|
13521
|
+
field.id ||= `map-gl-offline-field-${++fieldCount}`;
|
|
13522
|
+
label.htmlFor = field.id;
|
|
13523
|
+
}
|
|
13524
|
+
}
|
|
13525
|
+
/**
|
|
13526
|
+
* True when a region of Mapbox Standard stops below the zoom where its buildings
|
|
13527
|
+
* start. Standard Satellite has no buildings, so it doesn't count.
|
|
13528
|
+
*/
|
|
13529
|
+
function regionMissesBuildings(styleUrl, maxZoom) {
|
|
13530
|
+
return /mapbox\/standard(?![\w-])/.test(styleUrl) && maxZoom < STANDARD_BUILDINGS_MIN_ZOOM;
|
|
13531
|
+
}
|
|
12372
13532
|
class RegionFormModal {
|
|
12373
13533
|
options;
|
|
12374
13534
|
modal;
|
|
@@ -12376,6 +13536,7 @@ class RegionFormModal {
|
|
|
12376
13536
|
minZoomInput;
|
|
12377
13537
|
maxZoomInput;
|
|
12378
13538
|
styleUrlInput;
|
|
13539
|
+
buildingsHint;
|
|
12379
13540
|
// Enhanced Mapbox GL support
|
|
12380
13541
|
providerSelect;
|
|
12381
13542
|
accessTokenInput;
|
|
@@ -12426,13 +13587,13 @@ class RegionFormModal {
|
|
|
12426
13587
|
'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
13588
|
infoGroup.innerHTML = `
|
|
12428
13589
|
<div class="flex flex-col justify-center">
|
|
12429
|
-
<strong class="text-
|
|
12430
|
-
<div class="text-xl font-
|
|
13590
|
+
<strong class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.area')}</strong>
|
|
13591
|
+
<div class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
12431
13592
|
${area.toLocaleString()} <span class="text-sm font-medium text-gray-500">km²</span>
|
|
12432
13593
|
</div>
|
|
12433
13594
|
</div>
|
|
12434
13595
|
<div>
|
|
12435
|
-
<strong class="text-
|
|
13596
|
+
<strong class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.bounds')}</strong>
|
|
12436
13597
|
<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
13598
|
${west.toFixed(4)}, ${south.toFixed(4)}<br>
|
|
12438
13599
|
${east.toFixed(4)}, ${north.toFixed(4)}
|
|
@@ -12488,6 +13649,12 @@ class RegionFormModal {
|
|
|
12488
13649
|
zoomGroup.appendChild(minZoomDiv);
|
|
12489
13650
|
zoomGroup.appendChild(maxZoomDiv);
|
|
12490
13651
|
form.appendChild(zoomGroup);
|
|
13652
|
+
this.buildingsHint = document.createElement('p');
|
|
13653
|
+
this.buildingsHint.className = 'm-0 text-sm text-amber-700 dark:text-amber-400 hidden';
|
|
13654
|
+
this.buildingsHint.setAttribute('role', 'status');
|
|
13655
|
+
this.buildingsHint.textContent = t('regionForm.buildingsHint');
|
|
13656
|
+
form.appendChild(this.buildingsHint);
|
|
13657
|
+
this.maxZoomInput.addEventListener('input', () => this.updateBuildingsHint());
|
|
12491
13658
|
// Style URL input
|
|
12492
13659
|
const styleGroup = document.createElement('div');
|
|
12493
13660
|
styleGroup.innerHTML = `
|
|
@@ -12500,9 +13667,13 @@ class RegionFormModal {
|
|
|
12500
13667
|
this.styleUrlInput.value = styleUrl;
|
|
12501
13668
|
this.styleUrlInput.className =
|
|
12502
13669
|
'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', () =>
|
|
13670
|
+
this.styleUrlInput.addEventListener('input', () => {
|
|
13671
|
+
this.handleStyleUrlChange();
|
|
13672
|
+
this.updateBuildingsHint();
|
|
13673
|
+
});
|
|
12504
13674
|
styleGroup.appendChild(this.styleUrlInput);
|
|
12505
13675
|
form.appendChild(styleGroup);
|
|
13676
|
+
this.updateBuildingsHint();
|
|
12506
13677
|
// Provider selection
|
|
12507
13678
|
const providerGroup = document.createElement('div');
|
|
12508
13679
|
providerGroup.innerHTML = `
|
|
@@ -12560,6 +13731,7 @@ class RegionFormModal {
|
|
|
12560
13731
|
}
|
|
12561
13732
|
// Initialize provider detection
|
|
12562
13733
|
this.detectProviderFromUrl();
|
|
13734
|
+
linkLabelsToFields(form);
|
|
12563
13735
|
return form;
|
|
12564
13736
|
}
|
|
12565
13737
|
/**
|
|
@@ -12674,6 +13846,11 @@ class RegionFormModal {
|
|
|
12674
13846
|
/**
|
|
12675
13847
|
* Handle style URL changes to auto-detect provider
|
|
12676
13848
|
*/
|
|
13849
|
+
/** Warn when a region would be saved without the style's buildings. */
|
|
13850
|
+
updateBuildingsHint() {
|
|
13851
|
+
const missesBuildings = regionMissesBuildings(this.styleUrlInput?.value ?? '', Number(this.maxZoomInput?.value));
|
|
13852
|
+
this.buildingsHint?.classList.toggle('hidden', !missesBuildings);
|
|
13853
|
+
}
|
|
12677
13854
|
handleStyleUrlChange() {
|
|
12678
13855
|
this.detectProviderFromUrl();
|
|
12679
13856
|
}
|
|
@@ -12750,10 +13927,21 @@ class RegionFormModal {
|
|
|
12750
13927
|
footer.appendChild(saveButton.getElement());
|
|
12751
13928
|
return footer;
|
|
12752
13929
|
}
|
|
13930
|
+
/** Check the zoom fields, showing the browser's message on the first bad one. */
|
|
13931
|
+
zoomRangeIsValid() {
|
|
13932
|
+
const { minZoomInput: min, maxZoomInput: max } = this;
|
|
13933
|
+
if (!min || !max)
|
|
13934
|
+
return true;
|
|
13935
|
+
const reversed = min.value !== '' && max.value !== '' && Number(min.value) > Number(max.value);
|
|
13936
|
+
max.setCustomValidity(reversed ? t('regionForm.zoomRangeError') : '');
|
|
13937
|
+
return min.reportValidity() && max.reportValidity();
|
|
13938
|
+
}
|
|
12753
13939
|
/**
|
|
12754
13940
|
* Handle form save
|
|
12755
13941
|
*/
|
|
12756
13942
|
async handleSave() {
|
|
13943
|
+
if (!this.zoomRangeIsValid())
|
|
13944
|
+
return;
|
|
12757
13945
|
try {
|
|
12758
13946
|
const selectedSources = this.getSelectedExtraSources();
|
|
12759
13947
|
const formData = {
|
|
@@ -12870,14 +14058,14 @@ class RegionControl {
|
|
|
12870
14058
|
this.cancelButton = document.createElement('button');
|
|
12871
14059
|
this.cancelButton.type = 'button';
|
|
12872
14060
|
const prefix = this.options.cssPrefix || 'maplibregl';
|
|
12873
|
-
this.cancelButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-
|
|
14061
|
+
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
14062
|
this.cancelButton.innerHTML = icons.x({ size: 16, color: 'white' });
|
|
12875
14063
|
this.cancelButton.title = 'Cancel Selection';
|
|
12876
14064
|
this.cancelButton.addEventListener('click', () => this.cancelSelection());
|
|
12877
14065
|
// Create save button (green checkmark)
|
|
12878
14066
|
this.saveButton = document.createElement('button');
|
|
12879
14067
|
this.saveButton.type = 'button';
|
|
12880
|
-
this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-
|
|
14068
|
+
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
14069
|
this.saveButton.innerHTML = icons.check({ size: 16, color: 'white' });
|
|
12882
14070
|
this.saveButton.title = 'Save Selected Region';
|
|
12883
14071
|
this.saveButton.addEventListener('click', () => this.handleSaveClick());
|
|
@@ -13107,6 +14295,8 @@ class DownloadManager {
|
|
|
13107
14295
|
offlineManager;
|
|
13108
14296
|
/** Map of region IDs to their download progress */
|
|
13109
14297
|
currentDownloads = new Map();
|
|
14298
|
+
/** Aborts each running download, by region id. */
|
|
14299
|
+
controllers = new Map();
|
|
13110
14300
|
options;
|
|
13111
14301
|
/**
|
|
13112
14302
|
* Create a new DownloadManager instance.
|
|
@@ -13121,14 +14311,16 @@ class DownloadManager {
|
|
|
13121
14311
|
* Calculates percentage and notifies listeners via the onProgressUpdate callback.
|
|
13122
14312
|
* @internal
|
|
13123
14313
|
*/
|
|
13124
|
-
updateProgress(regionId, regionName, phase, completed, total, currentResource) {
|
|
13125
|
-
const percentage =
|
|
14314
|
+
updateProgress(regionId, regionName, phase, completed, total, currentResource, overallPercentage) {
|
|
14315
|
+
const percentage = Math.round(overallPercentage);
|
|
14316
|
+
const phasePercentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
13126
14317
|
this.currentDownloads.set(regionId, {
|
|
13127
14318
|
regionId,
|
|
13128
14319
|
regionName,
|
|
13129
14320
|
completed,
|
|
13130
14321
|
total,
|
|
13131
14322
|
percentage,
|
|
14323
|
+
phasePercentage,
|
|
13132
14324
|
currentResource,
|
|
13133
14325
|
phase,
|
|
13134
14326
|
});
|
|
@@ -13163,14 +14355,20 @@ class DownloadManager {
|
|
|
13163
14355
|
extraSources: formData.extraSources,
|
|
13164
14356
|
};
|
|
13165
14357
|
const regionId = regionConfig.id;
|
|
14358
|
+
const controller = new AbortController();
|
|
14359
|
+
this.controllers.set(regionId, controller);
|
|
13166
14360
|
this.updateUIForDownloadStart();
|
|
13167
14361
|
try {
|
|
13168
14362
|
await this.offlineManager.downloadRegion(regionConfig, {
|
|
13169
14363
|
provider: formData.provider || 'auto',
|
|
13170
14364
|
accessToken: formData.accessToken,
|
|
14365
|
+
signal: controller.signal,
|
|
13171
14366
|
onProgress: progress => {
|
|
14367
|
+
// A cancelled download keeps reporting until it stops; don't show it again.
|
|
14368
|
+
if (controller.signal.aborted)
|
|
14369
|
+
return;
|
|
13172
14370
|
const uiPhase = progress.phase === 'metadata' ? undefined : progress.phase;
|
|
13173
|
-
this.updateProgress(regionId, regionConfig.name, uiPhase, progress.completed, progress.total, progress.message ?? `Downloading ${progress.phase}
|
|
14371
|
+
this.updateProgress(regionId, regionConfig.name, uiPhase, progress.completed, progress.total, progress.message ?? `Downloading ${progress.phase}`, progress.overallPercentage);
|
|
13174
14372
|
},
|
|
13175
14373
|
tileOptions: {
|
|
13176
14374
|
maxConcurrency: 10,
|
|
@@ -13179,8 +14377,16 @@ class DownloadManager {
|
|
|
13179
14377
|
this.handleDownloadComplete(regionId);
|
|
13180
14378
|
}
|
|
13181
14379
|
catch (error) {
|
|
13182
|
-
|
|
13183
|
-
|
|
14380
|
+
if (controller.signal.aborted) {
|
|
14381
|
+
downloadLogger.debug(`Download of ${regionId} was cancelled`);
|
|
14382
|
+
}
|
|
14383
|
+
else {
|
|
14384
|
+
downloadLogger.error('Error downloading region:', error);
|
|
14385
|
+
this.handleDownloadError(regionId, error);
|
|
14386
|
+
}
|
|
14387
|
+
}
|
|
14388
|
+
finally {
|
|
14389
|
+
this.controllers.delete(regionId);
|
|
13184
14390
|
}
|
|
13185
14391
|
}
|
|
13186
14392
|
/**
|
|
@@ -13203,6 +14409,8 @@ class DownloadManager {
|
|
|
13203
14409
|
* @param regionId - The ID of the region download to cancel
|
|
13204
14410
|
*/
|
|
13205
14411
|
cancelDownload(regionId) {
|
|
14412
|
+
// Stops the download; downloadRegion then saves nothing and removes its partial tiles.
|
|
14413
|
+
this.controllers.get(regionId)?.abort();
|
|
13206
14414
|
this.currentDownloads.delete(regionId);
|
|
13207
14415
|
if (this.currentDownloads.size === 0) {
|
|
13208
14416
|
this.resetUI();
|
|
@@ -13214,6 +14422,8 @@ class DownloadManager {
|
|
|
13214
14422
|
* Clears all download tracking and resets the UI.
|
|
13215
14423
|
*/
|
|
13216
14424
|
cancelAllDownloads() {
|
|
14425
|
+
for (const controller of this.controllers.values())
|
|
14426
|
+
controller.abort();
|
|
13217
14427
|
this.currentDownloads.clear();
|
|
13218
14428
|
this.resetUI();
|
|
13219
14429
|
this.options.onProgressUpdate?.(this.currentDownloads);
|
|
@@ -13363,6 +14573,9 @@ class OfflineManagerControl {
|
|
|
13363
14573
|
regionControl;
|
|
13364
14574
|
downloadManager;
|
|
13365
14575
|
modalManager = new ModalManager();
|
|
14576
|
+
/** Percentages of this batch's downloads, finished ones at 100, for the badge. */
|
|
14577
|
+
badgeBatch = new Map();
|
|
14578
|
+
badgePercentage = 0;
|
|
13366
14579
|
/** Whether the bounding box visualization layer has been added to the map */
|
|
13367
14580
|
bboxLayerAdded = false;
|
|
13368
14581
|
/** Map library module for protocol registration (null if not provided) */
|
|
@@ -13417,8 +14630,11 @@ class OfflineManagerControl {
|
|
|
13417
14630
|
this.mapLib.addProtocol('idb', async (params) => {
|
|
13418
14631
|
const response = await idbFetchHandler(params.url);
|
|
13419
14632
|
if (!response.ok) {
|
|
13420
|
-
|
|
13421
|
-
|
|
14633
|
+
// MapLibre treats a 404 as "no tile here", which is normal once the map
|
|
14634
|
+
// moves outside a saved region. Any other error gets logged for every tile.
|
|
14635
|
+
throw Object.assign(new Error(`IDB fetch failed (${response.status}): ${params.url}`), {
|
|
14636
|
+
status: response.status,
|
|
14637
|
+
});
|
|
13422
14638
|
}
|
|
13423
14639
|
// MapLibre's makeRequest bypasses JSON parsing for protocol handlers,
|
|
13424
14640
|
// so we must parse JSON responses ourselves (e.g. sprite atlas, TileJSON).
|
|
@@ -13674,43 +14890,27 @@ class OfflineManagerControl {
|
|
|
13674
14890
|
* Handle progress updates from download manager
|
|
13675
14891
|
*/
|
|
13676
14892
|
handleProgressUpdate(downloads) {
|
|
13677
|
-
//
|
|
13678
|
-
|
|
13679
|
-
|
|
13680
|
-
|
|
13681
|
-
|
|
13682
|
-
|
|
13683
|
-
|
|
13684
|
-
|
|
13685
|
-
|
|
13686
|
-
|
|
13687
|
-
|
|
13688
|
-
|
|
13689
|
-
|
|
13690
|
-
|
|
13691
|
-
|
|
13692
|
-
|
|
13693
|
-
|
|
13694
|
-
|
|
13695
|
-
|
|
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);
|
|
14893
|
+
// The panel has the detail for each phase; the badge only has room for one
|
|
14894
|
+
// figure. With several downloads it averages all of this batch's, counting
|
|
14895
|
+
// finished ones as 100, so one finishing doesn't drop the figure, and never
|
|
14896
|
+
// goes back: a download added mid-batch holds it until the average catches up.
|
|
14897
|
+
for (const [regionId, download] of downloads) {
|
|
14898
|
+
this.badgeBatch.set(regionId, download.percentage);
|
|
14899
|
+
}
|
|
14900
|
+
for (const regionId of this.badgeBatch.keys()) {
|
|
14901
|
+
if (!downloads.has(regionId))
|
|
14902
|
+
this.badgeBatch.set(regionId, 100);
|
|
14903
|
+
}
|
|
14904
|
+
if (downloads.size === 0) {
|
|
14905
|
+
this.badgeBatch.clear();
|
|
14906
|
+
this.badgePercentage = 0;
|
|
14907
|
+
}
|
|
14908
|
+
else {
|
|
14909
|
+
const total = [...this.badgeBatch.values()].reduce((sum, value) => sum + value, 0);
|
|
14910
|
+
this.badgePercentage = Math.max(this.badgePercentage, Math.round(total / this.badgeBatch.size));
|
|
14911
|
+
this.updateProgressBadge(`${this.badgePercentage}%`, true);
|
|
13713
14912
|
}
|
|
14913
|
+
this.panelRenderer?.updateDownloadProgress(downloads);
|
|
13714
14914
|
}
|
|
13715
14915
|
/**
|
|
13716
14916
|
* Handle download completion
|
|
@@ -13726,6 +14926,7 @@ class OfflineManagerControl {
|
|
|
13726
14926
|
*/
|
|
13727
14927
|
handleDownloadError(regionId, error) {
|
|
13728
14928
|
controlLogger.error(`Download error for region ${regionId}:`, error);
|
|
14929
|
+
alert(`${t('alert.downloadFailed')}: ${error instanceof Error ? error.message : t('alert.unknownError')}`);
|
|
13729
14930
|
// Use the panel's refresh method instead of renderPanel to avoid conflicts
|
|
13730
14931
|
if (this.panelRenderer) {
|
|
13731
14932
|
this.panelRenderer.refresh();
|
|
@@ -13869,7 +15070,7 @@ class OfflineManagerControl {
|
|
|
13869
15070
|
type: 'line',
|
|
13870
15071
|
source: sourceId,
|
|
13871
15072
|
paint: {
|
|
13872
|
-
'line-color': '#
|
|
15073
|
+
'line-color': '#A8116F', // Revision magenta, the UI's accent
|
|
13873
15074
|
'line-width': 3,
|
|
13874
15075
|
'line-opacity': 0.8,
|
|
13875
15076
|
},
|
|
@@ -13922,6 +15123,10 @@ class OfflineManagerControl {
|
|
|
13922
15123
|
controlLogger.error(`Style ${styleId} not found in IndexedDB`);
|
|
13923
15124
|
return;
|
|
13924
15125
|
}
|
|
15126
|
+
if (needsMapboxGl(styleEntry.style) && !isMapboxGlMap(this.map)) {
|
|
15127
|
+
controlLogger.error(`Style ${styleId} needs Mapbox GL JS, so this map can't show it`);
|
|
15128
|
+
return;
|
|
15129
|
+
}
|
|
13925
15130
|
// The style is already patched with idb:// URLs by addRegion() in regionService.
|
|
13926
15131
|
// DO NOT call patchStyleForOffline() again — double-patching rewrites the downloadId
|
|
13927
15132
|
// in URLs and can break tile/glyph lookups.
|
|
@@ -13980,9 +15185,9 @@ class OfflineManagerControl {
|
|
|
13980
15185
|
${styles
|
|
13981
15186
|
.map(style => `
|
|
13982
15187
|
<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
|
|
15188
|
+
<div class="font-medium text-gray-900 dark:text-white">${escapeHtml$1(styleDisplayName(style))}</div>
|
|
13984
15189
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
13985
|
-
${style.regions?.length || 0}
|
|
15190
|
+
${regionCountLabel(style.regions?.length || 0)} •
|
|
13986
15191
|
${Object.keys(style.style.sources || {}).length} sources
|
|
13987
15192
|
</div>
|
|
13988
15193
|
</button>
|
|
@@ -14080,6 +15285,52 @@ class OfflineManagerControl {
|
|
|
14080
15285
|
}
|
|
14081
15286
|
}
|
|
14082
15287
|
|
|
15288
|
+
const assetLogger = logger.scope('RuntimeAssets');
|
|
15289
|
+
const ASSET_TIMEOUT_MS = 30000;
|
|
15290
|
+
/**
|
|
15291
|
+
* Cache the code Mapbox GL fetches from api.mapbox.com while rendering: the
|
|
15292
|
+
* Draco and meshopt decoders 3D models need, plus the RTL text and procedural
|
|
15293
|
+
* building modules. The Service Worker serves them back from this cache. They
|
|
15294
|
+
* live in the Cache API rather than IndexedDB because they belong to the
|
|
15295
|
+
* renderer, not to any style or region.
|
|
15296
|
+
*
|
|
15297
|
+
* Failures only log: the rest of a region is still usable without these.
|
|
15298
|
+
*
|
|
15299
|
+
* @returns how many assets are now cached
|
|
15300
|
+
*/
|
|
15301
|
+
async function cacheMapboxRuntimeAssets() {
|
|
15302
|
+
if (typeof caches === 'undefined')
|
|
15303
|
+
return 0;
|
|
15304
|
+
let cache;
|
|
15305
|
+
try {
|
|
15306
|
+
cache = await caches.open(MAPBOX_RUNTIME_CACHE);
|
|
15307
|
+
}
|
|
15308
|
+
catch (error) {
|
|
15309
|
+
assetLogger.warn('Cache API unavailable, skipping runtime assets:', error);
|
|
15310
|
+
return 0;
|
|
15311
|
+
}
|
|
15312
|
+
const results = await Promise.all(MAPBOX_RUNTIME_ASSETS.map(async (path) => {
|
|
15313
|
+
const url = `${MAPBOX_RUNTIME_ORIGIN}${path}`;
|
|
15314
|
+
try {
|
|
15315
|
+
if (await cache.match(url))
|
|
15316
|
+
return true;
|
|
15317
|
+
const response = await fetchWithRetry(url, { retries: 0, timeout: ASSET_TIMEOUT_MS });
|
|
15318
|
+
await cache.put(url, response);
|
|
15319
|
+
return true;
|
|
15320
|
+
}
|
|
15321
|
+
catch (error) {
|
|
15322
|
+
assetLogger.warn(`Failed to cache ${url}:`, error);
|
|
15323
|
+
return false;
|
|
15324
|
+
}
|
|
15325
|
+
}));
|
|
15326
|
+
return results.filter(Boolean).length;
|
|
15327
|
+
}
|
|
15328
|
+
|
|
15329
|
+
var runtimeAssetService = /*#__PURE__*/Object.freeze({
|
|
15330
|
+
__proto__: null,
|
|
15331
|
+
cacheMapboxRuntimeAssets: cacheMapboxRuntimeAssets
|
|
15332
|
+
});
|
|
15333
|
+
|
|
14083
15334
|
exports.AnalyticsService = AnalyticsService;
|
|
14084
15335
|
exports.CONTENT_TYPES = CONTENT_TYPES;
|
|
14085
15336
|
exports.CategorizedError = CategorizedError;
|
|
@@ -14175,6 +15426,7 @@ exports.getSpriteAnalytics = getSpriteAnalytics;
|
|
|
14175
15426
|
exports.getSpriteStats = getSpriteStats;
|
|
14176
15427
|
exports.getSqlJs = getSqlJs;
|
|
14177
15428
|
exports.getStyleStats = getStyleStats;
|
|
15429
|
+
exports.getStyleStorageSizes = getStyleStorageSizes;
|
|
14178
15430
|
exports.getTileAnalytics = getTileAnalytics;
|
|
14179
15431
|
exports.getTileStats = getTileStats;
|
|
14180
15432
|
exports.getUrlHostname = getUrlHostname;
|
|
@@ -14185,6 +15437,8 @@ exports.hostMatches = hostMatches;
|
|
|
14185
15437
|
exports.i18n = i18n;
|
|
14186
15438
|
exports.icons = icons;
|
|
14187
15439
|
exports.idbFetchHandler = idbFetchHandler;
|
|
15440
|
+
exports.injectExtraSources = injectExtraSources;
|
|
15441
|
+
exports.isMapboxGlMap = isMapboxGlMap;
|
|
14188
15442
|
exports.isMapboxHost = isMapboxHost;
|
|
14189
15443
|
exports.isMapboxProtocol = isMapboxProtocol;
|
|
14190
15444
|
exports.isStyleDownloaded = isStyleDownloaded;
|
|
@@ -14195,6 +15449,7 @@ exports.loadStyles = loadStyles;
|
|
|
14195
15449
|
exports.logger = logger;
|
|
14196
15450
|
exports.modelKeyBelongsToStyle = modelKeyBelongsToStyle;
|
|
14197
15451
|
exports.modelService = modelService;
|
|
15452
|
+
exports.needsMapboxGl = needsMapboxGl;
|
|
14198
15453
|
exports.normalizeSpriteProperty = normalizeSpriteProperty;
|
|
14199
15454
|
exports.normalizeStyleUrl = normalizeStyleUrl;
|
|
14200
15455
|
exports.optimizeStorage = optimizeStorage;
|
|
@@ -14209,12 +15464,17 @@ exports.resetOfflineMapDB = resetOfflineMapDB;
|
|
|
14209
15464
|
exports.resolveImports = resolveImports;
|
|
14210
15465
|
exports.resolveMapboxUrl = resolveMapboxUrl;
|
|
14211
15466
|
exports.resourceKeyBelongsToStyle = resourceKeyBelongsToStyle;
|
|
15467
|
+
exports.restoreUnstoredModels = restoreUnstoredModels;
|
|
14212
15468
|
exports.rewriteMapboxCdnTileUrl = rewriteMapboxCdnTileUrl;
|
|
14213
15469
|
exports.safeExecute = safeExecute;
|
|
14214
15470
|
exports.sanitizeIndoorExpressions = sanitizeIndoorExpressions;
|
|
15471
|
+
exports.selectTileJsonVariant = selectTileJsonVariant;
|
|
14215
15472
|
exports.setupAutoCleanup = setupAutoCleanup;
|
|
14216
15473
|
exports.spriteService = spriteService;
|
|
15474
|
+
exports.stashOriginalModels = stashOriginalModels;
|
|
14217
15475
|
exports.stopAutoCleanup = stopAutoCleanup;
|
|
15476
|
+
exports.storedModelNames = storedModelNames;
|
|
15477
|
+
exports.styleDisplayName = styleDisplayName;
|
|
14218
15478
|
exports.t = t;
|
|
14219
15479
|
exports.tileService = tileService;
|
|
14220
15480
|
exports.unregisterOfflineServiceWorker = unregisterOfflineServiceWorker;
|