map-gl-offline 0.8.8 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -191
- package/dist/idb-offline-sw.js +78 -11
- package/dist/index.d.ts +3 -3
- package/dist/index.esm.js +2065 -809
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +2072 -807
- package/dist/index.js.map +1 -1
- package/dist/index.umd.js +9502 -711
- 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/font.d.ts +5 -1
- package/dist/types/import-export.d.ts +5 -0
- package/dist/types/maintenance.d.ts +3 -1
- package/dist/types/region.d.ts +29 -2
- package/dist/types/style.d.ts +4 -0
- package/dist/types/tile.d.ts +31 -10
- 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 +15 -1
- package/dist/ui/translations/en.d.ts +15 -1
- package/dist/ui/translations/index.d.ts +2 -0
- package/dist/utils/constants.d.ts +10 -3
- 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/sqlJsLoader.d.ts +1 -2
- 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 +45 -40
package/dist/index.esm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { openDB } from 'idb';
|
|
1
|
+
import { openDB, unwrap } from 'idb';
|
|
2
2
|
import * as tilebelt from '@mapbox/tilebelt';
|
|
3
3
|
import i18next from 'i18next';
|
|
4
4
|
import { area } from '@turf/area';
|
|
@@ -163,18 +163,25 @@ const MAPBOX_API = {
|
|
|
163
163
|
/**
|
|
164
164
|
* Mapbox Standard sub-tilesets that are sparse-by-design across the planet.
|
|
165
165
|
* These only have tiles where the underlying feature (indoor venues, landmark
|
|
166
|
-
* POIs, 3D procedural buildings) actually exists, and return 404 for
|
|
167
|
-
*
|
|
166
|
+
* POIs, 3D procedural buildings) actually exists, and return 404 for every other
|
|
167
|
+
* coordinate, though they can be dense inside a major city. Unless the style's
|
|
168
|
+
* config has switched on a feature that needs one, the tile downloader pre-skips
|
|
169
|
+
* any source whose tile
|
|
168
170
|
* URL templates reference one of these tileset IDs, so we never issue probe
|
|
169
171
|
* or download requests for them — eliminating the 404 noise in devtools.
|
|
170
172
|
*
|
|
171
173
|
* Matching is done against the tileset segment of the tile URL template
|
|
172
174
|
* (e.g. `https://api.mapbox.com/v4/mapbox.indoor-v3/{z}/{x}/{y}.vector.pbf`
|
|
173
175
|
* or `mapbox://mapbox.indoor-v3`).
|
|
176
|
+
*
|
|
177
|
+
* The ids below are the ones the live Mapbox Standard style declares — note
|
|
178
|
+
* that the landmark-POI tileset is `mapbox.mapbox-landmark-pois-v1` (doubled
|
|
179
|
+
* `mapbox` segment), unlike the other two. The matcher is delimiter-anchored,
|
|
180
|
+
* so an approximate id silently never matches.
|
|
174
181
|
*/
|
|
175
182
|
const MAPBOX_STANDARD_SPARSE_TILESETS = [
|
|
176
183
|
'mapbox.indoor-v3',
|
|
177
|
-
'mapbox.landmark-pois-v1',
|
|
184
|
+
'mapbox.mapbox-landmark-pois-v1',
|
|
178
185
|
'mapbox.procedural-buildings-v1',
|
|
179
186
|
];
|
|
180
187
|
// Map Providers
|
|
@@ -361,7 +368,10 @@ async function openOfflineMapDB() {
|
|
|
361
368
|
// Migration: v2 -> v3
|
|
362
369
|
// Move regions from 'regions' store to styles.regions[]
|
|
363
370
|
if (oldVersion > 0 && oldVersion < 3) {
|
|
364
|
-
|
|
371
|
+
// Unwrap to the raw IDBTransaction: on idb's wrapper, getAll()/get()
|
|
372
|
+
// return Promises, so the onsuccess callbacks the migration relies
|
|
373
|
+
// on would never fire and no region would be migrated.
|
|
374
|
+
migrateRegionsToStyles(unwrap(transaction));
|
|
365
375
|
}
|
|
366
376
|
// Migration: v3 -> v4
|
|
367
377
|
// Adds the `models` store for Mapbox Standard 3D model assets.
|
|
@@ -1022,6 +1032,20 @@ function formatBytes(bytes) {
|
|
|
1022
1032
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
1023
1033
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
1024
1034
|
}
|
|
1035
|
+
/**
|
|
1036
|
+
* A stored style's display name: its own `name`, or else the last meaningful
|
|
1037
|
+
* part of its URL ("liberty" for `.../styles/liberty`), or else its key.
|
|
1038
|
+
*/
|
|
1039
|
+
function styleDisplayName(entry) {
|
|
1040
|
+
if (entry.style?.name)
|
|
1041
|
+
return entry.style.name;
|
|
1042
|
+
const path = (entry.originalUrl ?? '').split(/[?#]/)[0];
|
|
1043
|
+
const segments = path
|
|
1044
|
+
.split('/')
|
|
1045
|
+
.map(segment => segment.replace(/\.json$/i, ''))
|
|
1046
|
+
.filter(segment => segment && !/^(style|styles)$/i.test(segment) && !segment.includes(':'));
|
|
1047
|
+
return segments.at(-1) ?? entry.key;
|
|
1048
|
+
}
|
|
1025
1049
|
/**
|
|
1026
1050
|
* Format date to human readable string
|
|
1027
1051
|
*/
|
|
@@ -1249,6 +1273,39 @@ function deriveTileExtension(tiles) {
|
|
|
1249
1273
|
* They have different shapes so cannot be shared; the key computation
|
|
1250
1274
|
* can be and is.
|
|
1251
1275
|
*/
|
|
1276
|
+
/**
|
|
1277
|
+
* `decodeURIComponent` that returns the input unchanged instead of throwing
|
|
1278
|
+
* on a malformed escape (e.g. a literal `%` in a font or source name), so a
|
|
1279
|
+
* bad path becomes a lookup miss / 404 rather than a rejected fetch.
|
|
1280
|
+
*/
|
|
1281
|
+
function safeDecodeURIComponent(value) {
|
|
1282
|
+
try {
|
|
1283
|
+
return decodeURIComponent(value);
|
|
1284
|
+
}
|
|
1285
|
+
catch {
|
|
1286
|
+
return value;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
// ---------------------------------------------------------------------------
|
|
1290
|
+
// Mapbox GL runtime assets
|
|
1291
|
+
// ---------------------------------------------------------------------------
|
|
1292
|
+
const MAPBOX_RUNTIME_ORIGIN = 'https://api.mapbox.com';
|
|
1293
|
+
const MAPBOX_RUNTIME_CACHE = 'map-gl-offline-runtime-v1';
|
|
1294
|
+
/**
|
|
1295
|
+
* Code Mapbox GL fetches from api.mapbox.com while rendering, so it has to be
|
|
1296
|
+
* available offline too. Paths are versioned, so a renderer that moves to a new
|
|
1297
|
+
* version misses here; `npm run check:mapbox-standard` compares this list with
|
|
1298
|
+
* the installed renderer. They match Mapbox GL 3.34; an app on a version with
|
|
1299
|
+
* other paths gets no offline copy of those files.
|
|
1300
|
+
*/
|
|
1301
|
+
const MAPBOX_RUNTIME_ASSETS = [
|
|
1302
|
+
'/mapbox-gl-js/draco_decoder_gltf_v1.5.6.wasm',
|
|
1303
|
+
'/mapbox-gl-js/meshopt_base_v1.2.wasm',
|
|
1304
|
+
'/mapbox-gl-js/meshopt_simd_v1.2.wasm',
|
|
1305
|
+
'/mapbox-gl-js/rtl_text_v1.0.0.wasm',
|
|
1306
|
+
'/mapbox-gl-js/building-gen/building_gen_v1.2.6.wasm',
|
|
1307
|
+
'/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.3.0/mapbox-gl-rtl-text.js',
|
|
1308
|
+
];
|
|
1252
1309
|
/**
|
|
1253
1310
|
* Extensions to try in order when the requested extension misses. `glb` is
|
|
1254
1311
|
* last so batched-model sources (Mapbox Standard 3D buildings) resolve when
|
|
@@ -1286,9 +1343,10 @@ function findStyleByRegionIdIn(styles, regionId) {
|
|
|
1286
1343
|
// Glyph candidate keys
|
|
1287
1344
|
// ---------------------------------------------------------------------------
|
|
1288
1345
|
/**
|
|
1289
|
-
* Parse `FontA,FontB,FontC/0-255.pbf` into (fontstacks, rangePart). Mapbox
|
|
1290
|
-
* requests a comma-joined font-family fallback chain
|
|
1291
|
-
*
|
|
1346
|
+
* Parse `FontA,FontB,FontC/0-255.pbf` into (fontstacks, rangePart). Older Mapbox
|
|
1347
|
+
* GL requests a comma-joined font-family fallback chain (current versions fetch
|
|
1348
|
+
* each font and merge them client-side); each glyph is stored individually, so
|
|
1349
|
+
* the caller tries each fontstack in order.
|
|
1292
1350
|
*/
|
|
1293
1351
|
function parseGlyphPath(decodedPath) {
|
|
1294
1352
|
const pathParts = decodedPath.split('/');
|
|
@@ -1300,6 +1358,47 @@ function parseGlyphPath(decodedPath) {
|
|
|
1300
1358
|
.filter(Boolean);
|
|
1301
1359
|
return { fontstacks, rangePart };
|
|
1302
1360
|
}
|
|
1361
|
+
function readVarint(bytes, start) {
|
|
1362
|
+
let value = 0;
|
|
1363
|
+
let multiplier = 1;
|
|
1364
|
+
for (let pos = start; pos < bytes.length && pos < start + 5; pos++) {
|
|
1365
|
+
value += (bytes[pos] & 0x7f) * multiplier;
|
|
1366
|
+
if (bytes[pos] < 0x80)
|
|
1367
|
+
return { value, next: pos + 1 };
|
|
1368
|
+
multiplier *= 128;
|
|
1369
|
+
}
|
|
1370
|
+
return null;
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* True for a glyph range PBF holding only its font name and range. Servers
|
|
1374
|
+
* return these for blocks a font doesn't cover, e.g. `DIN Pro Medium/1536-1791`.
|
|
1375
|
+
* Serving one in place of a later font in the chain blanks those labels.
|
|
1376
|
+
*
|
|
1377
|
+
* Reads `glyphs { fontstack stacks = 1 { name = 1; range = 2; glyph = 3 } }`.
|
|
1378
|
+
* Anything unexpected counts as not empty, so unknown data is still served.
|
|
1379
|
+
*/
|
|
1380
|
+
function isEmptyGlyphRange(data) {
|
|
1381
|
+
const bytes = new Uint8Array(data);
|
|
1382
|
+
if (bytes[0] !== 0x0a)
|
|
1383
|
+
return false;
|
|
1384
|
+
const stack = readVarint(bytes, 1);
|
|
1385
|
+
if (!stack)
|
|
1386
|
+
return false;
|
|
1387
|
+
const end = stack.next + stack.value;
|
|
1388
|
+
if (end !== bytes.length)
|
|
1389
|
+
return false;
|
|
1390
|
+
let pos = stack.next;
|
|
1391
|
+
while (pos < end) {
|
|
1392
|
+
const tag = bytes[pos];
|
|
1393
|
+
if ((tag & 0x07) !== 2 || tag >> 3 === 3)
|
|
1394
|
+
return false;
|
|
1395
|
+
const length = readVarint(bytes, pos + 1);
|
|
1396
|
+
if (!length)
|
|
1397
|
+
return false;
|
|
1398
|
+
pos = length.next + length.value;
|
|
1399
|
+
}
|
|
1400
|
+
return true;
|
|
1401
|
+
}
|
|
1303
1402
|
/**
|
|
1304
1403
|
* Build the list of keys to try for a single (fontstack, range) pair.
|
|
1305
1404
|
* Order: actualStyleId variants first (most common), then downloadId,
|
|
@@ -1470,10 +1569,10 @@ function clearAllCaches() {
|
|
|
1470
1569
|
tileCache.clear();
|
|
1471
1570
|
}
|
|
1472
1571
|
/**
|
|
1473
|
-
*
|
|
1474
|
-
*
|
|
1475
|
-
*
|
|
1476
|
-
* Results are cached to avoid repeated DB queries during tile fetching
|
|
1572
|
+
* Resolve the `{id}` segment of an `idb://{id}/...` URL to its style entry.
|
|
1573
|
+
* Styles patched since URLs became style-keyed use the style key itself (one
|
|
1574
|
+
* keyed read); older stored styles use a region id, which needs a scan of all
|
|
1575
|
+
* styles. Results are cached to avoid repeated DB queries during tile fetching.
|
|
1477
1576
|
*/
|
|
1478
1577
|
async function findStyleByRegionId(db, regionId) {
|
|
1479
1578
|
// Check cache first
|
|
@@ -1482,6 +1581,11 @@ async function findStyleByRegionId(db, regionId) {
|
|
|
1482
1581
|
return cached.styleEntry;
|
|
1483
1582
|
}
|
|
1484
1583
|
try {
|
|
1584
|
+
const direct = (await db.get('styles', regionId));
|
|
1585
|
+
if (direct) {
|
|
1586
|
+
regionToStyleCache.set(regionId, { styleEntry: direct, timestamp: Date.now() });
|
|
1587
|
+
return direct;
|
|
1588
|
+
}
|
|
1485
1589
|
const allStyles = await db.getAll('styles');
|
|
1486
1590
|
const hit = findStyleByRegionIdIn(allStyles, regionId);
|
|
1487
1591
|
if (hit) {
|
|
@@ -1571,9 +1675,11 @@ async function idbFetchHandler(url, init) {
|
|
|
1571
1675
|
}
|
|
1572
1676
|
const db = await dbPromise;
|
|
1573
1677
|
const parsed = url.replace('idb://', '').split('/');
|
|
1574
|
-
|
|
1678
|
+
// A Request's URL arrives percent-encoded, so decode the style id as the Service Worker does.
|
|
1679
|
+
const [encodedDownloadId, type, ...rest] = parsed;
|
|
1680
|
+
const downloadId = safeDecodeURIComponent(encodedDownloadId ?? '');
|
|
1575
1681
|
const resourcePath = rest.join('/');
|
|
1576
|
-
const decodedResourcePath =
|
|
1682
|
+
const decodedResourcePath = safeDecodeURIComponent(resourcePath);
|
|
1577
1683
|
const key = `${downloadId}::${decodedResourcePath}`;
|
|
1578
1684
|
try {
|
|
1579
1685
|
switch (type) {
|
|
@@ -1592,7 +1698,7 @@ async function idbFetchHandler(url, init) {
|
|
|
1592
1698
|
// MapLibre can request fractional zoom levels (e.g., 12.5)
|
|
1593
1699
|
// but tiles are stored with integer zoom levels, so floor the value
|
|
1594
1700
|
const z = Math.floor(parseFloat(pathParts[pathParts.length - 3]));
|
|
1595
|
-
const sourceKey = pathParts.slice(0, pathParts.length - 3).join('/');
|
|
1701
|
+
const sourceKey = safeDecodeURIComponent(pathParts.slice(0, pathParts.length - 3).join('/'));
|
|
1596
1702
|
const yMatch = yExt.match(/^(\d+)\.(\w+)$/);
|
|
1597
1703
|
if (yMatch) {
|
|
1598
1704
|
const y = parseInt(yMatch[1]);
|
|
@@ -1630,7 +1736,7 @@ async function idbFetchHandler(url, init) {
|
|
|
1630
1736
|
if (pathParts.length === 1) {
|
|
1631
1737
|
// Old format without sourceKey - try to extract from the URL
|
|
1632
1738
|
const encodedTileUrl = pathParts[0];
|
|
1633
|
-
const tileUrl =
|
|
1739
|
+
const tileUrl = safeDecodeURIComponent(encodedTileUrl);
|
|
1634
1740
|
// Try to extract source from URL pattern
|
|
1635
1741
|
// For example: https://domain.com/service/source/vt/{z}/{x}/{y}.pbf
|
|
1636
1742
|
const urlParts = tileUrl.split('/');
|
|
@@ -1674,21 +1780,30 @@ async function idbFetchHandler(url, init) {
|
|
|
1674
1780
|
const actualStyleId = styleEntry?.key || downloadId;
|
|
1675
1781
|
const { fontstacks, rangePart } = parseGlyphPath(decodedResourcePath);
|
|
1676
1782
|
idbLogger.debug(`Trying ${fontstacks.length} fonts in fallback order: ${fontstacks.join(', ')}`);
|
|
1783
|
+
const glyphResponse = (data) => new Response(data, {
|
|
1784
|
+
status: 200,
|
|
1785
|
+
headers: { 'Content-Type': 'application/x-protobuf' },
|
|
1786
|
+
});
|
|
1787
|
+
let emptyRange;
|
|
1677
1788
|
for (const fontstack of fontstacks) {
|
|
1678
1789
|
const candidateKeys = glyphCandidateKeys(actualStyleId, downloadId, fontstack, rangePart);
|
|
1679
1790
|
for (const candidateKey of candidateKeys) {
|
|
1680
1791
|
const resource = await db.get('glyphs', candidateKey);
|
|
1681
1792
|
if (resource?.data) {
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1793
|
+
if (!isEmptyGlyphRange(resource.data)) {
|
|
1794
|
+
idbLogger.debug(`Found glyph using key: ${candidateKey} (font: ${fontstack})`);
|
|
1795
|
+
return glyphResponse(resource.data);
|
|
1796
|
+
}
|
|
1797
|
+
emptyRange ??= resource.data;
|
|
1687
1798
|
}
|
|
1688
1799
|
}
|
|
1689
1800
|
}
|
|
1690
|
-
|
|
1691
|
-
|
|
1801
|
+
// Never a 404: Mapbox GL fails a tile's whole glyph request if one range
|
|
1802
|
+
// errors, so a range outside the downloaded set would blank all its labels.
|
|
1803
|
+
if (!emptyRange) {
|
|
1804
|
+
idbLogger.warn(`Glyph not found for any font in: ${fontstacks.join(', ')}`);
|
|
1805
|
+
}
|
|
1806
|
+
return glyphResponse(emptyRange ?? new ArrayBuffer(0));
|
|
1692
1807
|
}
|
|
1693
1808
|
case 'sprite': {
|
|
1694
1809
|
idbLogger.debug(`Looking for sprite with key: ${key}`);
|
|
@@ -1780,7 +1895,8 @@ async function idbFetchHandler(url, init) {
|
|
|
1780
1895
|
catch (error) {
|
|
1781
1896
|
idbLogger.error(`Error fetching resource from IDB: ${url}`, error);
|
|
1782
1897
|
}
|
|
1783
|
-
|
|
1898
|
+
// Normal once the map moves outside a saved region, so not worth a warning per tile.
|
|
1899
|
+
idbLogger.debug(`Resource not found in IDB: ${url}`);
|
|
1784
1900
|
return new Response('Not found in IDB', { status: 404 });
|
|
1785
1901
|
}
|
|
1786
1902
|
|
|
@@ -1841,57 +1957,103 @@ function resolveMapboxUrl(mapboxUrl, accessToken) {
|
|
|
1841
1957
|
if (!accessToken) {
|
|
1842
1958
|
throw new Error(`Mapbox access token is required to resolve mapbox:// URL: ${mapboxUrl}`);
|
|
1843
1959
|
}
|
|
1844
|
-
// Strip the protocol prefix
|
|
1845
|
-
|
|
1960
|
+
// Strip the protocol prefix, and split off any query so path matching and
|
|
1961
|
+
// the resolved URL's suffixes (`.json`, `/sprite`) go before it. Existing
|
|
1962
|
+
// parameters are kept; `access_token` is set exactly once.
|
|
1963
|
+
const withoutProtocol = mapboxUrl.slice(MAPBOX_API.PROTOCOL.length);
|
|
1964
|
+
const queryIndex = withoutProtocol.indexOf('?');
|
|
1965
|
+
const path = queryIndex === -1 ? withoutProtocol : withoutProtocol.slice(0, queryIndex);
|
|
1966
|
+
const params = new URLSearchParams(queryIndex === -1 ? '' : withoutProtocol.slice(queryIndex + 1));
|
|
1967
|
+
params.set('access_token', accessToken);
|
|
1968
|
+
const query = `?${params.toString()}`;
|
|
1846
1969
|
// mapbox://styles/{user}/{id}
|
|
1847
1970
|
if (path.startsWith('styles/')) {
|
|
1848
1971
|
const rest = path.slice('styles/'.length);
|
|
1849
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}
|
|
1972
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}${query}`;
|
|
1850
1973
|
}
|
|
1851
1974
|
// mapbox://sprites/{user}/{id}
|
|
1852
1975
|
if (path.startsWith('sprites/')) {
|
|
1853
1976
|
const rest = path.slice('sprites/'.length);
|
|
1854
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}/sprite
|
|
1977
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}/sprite${query}`;
|
|
1855
1978
|
}
|
|
1856
1979
|
// mapbox://fonts/{user}/{fontstack}/{range}.pbf
|
|
1857
1980
|
if (path.startsWith('fonts/')) {
|
|
1858
1981
|
const rest = path.slice('fonts/'.length);
|
|
1859
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.FONTS_PATH}/${rest}
|
|
1982
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.FONTS_PATH}/${rest}${query}`;
|
|
1860
1983
|
}
|
|
1861
1984
|
// mapbox://models/{path}
|
|
1862
1985
|
if (path.startsWith('models/')) {
|
|
1863
1986
|
const rest = path.slice('models/'.length);
|
|
1864
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.MODELS_PATH}/${rest}
|
|
1987
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.MODELS_PATH}/${rest}${query}`;
|
|
1865
1988
|
}
|
|
1866
1989
|
// mapbox://{tileset} (e.g. mapbox://mapbox.mapbox-streets-v8)
|
|
1867
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${path}.json
|
|
1990
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${path}.json${query}`;
|
|
1868
1991
|
}
|
|
1869
1992
|
/**
|
|
1870
|
-
*
|
|
1993
|
+
* Pick the TileJSON variant Mapbox GL renders from, the way its `mergeVariants`
|
|
1994
|
+
* does: `meshopt-v2+lod`, then `meshopt`, then the base tileset.
|
|
1871
1995
|
*
|
|
1872
|
-
*
|
|
1873
|
-
*
|
|
1874
|
-
*
|
|
1875
|
-
*
|
|
1876
|
-
*
|
|
1996
|
+
* These aren't just different encodings of one dataset. For
|
|
1997
|
+
* `mapbox.mapbox-3dbuildings-v1`, the base tile over central Dubai is empty while
|
|
1998
|
+
* the variants hold its 3D buildings, so downloading the base loses them offline.
|
|
1999
|
+
* Variant tiles are `mapbox://3dtiles/...` URLs, resolved here using the base
|
|
2000
|
+
* URL's query string, which carries the access token.
|
|
2001
|
+
*/
|
|
2002
|
+
function selectTileJsonVariant(tileJson) {
|
|
2003
|
+
const { variants, tiles: baseTiles } = tileJson;
|
|
2004
|
+
if (!Array.isArray(variants))
|
|
2005
|
+
return tileJson;
|
|
2006
|
+
const withCapability = (capability) => variants.find(variant => Array.isArray(variant?.capabilities) &&
|
|
2007
|
+
variant.capabilities.length === 1 &&
|
|
2008
|
+
variant.capabilities[0] === capability);
|
|
2009
|
+
const selected = withCapability('meshopt-v2+lod') ?? withCapability('meshopt');
|
|
2010
|
+
if (!selected || !Array.isArray(selected.tiles))
|
|
2011
|
+
return tileJson;
|
|
2012
|
+
const firstBaseTile = Array.isArray(baseTiles) ? String(baseTiles[0] ?? '') : '';
|
|
2013
|
+
const queryIndex = firstBaseTile.indexOf('?');
|
|
2014
|
+
const query = queryIndex === -1 ? '' : firstBaseTile.slice(queryIndex);
|
|
2015
|
+
const tiles = selected.tiles.map(tile => tile.startsWith('mapbox://3dtiles/')
|
|
2016
|
+
? `${MAPBOX_API.BASE_URL}/3dtiles/v1/${tile.slice('mapbox://3dtiles/'.length)}${query}`
|
|
2017
|
+
: tile);
|
|
2018
|
+
return { ...tileJson, ...selected, tiles };
|
|
2019
|
+
}
|
|
2020
|
+
const SKU_LIFETIME_MS = 12 * 60 * 60 * 1000;
|
|
2021
|
+
const SKU_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
2022
|
+
let skuSession;
|
|
2023
|
+
/** A Mapbox session token in the format Mapbox GL JS generates: version + SKU id + 10 random. */
|
|
2024
|
+
function getSkuToken() {
|
|
2025
|
+
if (!skuSession || Date.now() > skuSession.expiresAt) {
|
|
2026
|
+
let randomizer = '';
|
|
2027
|
+
for (let i = 0; i < 10; i++) {
|
|
2028
|
+
randomizer += SKU_CHARS[Math.floor(Math.random() * SKU_CHARS.length)];
|
|
2029
|
+
}
|
|
2030
|
+
skuSession = { token: `101${randomizer}`, expiresAt: Date.now() + SKU_LIFETIME_MS };
|
|
2031
|
+
}
|
|
2032
|
+
return skuSession.token;
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Add the `sku` session token that Mapbox `/raster/v1/` tile URLs need.
|
|
2036
|
+
*
|
|
2037
|
+
* TileJSON returns these URLs without one, and without it they 401. Mapbox GL
|
|
2038
|
+
* adds it at request time, but this library fetches tiles itself.
|
|
2039
|
+
*
|
|
2040
|
+
* Don't switch these to the `/v4/` endpoint, which needs no token: it serves a
|
|
2041
|
+
* different tile. For `mapbox.mapbox-terrain-dem-v1`, `/v4/….png` is a 256px
|
|
2042
|
+
* palette PNG with elevations off by up to ~200 m, while the renderer expects
|
|
2043
|
+
* the 514px RGB tile (`tileSize: 514`) that `/raster/v1/` returns.
|
|
1877
2044
|
*
|
|
1878
2045
|
* Example:
|
|
1879
2046
|
* https://a.tiles.mapbox.com/raster/v1/mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png?access_token=...
|
|
1880
|
-
* -> https://
|
|
2047
|
+
* -> https://a.tiles.mapbox.com/raster/v1/mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png?access_token=...&sku=101...
|
|
1881
2048
|
*/
|
|
1882
2049
|
function rewriteMapboxCdnTileUrl(tileUrl) {
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
const match = tileUrl.match(/^https?:\/\/[a-z]\.tiles\.mapbox\.com\/raster\/v1\/([^?]+)(\?.*)?$/);
|
|
1888
|
-
if (!match) {
|
|
2050
|
+
if (!/^https?:\/\/(?:[a-z]\.tiles\.mapbox\.com|api\.mapbox\.com)\/raster\/v1\//.test(tileUrl)) {
|
|
2051
|
+
return tileUrl;
|
|
2052
|
+
}
|
|
2053
|
+
if (/[?&]sku=/.test(tileUrl)) {
|
|
1889
2054
|
return tileUrl;
|
|
1890
2055
|
}
|
|
1891
|
-
|
|
1892
|
-
const tilesetAndPath = match[1];
|
|
1893
|
-
const queryString = match[2] || '';
|
|
1894
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${tilesetAndPath}${queryString}`;
|
|
2056
|
+
return `${tileUrl}${tileUrl.includes('?') ? '&' : '?'}sku=${getSkuToken()}`;
|
|
1895
2057
|
}
|
|
1896
2058
|
/**
|
|
1897
2059
|
* Detect the style provider based on the style URL or content
|
|
@@ -2117,24 +2279,100 @@ function validateStyleForProvider(style, provider) {
|
|
|
2117
2279
|
|
|
2118
2280
|
const styleLogger = logger.scope('StyleUtils');
|
|
2119
2281
|
/**
|
|
2120
|
-
* Patches a MapboxStyle for offline use by replacing URLs with IndexedDB references
|
|
2282
|
+
* Patches a MapboxStyle for offline use by replacing URLs with IndexedDB references.
|
|
2283
|
+
*
|
|
2284
|
+
* Safe to call repeatedly on the same stored style (every `addRegion` does):
|
|
2285
|
+
* the first call stashes each source's upstream `tiles` / `maxzoom` under
|
|
2286
|
+
* `__originalTiles` / `__originalMaxzoom`, and later calls patch from those,
|
|
2287
|
+
* so the download pipeline can still reach the network and `maxzoom` can grow
|
|
2288
|
+
* again when a deeper region is added.
|
|
2289
|
+
*
|
|
2121
2290
|
* @param style - The style to patch
|
|
2122
|
-
* @param downloadId -
|
|
2123
|
-
* @param maxZoom -
|
|
2291
|
+
* @param downloadId - Fallback ID for `idb://` URLs when `styleId` is omitted
|
|
2292
|
+
* @param maxZoom - Max zoom downloaded for this style (the highest across its regions)
|
|
2124
2293
|
* @param tileExtension - Optional tile extension
|
|
2125
|
-
* @param styleId -
|
|
2126
|
-
*
|
|
2127
|
-
*
|
|
2294
|
+
* @param styleId - Style ID used in all `idb://` URLs. Tiles, glyphs, sprites and
|
|
2295
|
+
* models are stored per style, so URLs keyed by style keep working
|
|
2296
|
+
* when any one region is deleted.
|
|
2297
|
+
*/
|
|
2298
|
+
/**
|
|
2299
|
+
* Add a region's extra tile sources to a style, unless one with that id is
|
|
2300
|
+
* already there. Sources must be in the style before it's patched for offline use.
|
|
2301
|
+
*/
|
|
2302
|
+
function injectExtraSources(style, extraSources = []) {
|
|
2303
|
+
for (const extra of extraSources) {
|
|
2304
|
+
if (style.sources[extra.id])
|
|
2305
|
+
continue;
|
|
2306
|
+
style.sources[extra.id] = {
|
|
2307
|
+
type: extra.type || 'vector',
|
|
2308
|
+
tiles: extra.tiles,
|
|
2309
|
+
...(extra.minzoom !== undefined ? { minzoom: extra.minzoom } : {}),
|
|
2310
|
+
...(extra.maxzoom !== undefined ? { maxzoom: extra.maxzoom } : {}),
|
|
2311
|
+
...(extra.attribution ? { attribution: extra.attribution } : {}),
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
/**
|
|
2316
|
+
* Keep each model's upstream URL before the style is patched, the first time
|
|
2317
|
+
* only. Patching points every model at `idb://`, so without this a model that
|
|
2318
|
+
* failed to download couldn't be fetched again.
|
|
2319
|
+
*/
|
|
2320
|
+
function stashOriginalModels(entry) {
|
|
2321
|
+
const models = entry.style.models;
|
|
2322
|
+
if (!models || entry.originalModels)
|
|
2323
|
+
return;
|
|
2324
|
+
const isPatched = (value) => String(typeof value === 'string' ? value : value?.uri).startsWith('idb://');
|
|
2325
|
+
// A style patched before this stash existed has no upstream URLs left to keep.
|
|
2326
|
+
if (Object.values(models).some(isPatched))
|
|
2327
|
+
return;
|
|
2328
|
+
entry.originalModels = JSON.parse(JSON.stringify(models));
|
|
2329
|
+
}
|
|
2330
|
+
/**
|
|
2331
|
+
* Point models that aren't stored back at their upstream URLs. Run after
|
|
2332
|
+
* patching, which points every model at `idb://`.
|
|
2333
|
+
*/
|
|
2334
|
+
function restoreUnstoredModels(entry, storedNames) {
|
|
2335
|
+
const models = entry.style.models;
|
|
2336
|
+
if (!models || !entry.originalModels)
|
|
2337
|
+
return;
|
|
2338
|
+
for (const name of Object.keys(models)) {
|
|
2339
|
+
if (!storedNames.has(name) && entry.originalModels[name] !== undefined) {
|
|
2340
|
+
models[name] = JSON.parse(JSON.stringify(entry.originalModels[name]));
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
/**
|
|
2345
|
+
* Whether a style uses parts of the spec only Mapbox GL JS v3 draws: config
|
|
2346
|
+
* (`schema`), 3D `models`, `lights` or `imports`. MapLibre rejects these.
|
|
2128
2347
|
*/
|
|
2348
|
+
function needsMapboxGl(style) {
|
|
2349
|
+
const parts = style;
|
|
2350
|
+
return Boolean(parts.schema || parts.models || parts.lights || parts.imports);
|
|
2351
|
+
}
|
|
2352
|
+
/** Whether a map instance is Mapbox GL JS v3, the only renderer with style config. */
|
|
2353
|
+
function isMapboxGlMap(map) {
|
|
2354
|
+
return typeof map?.setConfigProperty === 'function';
|
|
2355
|
+
}
|
|
2129
2356
|
function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId) {
|
|
2130
2357
|
styleLogger.debug(`Patching style for offline use with downloadId: ${downloadId}, maxZoom: ${maxZoom}, tileExtension: ${tileExtension}`);
|
|
2131
2358
|
styleLogger.debug(`Original style:`, style);
|
|
2359
|
+
const urlId = styleId || downloadId;
|
|
2132
2360
|
// Patch sources
|
|
2133
2361
|
for (const sourceKey in style.sources) {
|
|
2134
2362
|
const source = style.sources[sourceKey];
|
|
2135
2363
|
styleLogger.debug(`Patching source: ${sourceKey}`, source);
|
|
2364
|
+
// Stash upstream values once. Already-patched tiles (all idb://) are never
|
|
2365
|
+
// stashed as "original"; `null` records that the source had no maxzoom.
|
|
2366
|
+
if (source.tiles &&
|
|
2367
|
+
source.__originalTiles === undefined &&
|
|
2368
|
+
!source.tiles.every(url => url.startsWith('idb://'))) {
|
|
2369
|
+
source.__originalTiles = [...source.tiles];
|
|
2370
|
+
}
|
|
2371
|
+
if (source.__originalMaxzoom === undefined) {
|
|
2372
|
+
source.__originalMaxzoom = source.maxzoom ?? null;
|
|
2373
|
+
}
|
|
2136
2374
|
if (source.tiles) {
|
|
2137
|
-
const originalTiles = [...source.tiles];
|
|
2375
|
+
const originalTiles = source.__originalTiles ?? [...source.tiles];
|
|
2138
2376
|
// Patch to idb://{downloadId}/tile/{sourceKey}/{z}/{x}/{y}.ext.
|
|
2139
2377
|
// Extension extraction goes through the shared extractTileExtensionFromUrl
|
|
2140
2378
|
// helper so the patched URL's extension matches what tileService used when
|
|
@@ -2142,9 +2380,9 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2142
2380
|
// stored key under `.pbf` but a patched URL with `.vector`, forcing
|
|
2143
2381
|
// idbFetchHandler to fall through its pbf/mvt/png/jpg/webp fallback loop
|
|
2144
2382
|
// on every tile.
|
|
2145
|
-
source.tiles =
|
|
2383
|
+
source.tiles = originalTiles.map((url) => {
|
|
2146
2384
|
const ext = tileExtension ?? extractTileExtensionFromUrl(url);
|
|
2147
|
-
return `idb://${
|
|
2385
|
+
return `idb://${urlId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`;
|
|
2148
2386
|
});
|
|
2149
2387
|
styleLogger.debug(`Patched tiles for ${sourceKey} with extension .${tileExtension || 'pbf'}:`, {
|
|
2150
2388
|
original: originalTiles,
|
|
@@ -2152,10 +2390,12 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2152
2390
|
});
|
|
2153
2391
|
}
|
|
2154
2392
|
// Cap maxzoom so the map doesn't request tiles beyond what we downloaded.
|
|
2155
|
-
// Use the lower of the
|
|
2156
|
-
// don't raise it above the tileset's actual range (e.g.
|
|
2393
|
+
// Use the lower of the downloaded maxZoom and the source's *upstream*
|
|
2394
|
+
// maxzoom so we don't raise it above the tileset's actual range (e.g.
|
|
2395
|
+
// 3dbuildings at z14) — and so an earlier, shallower region's cap doesn't
|
|
2396
|
+
// stick once a deeper region is added.
|
|
2157
2397
|
if (maxZoom !== undefined) {
|
|
2158
|
-
const originalMaxzoom = source.
|
|
2398
|
+
const originalMaxzoom = source.__originalMaxzoom ?? undefined;
|
|
2159
2399
|
source.maxzoom = originalMaxzoom !== undefined ? Math.min(maxZoom, originalMaxzoom) : maxZoom;
|
|
2160
2400
|
styleLogger.debug(`Set maxzoom for ${sourceKey}: ${originalMaxzoom} → ${source.maxzoom}`);
|
|
2161
2401
|
}
|
|
@@ -2168,7 +2408,7 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2168
2408
|
let ext = tileExtension;
|
|
2169
2409
|
if (!ext)
|
|
2170
2410
|
ext = 'pbf';
|
|
2171
|
-
source.tiles = [`idb://${
|
|
2411
|
+
source.tiles = [`idb://${urlId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`];
|
|
2172
2412
|
styleLogger.debug(`Added tiles array for TileJSON-only source ${sourceKey}:`, {
|
|
2173
2413
|
tiles: source.tiles,
|
|
2174
2414
|
});
|
|
@@ -2184,7 +2424,7 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2184
2424
|
// Patch glyphs
|
|
2185
2425
|
if (style.glyphs) {
|
|
2186
2426
|
const originalGlyphs = style.glyphs;
|
|
2187
|
-
style.glyphs = `idb://${
|
|
2427
|
+
style.glyphs = `idb://${urlId}/glyph/{fontstack}/{range}.pbf`;
|
|
2188
2428
|
styleLogger.debug(`Patched glyphs:`, { original: originalGlyphs, patched: style.glyphs });
|
|
2189
2429
|
}
|
|
2190
2430
|
// Patch sprite
|
|
@@ -2243,13 +2483,31 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2243
2483
|
styleLogger.debug(`Final patched style:`, style);
|
|
2244
2484
|
return style;
|
|
2245
2485
|
}
|
|
2486
|
+
/** Resolve one entry of a font stack: a name, `["config", key]` or a `["concat", ...]` of those. */
|
|
2487
|
+
function resolveFontName(value, schema) {
|
|
2488
|
+
if (typeof value === 'string')
|
|
2489
|
+
return value;
|
|
2490
|
+
if (!Array.isArray(value))
|
|
2491
|
+
return undefined;
|
|
2492
|
+
if (value[0] === 'config' && typeof value[1] === 'string') {
|
|
2493
|
+
const configured = schema?.[value[1]]?.default;
|
|
2494
|
+
return typeof configured === 'string' ? configured : undefined;
|
|
2495
|
+
}
|
|
2496
|
+
if (value[0] === 'concat') {
|
|
2497
|
+
const parts = value.slice(1).map(part => resolveFontName(part, schema));
|
|
2498
|
+
return parts.every(part => part !== undefined) ? parts.join('') : undefined;
|
|
2499
|
+
}
|
|
2500
|
+
return undefined;
|
|
2501
|
+
}
|
|
2246
2502
|
/**
|
|
2247
2503
|
* Extract individual font names from a `text-font` layout property value.
|
|
2248
2504
|
*
|
|
2249
|
-
* Handles
|
|
2250
|
-
*
|
|
2505
|
+
* Handles simple arrays (`["Font A", "Font B"]`), Mapbox GL expression syntax
|
|
2506
|
+
* (e.g. `["step", ["zoom"], ["literal", ["Font A"]], 10, ["literal", ["Font B"]]]`),
|
|
2507
|
+
* and Mapbox Standard's config-built stacks
|
|
2508
|
+
* (`[["concat", ["config", "font"], " Medium"], "Arial Unicode MS Bold"]`).
|
|
2251
2509
|
*/
|
|
2252
|
-
function extractFontNamesFromTextField(textFont) {
|
|
2510
|
+
function extractFontNamesFromTextField(textFont, schema) {
|
|
2253
2511
|
const fonts = new Set();
|
|
2254
2512
|
// Known expression operators — anything starting with one of these is NOT a font name list
|
|
2255
2513
|
const expressionOps = new Set([
|
|
@@ -2289,20 +2547,40 @@ function extractFontNamesFromTextField(textFont) {
|
|
|
2289
2547
|
}
|
|
2290
2548
|
return;
|
|
2291
2549
|
}
|
|
2292
|
-
//
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2550
|
+
// A font stack: only font names, and the first isn't an expression operator
|
|
2551
|
+
const isFontEntry = (item) => typeof item === 'string' ||
|
|
2552
|
+
(Array.isArray(item) && (item[0] === 'config' || item[0] === 'concat'));
|
|
2553
|
+
if (value.every(isFontEntry) && !expressionOps.has(value[0])) {
|
|
2554
|
+
for (const item of value) {
|
|
2555
|
+
const name = resolveFontName(item, schema);
|
|
2556
|
+
if (name !== undefined)
|
|
2557
|
+
fonts.add(name);
|
|
2297
2558
|
}
|
|
2298
2559
|
return;
|
|
2299
2560
|
}
|
|
2300
|
-
//
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2561
|
+
// Only the branches that output a font. Conditions and inputs hold strings
|
|
2562
|
+
// like `["zoom"]` or match labels that aren't fonts.
|
|
2563
|
+
const last = value.length - 1;
|
|
2564
|
+
const isOutput = (i) => {
|
|
2565
|
+
switch (value[0]) {
|
|
2566
|
+
case 'step': // ["step", input, out0, stop, out, ...]
|
|
2567
|
+
return i >= 2 && i % 2 === 0;
|
|
2568
|
+
case 'match': // ["match", input, label, out, label, out, ..., fallback]
|
|
2569
|
+
return i === last || (i >= 3 && i % 2 === 1);
|
|
2570
|
+
case 'case': // ["case", cond, out, cond, out, ..., fallback]
|
|
2571
|
+
return i === last || (i >= 2 && i % 2 === 0);
|
|
2572
|
+
case 'coalesce':
|
|
2573
|
+
return i >= 1;
|
|
2574
|
+
case 'let': // ["let", name, value, ..., body]
|
|
2575
|
+
return i === last;
|
|
2576
|
+
default:
|
|
2577
|
+
return false;
|
|
2304
2578
|
}
|
|
2305
|
-
}
|
|
2579
|
+
};
|
|
2580
|
+
value.forEach((item, i) => {
|
|
2581
|
+
if (isOutput(i))
|
|
2582
|
+
walk(item);
|
|
2583
|
+
});
|
|
2306
2584
|
}
|
|
2307
2585
|
walk(textFont);
|
|
2308
2586
|
return Array.from(fonts);
|
|
@@ -2330,7 +2608,7 @@ function extractAllFontNames(style) {
|
|
|
2330
2608
|
if (style && Array.isArray(style.layers)) {
|
|
2331
2609
|
for (const layer of style.layers) {
|
|
2332
2610
|
if (layer.layout && layer.layout['text-font']) {
|
|
2333
|
-
for (const name of extractFontNamesFromTextField(layer.layout['text-font'])) {
|
|
2611
|
+
for (const name of extractFontNamesFromTextField(layer.layout['text-font'], style.schema)) {
|
|
2334
2612
|
fonts.add(name);
|
|
2335
2613
|
}
|
|
2336
2614
|
}
|
|
@@ -2399,8 +2677,13 @@ var styleUtils = /*#__PURE__*/Object.freeze({
|
|
|
2399
2677
|
extractAllFontNames: extractAllFontNames,
|
|
2400
2678
|
extractFontNamesFromTextField: extractFontNamesFromTextField,
|
|
2401
2679
|
generateGlyphUrlsFromStyle: generateGlyphUrlsFromStyle,
|
|
2680
|
+
injectExtraSources: injectExtraSources,
|
|
2681
|
+
isMapboxGlMap: isMapboxGlMap,
|
|
2682
|
+
needsMapboxGl: needsMapboxGl,
|
|
2402
2683
|
normalizeSpriteProperty: normalizeSpriteProperty,
|
|
2403
|
-
patchStyleForOffline: patchStyleForOffline
|
|
2684
|
+
patchStyleForOffline: patchStyleForOffline,
|
|
2685
|
+
restoreUnstoredModels: restoreUnstoredModels,
|
|
2686
|
+
stashOriginalModels: stashOriginalModels
|
|
2404
2687
|
});
|
|
2405
2688
|
|
|
2406
2689
|
const cleanupLogger$1 = logger.scope('CleanupCompressedTiles');
|
|
@@ -2957,7 +3240,7 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
|
|
|
2957
3240
|
// Collect all flattened sources, layers from imports
|
|
2958
3241
|
const flattenedSources = {};
|
|
2959
3242
|
const flattenedLayers = [];
|
|
2960
|
-
|
|
3243
|
+
const importSprites = [];
|
|
2961
3244
|
let importedGlyphs;
|
|
2962
3245
|
let importedModels;
|
|
2963
3246
|
for (const importEntry of imports) {
|
|
@@ -3009,6 +3292,7 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
|
|
|
3009
3292
|
// Build config lookup: schema defaults + import config overrides
|
|
3010
3293
|
const configValues = buildConfigLookup(importedStyle, importEntry.config);
|
|
3011
3294
|
// Flatten layers: prefix layer IDs, update source references, resolve config expressions
|
|
3295
|
+
const importLayers = [];
|
|
3012
3296
|
if (importedStyle.layers && Array.isArray(importedStyle.layers)) {
|
|
3013
3297
|
for (const layer of importedStyle.layers) {
|
|
3014
3298
|
const layerObj = layer;
|
|
@@ -3024,11 +3308,13 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
|
|
|
3024
3308
|
// Resolve ["config", "key"] expressions using schema defaults and import overrides.
|
|
3025
3309
|
resolveConfigExpressions(prefixedLayer, configValues);
|
|
3026
3310
|
flattenedLayers.push(prefixedLayer);
|
|
3311
|
+
importLayers.push(prefixedLayer);
|
|
3027
3312
|
}
|
|
3028
3313
|
}
|
|
3029
|
-
// Sprite: collect
|
|
3030
|
-
if
|
|
3031
|
-
|
|
3314
|
+
// Sprite: collect every import's sprite with its layers, so each can be
|
|
3315
|
+
// scoped if the flattened style ends up with more than one sprite.
|
|
3316
|
+
if (importedStyle.sprite) {
|
|
3317
|
+
importSprites.push({ importId, sprite: importedStyle.sprite, layers: importLayers });
|
|
3032
3318
|
}
|
|
3033
3319
|
// Glyphs: collect imported glyphs
|
|
3034
3320
|
if (importedStyle.glyphs && !importedGlyphs) {
|
|
@@ -3045,12 +3331,10 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
|
|
|
3045
3331
|
// Layers: imported layers go first (bottom), outer layers on top
|
|
3046
3332
|
const outerLayers = style.layers || [];
|
|
3047
3333
|
style.layers = [...flattenedLayers, ...outerLayers];
|
|
3048
|
-
// Sprite:
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
else if (!style.sprite && importedSprite) {
|
|
3053
|
-
style.sprite = importedSprite;
|
|
3334
|
+
// Sprite: a single sprite stays as-is; several become an array sprite with
|
|
3335
|
+
// each import's image references scoped to its sprite id.
|
|
3336
|
+
if (importSprites.length > 0) {
|
|
3337
|
+
style.sprite = combineSprites(style.sprite, importSprites);
|
|
3054
3338
|
}
|
|
3055
3339
|
// Glyphs: outer wins; if absent, use imported
|
|
3056
3340
|
if (!style.glyphs && importedGlyphs) {
|
|
@@ -3237,23 +3521,37 @@ function isExpression(value) {
|
|
|
3237
3521
|
return Array.isArray(value) && value.length > 0 && typeof value[0] === 'string';
|
|
3238
3522
|
}
|
|
3239
3523
|
/**
|
|
3240
|
-
*
|
|
3241
|
-
*
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3524
|
+
* Combine the outer style's sprite with its imports' sprites.
|
|
3525
|
+
*
|
|
3526
|
+
* With one sprite in total it is returned unchanged (a plain string keeps
|
|
3527
|
+
* working in Mapbox GL, which only accepts a string `sprite`). With several,
|
|
3528
|
+
* the result is a MapLibre array sprite: imports first, the outer sprite last
|
|
3529
|
+
* as `default`. Array-sprite images from any entry other than `default` are
|
|
3530
|
+
* only reachable as `{id}:{name}`, so each string-sprite import gets its
|
|
3531
|
+
* import id as sprite id and its layers' image references are prefixed with
|
|
3532
|
+
* `{importId}:`. Imports that already use an array sprite keep their entries
|
|
3533
|
+
* and references unchanged.
|
|
3534
|
+
*/
|
|
3535
|
+
function combineSprites(outer, imports) {
|
|
3536
|
+
if (!outer && imports.length === 1) {
|
|
3537
|
+
return imports[0].sprite;
|
|
3248
3538
|
}
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3539
|
+
const result = [];
|
|
3540
|
+
for (const { importId, sprite, layers } of imports) {
|
|
3541
|
+
if (typeof sprite === 'string') {
|
|
3542
|
+
result.push({ id: importId, url: sprite });
|
|
3543
|
+
for (const layer of layers) {
|
|
3544
|
+
prefixLayerImages(layer, `${importId}:`);
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
else if (Array.isArray(sprite)) {
|
|
3548
|
+
for (const entry of sprite) {
|
|
3549
|
+
if (entry && typeof entry.url === 'string') {
|
|
3550
|
+
result.push(entry);
|
|
3551
|
+
}
|
|
3253
3552
|
}
|
|
3254
3553
|
}
|
|
3255
3554
|
}
|
|
3256
|
-
// Add outer sprites on top
|
|
3257
3555
|
if (typeof outer === 'string') {
|
|
3258
3556
|
result.push({ id: 'default', url: outer });
|
|
3259
3557
|
}
|
|
@@ -3266,8 +3564,74 @@ function mergeSprites(outer, imported) {
|
|
|
3266
3564
|
}
|
|
3267
3565
|
return result;
|
|
3268
3566
|
}
|
|
3567
|
+
/** Layer properties whose values name sprite images. */
|
|
3568
|
+
const IMAGE_PROPERTIES = [
|
|
3569
|
+
['layout', 'icon-image'],
|
|
3570
|
+
['paint', 'fill-pattern'],
|
|
3571
|
+
['paint', 'line-pattern'],
|
|
3572
|
+
['paint', 'fill-extrusion-pattern'],
|
|
3573
|
+
['paint', 'background-pattern'],
|
|
3574
|
+
];
|
|
3575
|
+
function prefixLayerImages(layer, prefix) {
|
|
3576
|
+
for (const [group, property] of IMAGE_PROPERTIES) {
|
|
3577
|
+
const props = layer[group];
|
|
3578
|
+
if (props && property in props) {
|
|
3579
|
+
props[property] = prefixImageValue(props[property], prefix);
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
/**
|
|
3584
|
+
* Prefix the image name(s) a property value resolves to. Handles plain names
|
|
3585
|
+
* (including `{token}` templates), legacy `stops` functions, and expressions:
|
|
3586
|
+
* `image` / `literal` directly, the output branches of `coalesce` / `case` /
|
|
3587
|
+
* `match` / `step`, and anything else string-valued via `concat`.
|
|
3588
|
+
*/
|
|
3589
|
+
function prefixImageValue(value, prefix) {
|
|
3590
|
+
if (typeof value === 'string') {
|
|
3591
|
+
return value === '' ? value : `${prefix}${value}`;
|
|
3592
|
+
}
|
|
3593
|
+
if (Array.isArray(value)) {
|
|
3594
|
+
if (value.length === 0 || typeof value[0] !== 'string')
|
|
3595
|
+
return value;
|
|
3596
|
+
const [op, ...args] = value;
|
|
3597
|
+
const mapAt = (isOutput) => value.map((arg, i) => (i > 0 && isOutput(i) ? prefixImageValue(arg, prefix) : arg));
|
|
3598
|
+
const lastIndex = value.length - 1;
|
|
3599
|
+
switch (op) {
|
|
3600
|
+
case 'image':
|
|
3601
|
+
return ['image', prefixImageValue(args[0], prefix), ...args.slice(1)];
|
|
3602
|
+
case 'literal':
|
|
3603
|
+
return typeof args[0] === 'string' ? ['literal', `${prefix}${args[0]}`] : value;
|
|
3604
|
+
case 'coalesce':
|
|
3605
|
+
return mapAt(() => true);
|
|
3606
|
+
case 'case':
|
|
3607
|
+
// ["case", cond, out, cond, out, ..., fallback]
|
|
3608
|
+
return mapAt(i => i % 2 === 0 || i === lastIndex);
|
|
3609
|
+
case 'match':
|
|
3610
|
+
// ["match", input, label, out, label, out, ..., fallback]
|
|
3611
|
+
return mapAt(i => (i >= 3 && i % 2 === 1) || i === lastIndex);
|
|
3612
|
+
case 'step':
|
|
3613
|
+
// ["step", input, out0, stop, out, ...]
|
|
3614
|
+
return mapAt(i => i >= 2 && i % 2 === 0);
|
|
3615
|
+
default:
|
|
3616
|
+
return ['concat', prefix, value];
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
if (value && typeof value === 'object') {
|
|
3620
|
+
const fn = value;
|
|
3621
|
+
if (Array.isArray(fn.stops)) {
|
|
3622
|
+
return {
|
|
3623
|
+
...fn,
|
|
3624
|
+
stops: fn.stops.map(stop => Array.isArray(stop) ? [stop[0], prefixImageValue(stop[1], prefix)] : stop),
|
|
3625
|
+
...('default' in fn
|
|
3626
|
+
? { default: prefixImageValue(fn.default, prefix) }
|
|
3627
|
+
: {}),
|
|
3628
|
+
};
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
return value;
|
|
3632
|
+
}
|
|
3269
3633
|
|
|
3270
|
-
const DEFAULT_WASM_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.
|
|
3634
|
+
const DEFAULT_WASM_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.15.2/dist/';
|
|
3271
3635
|
let currentConfig = {};
|
|
3272
3636
|
let sqlJsPromise = null;
|
|
3273
3637
|
/**
|
|
@@ -3279,15 +3643,42 @@ function configureSqlJs(config) {
|
|
|
3279
3643
|
sqlJsPromise = null;
|
|
3280
3644
|
}
|
|
3281
3645
|
/**
|
|
3282
|
-
*
|
|
3283
|
-
*
|
|
3646
|
+
* Resolve the `initSqlJs` factory.
|
|
3647
|
+
*
|
|
3648
|
+
* Bundler builds resolve the npm package via dynamic `import()`, so `sql.js`
|
|
3649
|
+
* only ships with bundles that actually call MBTiles code. The UMD/CDN build
|
|
3650
|
+
* has no module resolver, so the import throws there — fall back to the
|
|
3651
|
+
* `initSqlJs` global that sql.js's own `<script>` build defines.
|
|
3652
|
+
*/
|
|
3653
|
+
async function resolveInitSqlJs() {
|
|
3654
|
+
let importError;
|
|
3655
|
+
try {
|
|
3656
|
+
const mod = (await import('sql.js'));
|
|
3657
|
+
if (typeof mod?.default === 'function')
|
|
3658
|
+
return mod.default;
|
|
3659
|
+
}
|
|
3660
|
+
catch (error) {
|
|
3661
|
+
// Bare specifier not resolvable (UMD/CDN), or the module threw while
|
|
3662
|
+
// initialising. Either way, try the global before giving up.
|
|
3663
|
+
importError = error;
|
|
3664
|
+
}
|
|
3665
|
+
const globalInit = globalThis.initSqlJs;
|
|
3666
|
+
if (typeof globalInit === 'function')
|
|
3667
|
+
return globalInit;
|
|
3668
|
+
// Preserve the original failure — a module that resolved but threw is a
|
|
3669
|
+
// very different problem from one that couldn't be resolved at all.
|
|
3670
|
+
throw new Error('sql.js could not be loaded. Install `sql.js` when bundling, or load ' +
|
|
3671
|
+
'https://cdn.jsdelivr.net/npm/sql.js/dist/sql-wasm.js before map-gl-offline ' +
|
|
3672
|
+
'when using the UMD build.', importError !== undefined ? { cause: importError } : undefined);
|
|
3673
|
+
}
|
|
3674
|
+
/**
|
|
3675
|
+
* Lazily initialise `sql.js`.
|
|
3284
3676
|
*/
|
|
3285
3677
|
async function getSqlJs() {
|
|
3286
3678
|
if (sqlJsPromise)
|
|
3287
3679
|
return sqlJsPromise;
|
|
3288
3680
|
sqlJsPromise = (async () => {
|
|
3289
|
-
const
|
|
3290
|
-
const initSqlJs = mod.default;
|
|
3681
|
+
const initSqlJs = await resolveInitSqlJs();
|
|
3291
3682
|
const options = {};
|
|
3292
3683
|
if (currentConfig.wasmBinary) {
|
|
3293
3684
|
options.wasmBinary = currentConfig.wasmBinary;
|
|
@@ -3301,91 +3692,575 @@ async function getSqlJs() {
|
|
|
3301
3692
|
return sqlJsPromise;
|
|
3302
3693
|
}
|
|
3303
3694
|
|
|
3304
|
-
const
|
|
3305
|
-
|
|
3695
|
+
const modelLogger = logger.scope('ModelService');
|
|
3696
|
+
/**
|
|
3697
|
+
* Build the storage key for a model. Kept consistent with sprite/glyph
|
|
3698
|
+
* conventions: `{styleId}::model::{modelName}`.
|
|
3699
|
+
*/
|
|
3700
|
+
function modelKey(styleId, modelName) {
|
|
3701
|
+
return `${styleId}::model::${modelName}`;
|
|
3702
|
+
}
|
|
3703
|
+
/** Names of the models stored for a style. */
|
|
3704
|
+
async function storedModelNames(styleId) {
|
|
3705
|
+
const db = await dbPromise;
|
|
3706
|
+
const prefix = modelKey(styleId, '');
|
|
3707
|
+
const keys = await db.getAllKeys('models', IDBKeyRange.bound(prefix, `${prefix}`));
|
|
3708
|
+
return new Set(keys.map(key => String(key).slice(prefix.length)));
|
|
3709
|
+
}
|
|
3710
|
+
/** True when the given key belongs to the given styleId's model store prefix. */
|
|
3711
|
+
function modelKeyBelongsToStyle(key, styleId) {
|
|
3712
|
+
return key.startsWith(`${styleId}::model::`);
|
|
3713
|
+
}
|
|
3714
|
+
/**
|
|
3715
|
+
* Service for downloading, storing, and serving Mapbox 3D model (.glb) files.
|
|
3716
|
+
*
|
|
3717
|
+
* Mapbox Standard declares 32 models at the top of `style.models`:
|
|
3718
|
+
*
|
|
3719
|
+
* ```json
|
|
3720
|
+
* {
|
|
3721
|
+
* "maple1-lod1": "mapbox://models/mapbox/maple1-v4-lod1.glb",
|
|
3722
|
+
* ...
|
|
3723
|
+
* }
|
|
3724
|
+
* ```
|
|
3725
|
+
*
|
|
3726
|
+
* `model` layers (e.g. `trees`, `wind-turbine-towers`) pick one by name at
|
|
3727
|
+
* render time. For offline use each referenced URL is fetched and stored
|
|
3728
|
+
* here, and `patchStyleForOffline` rewrites the dictionary entries to
|
|
3729
|
+
* `idb://{styleId}/model/{name}` URLs.
|
|
3730
|
+
*/
|
|
3731
|
+
class ModelService {
|
|
3306
3732
|
db = dbPromise;
|
|
3307
|
-
|
|
3733
|
+
/**
|
|
3734
|
+
* Download the set of models referenced by `style.models` for one style.
|
|
3735
|
+
*
|
|
3736
|
+
* @param models `{ modelName: resolvedHttpUrl }` — URLs must already be
|
|
3737
|
+
* resolved (mapbox:// URLs should be resolved by the caller).
|
|
3738
|
+
* @param styleId The owning style's key.
|
|
3739
|
+
*/
|
|
3740
|
+
async downloadModels(models, styleId, options = {}) {
|
|
3308
3741
|
const db = await this.db;
|
|
3309
|
-
const { batchSize =
|
|
3310
|
-
const
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3742
|
+
const { onProgress, batchSize = 4, maxRetries = 3, skipExisting = true, timeoutMs = 30000, } = options;
|
|
3743
|
+
const entries = Object.entries(models);
|
|
3744
|
+
const progressTracker = createProgressTracker(entries.length);
|
|
3745
|
+
const result = {
|
|
3746
|
+
totalModels: entries.length,
|
|
3747
|
+
downloadedModels: 0,
|
|
3748
|
+
skippedModels: 0,
|
|
3749
|
+
failedModels: 0,
|
|
3750
|
+
totalSize: 0,
|
|
3751
|
+
errors: [],
|
|
3752
|
+
};
|
|
3753
|
+
const emit = () => onProgress?.(progressTracker.getProgress());
|
|
3754
|
+
emit();
|
|
3755
|
+
if (entries.length === 0)
|
|
3756
|
+
return result;
|
|
3757
|
+
// Pre-compute existing keys for skipExisting
|
|
3758
|
+
const existingKeys = new Set();
|
|
3321
3759
|
if (skipExisting) {
|
|
3322
|
-
const
|
|
3323
|
-
const
|
|
3324
|
-
|
|
3325
|
-
while (cursor) {
|
|
3326
|
-
existingFonts.add(cursor.value.key);
|
|
3327
|
-
cursor = await cursor.continue();
|
|
3760
|
+
const tx = db.transaction('models', 'readonly');
|
|
3761
|
+
for await (const cursor of tx.store) {
|
|
3762
|
+
existingKeys.add(cursor.value.key);
|
|
3328
3763
|
}
|
|
3329
|
-
urlsToDownload = fontUrls.filter(url => {
|
|
3330
|
-
const key = this.createFontKey(url, styleName);
|
|
3331
|
-
return !existingFonts.has(key);
|
|
3332
|
-
});
|
|
3333
|
-
skippedFonts = fontUrls.length - urlsToDownload.length;
|
|
3334
3764
|
}
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
const
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3765
|
+
await processBatch(entries, async ([modelName, url]) => {
|
|
3766
|
+
const key = modelKey(styleId, modelName);
|
|
3767
|
+
const label = `${styleId}::${modelName}`;
|
|
3768
|
+
if (skipExisting && existingKeys.has(key)) {
|
|
3769
|
+
result.skippedModels++;
|
|
3770
|
+
progressTracker.update(1, label);
|
|
3771
|
+
emit();
|
|
3772
|
+
return;
|
|
3343
3773
|
}
|
|
3344
|
-
}
|
|
3345
|
-
// Process fonts in batches with concurrency control
|
|
3346
|
-
await processBatch(urlsToDownload, async (fontUrl) => {
|
|
3347
3774
|
try {
|
|
3348
|
-
const
|
|
3349
|
-
const response = await fetchResourceWithRetry(fontUrl, {
|
|
3775
|
+
const response = await fetchResourceWithRetry(url, {
|
|
3350
3776
|
retries: maxRetries,
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
proxyType: 'fonts',
|
|
3777
|
+
timeout: timeoutMs,
|
|
3778
|
+
proxyType: 'tiles',
|
|
3354
3779
|
});
|
|
3355
3780
|
if (response.type === 'json') {
|
|
3356
|
-
throw new Error(
|
|
3357
|
-
}
|
|
3358
|
-
const fontData = response.data;
|
|
3359
|
-
const contentType = response.contentType ||
|
|
3360
|
-
(response.type === 'other' ? 'font/opentype' : `font/${response.type}`);
|
|
3361
|
-
// Validate font if enabled
|
|
3362
|
-
if (validateFonts) {
|
|
3363
|
-
await this.validateFont(fontData, contentType);
|
|
3781
|
+
throw new Error('Unexpected JSON response for model');
|
|
3364
3782
|
}
|
|
3365
|
-
|
|
3366
|
-
const
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
data: fontData,
|
|
3783
|
+
const data = response.data;
|
|
3784
|
+
const contentType = ('contentType' in response && response.contentType) || 'model/gltf-binary';
|
|
3785
|
+
const entry = {
|
|
3786
|
+
key,
|
|
3787
|
+
data,
|
|
3371
3788
|
contentType,
|
|
3372
|
-
|
|
3373
|
-
|
|
3789
|
+
size: data.byteLength,
|
|
3790
|
+
url,
|
|
3791
|
+
styleId,
|
|
3792
|
+
modelName,
|
|
3374
3793
|
lastModified: Date.now(),
|
|
3375
3794
|
downloadedAt: new Date().toISOString(),
|
|
3376
|
-
downloadId: styleName,
|
|
3377
|
-
metadata: {
|
|
3378
|
-
userAgent: navigator.userAgent,
|
|
3379
|
-
downloadTimestamp: Date.now(),
|
|
3380
|
-
},
|
|
3381
3795
|
expires: response.expires,
|
|
3382
3796
|
};
|
|
3383
|
-
await db.put('
|
|
3384
|
-
|
|
3385
|
-
totalSize +=
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3797
|
+
await db.put('models', entry);
|
|
3798
|
+
result.downloadedModels++;
|
|
3799
|
+
result.totalSize += data.byteLength;
|
|
3800
|
+
progressTracker.update(1, label);
|
|
3801
|
+
}
|
|
3802
|
+
catch (err) {
|
|
3803
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3804
|
+
result.failedModels++;
|
|
3805
|
+
result.errors.push({ url, error: message });
|
|
3806
|
+
modelLogger.warn(`Failed to download model "${modelName}" from ${url}:`, err);
|
|
3807
|
+
progressTracker.update(1, label, message);
|
|
3808
|
+
}
|
|
3809
|
+
emit();
|
|
3810
|
+
}, { batchSize });
|
|
3811
|
+
modelLogger.info(`Models downloaded for style ${styleId}: ${result.downloadedModels} new, ${result.skippedModels} skipped, ${result.failedModels} failed`);
|
|
3812
|
+
return result;
|
|
3813
|
+
}
|
|
3814
|
+
/** Retrieve a single model by `{styleId, modelName}`. */
|
|
3815
|
+
async getModel(styleId, modelName) {
|
|
3816
|
+
const db = await this.db;
|
|
3817
|
+
return db.get('models', modelKey(styleId, modelName));
|
|
3818
|
+
}
|
|
3819
|
+
/** Aggregate stats across all stored models. */
|
|
3820
|
+
async getModelStats() {
|
|
3821
|
+
const db = await this.db;
|
|
3822
|
+
const stats = {
|
|
3823
|
+
count: 0,
|
|
3824
|
+
totalSize: 0,
|
|
3825
|
+
averageSize: 0,
|
|
3826
|
+
models: [],
|
|
3827
|
+
modelsByStyle: {},
|
|
3828
|
+
};
|
|
3829
|
+
const tx = db.transaction('models', 'readonly');
|
|
3830
|
+
for await (const cursor of tx.store) {
|
|
3831
|
+
const m = cursor.value;
|
|
3832
|
+
stats.count++;
|
|
3833
|
+
stats.totalSize += m.size;
|
|
3834
|
+
stats.models.push({ name: m.modelName, size: m.size, lastModified: m.lastModified });
|
|
3835
|
+
stats.modelsByStyle[m.styleId] = (stats.modelsByStyle[m.styleId] ?? 0) + 1;
|
|
3836
|
+
}
|
|
3837
|
+
stats.averageSize = stats.count > 0 ? stats.totalSize / stats.count : 0;
|
|
3838
|
+
return stats;
|
|
3839
|
+
}
|
|
3840
|
+
/**
|
|
3841
|
+
* Delete models older than `maxAge` days. Defaults to 30.
|
|
3842
|
+
*/
|
|
3843
|
+
async cleanupOldModels(maxAge = 30) {
|
|
3844
|
+
const db = await this.db;
|
|
3845
|
+
const cutoff = Date.now() - maxAge * 24 * 60 * 60 * 1000;
|
|
3846
|
+
let deleted = 0;
|
|
3847
|
+
const tx = db.transaction('models', 'readwrite');
|
|
3848
|
+
for await (const cursor of tx.store) {
|
|
3849
|
+
if (cursor.value.lastModified < cutoff) {
|
|
3850
|
+
await cursor.delete();
|
|
3851
|
+
deleted++;
|
|
3852
|
+
}
|
|
3853
|
+
}
|
|
3854
|
+
return deleted;
|
|
3855
|
+
}
|
|
3856
|
+
/**
|
|
3857
|
+
* Basic integrity check: remove entries with empty/missing data.
|
|
3858
|
+
*/
|
|
3859
|
+
async verifyAndRepairModels() {
|
|
3860
|
+
const db = await this.db;
|
|
3861
|
+
let verified = 0;
|
|
3862
|
+
let removed = 0;
|
|
3863
|
+
const tx = db.transaction('models', 'readwrite');
|
|
3864
|
+
for await (const cursor of tx.store) {
|
|
3865
|
+
const m = cursor.value;
|
|
3866
|
+
if (!m.data || m.data.byteLength === 0) {
|
|
3867
|
+
await cursor.delete();
|
|
3868
|
+
removed++;
|
|
3869
|
+
}
|
|
3870
|
+
else {
|
|
3871
|
+
verified++;
|
|
3872
|
+
}
|
|
3873
|
+
}
|
|
3874
|
+
return { verified, repaired: 0, removed };
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
// Singleton + convenience exports, matching other service modules.
|
|
3878
|
+
const modelService = new ModelService();
|
|
3879
|
+
const downloadModels = (models, styleId, options) => modelService.downloadModels(models, styleId, options);
|
|
3880
|
+
const getModel = (styleId, modelName) => modelService.getModel(styleId, modelName);
|
|
3881
|
+
const getModelStats = () => modelService.getModelStats();
|
|
3882
|
+
const cleanupOldModels = (maxAge) => modelService.cleanupOldModels(maxAge);
|
|
3883
|
+
const verifyAndRepairModels = () => modelService.verifyAndRepairModels();
|
|
3884
|
+
|
|
3885
|
+
var modelService$1 = /*#__PURE__*/Object.freeze({
|
|
3886
|
+
__proto__: null,
|
|
3887
|
+
ModelService: ModelService,
|
|
3888
|
+
cleanupOldModels: cleanupOldModels,
|
|
3889
|
+
downloadModels: downloadModels,
|
|
3890
|
+
getModel: getModel,
|
|
3891
|
+
getModelStats: getModelStats,
|
|
3892
|
+
modelKeyBelongsToStyle: modelKeyBelongsToStyle,
|
|
3893
|
+
modelService: modelService,
|
|
3894
|
+
storedModelNames: storedModelNames,
|
|
3895
|
+
verifyAndRepairModels: verifyAndRepairModels
|
|
3896
|
+
});
|
|
3897
|
+
|
|
3898
|
+
/**
|
|
3899
|
+
* Mapbox GL v3 style-config helpers.
|
|
3900
|
+
*
|
|
3901
|
+
* Mapbox Standard is one style with switchable features: most of its layers set
|
|
3902
|
+
* `layout.visibility` from `["config", key]` expressions backed by `style.schema`
|
|
3903
|
+
* defaults. These helpers work out which sources the style actually needs for
|
|
3904
|
+
* the current config, so features that are switched off aren't downloaded and
|
|
3905
|
+
* features that are switched on aren't skipped.
|
|
3906
|
+
*/
|
|
3907
|
+
const configLogger = logger.scope('StyleConfig');
|
|
3908
|
+
/** Sentinel for an expression that can't be decided from config alone. */
|
|
3909
|
+
const UNKNOWN = Symbol('unknown');
|
|
3910
|
+
/**
|
|
3911
|
+
* True for styles whose feature set is config-driven: they declare a `schema`,
|
|
3912
|
+
* or were flattened from `imports` (whose config is already resolved into the
|
|
3913
|
+
* layers). Other styles are never filtered by config.
|
|
3914
|
+
*/
|
|
3915
|
+
function isConfigDrivenStyle(style) {
|
|
3916
|
+
const s = style;
|
|
3917
|
+
if (!s)
|
|
3918
|
+
return false;
|
|
3919
|
+
return ((s.schema !== undefined && Object.keys(s.schema).length > 0) ||
|
|
3920
|
+
(Array.isArray(s.imports) && s.imports.length > 0));
|
|
3921
|
+
}
|
|
3922
|
+
function evaluate(expr, config) {
|
|
3923
|
+
if (!Array.isArray(expr))
|
|
3924
|
+
return expr;
|
|
3925
|
+
const [op, ...args] = expr;
|
|
3926
|
+
switch (op) {
|
|
3927
|
+
case 'literal':
|
|
3928
|
+
return args[0];
|
|
3929
|
+
case 'config': {
|
|
3930
|
+
const key = args[0];
|
|
3931
|
+
return typeof key === 'string' && key in config ? config[key] : UNKNOWN;
|
|
3932
|
+
}
|
|
3933
|
+
case '!': {
|
|
3934
|
+
const value = evaluate(args[0], config);
|
|
3935
|
+
return typeof value === 'boolean' ? !value : UNKNOWN;
|
|
3936
|
+
}
|
|
3937
|
+
case 'all':
|
|
3938
|
+
case 'any': {
|
|
3939
|
+
const decisive = op === 'any';
|
|
3940
|
+
let sawUnknown = false;
|
|
3941
|
+
for (const arg of args) {
|
|
3942
|
+
const value = evaluate(arg, config);
|
|
3943
|
+
if (value === decisive)
|
|
3944
|
+
return decisive;
|
|
3945
|
+
if (typeof value !== 'boolean')
|
|
3946
|
+
sawUnknown = true;
|
|
3947
|
+
}
|
|
3948
|
+
return sawUnknown ? UNKNOWN : !decisive;
|
|
3949
|
+
}
|
|
3950
|
+
case '==':
|
|
3951
|
+
case '!=': {
|
|
3952
|
+
const a = evaluate(args[0], config);
|
|
3953
|
+
const b = evaluate(args[1], config);
|
|
3954
|
+
if (a === UNKNOWN || b === UNKNOWN)
|
|
3955
|
+
return UNKNOWN;
|
|
3956
|
+
return op === '==' ? a === b : a !== b;
|
|
3957
|
+
}
|
|
3958
|
+
case 'case': {
|
|
3959
|
+
// ["case", cond, out, cond, out, ..., fallback]
|
|
3960
|
+
for (let i = 0; i < args.length - 1; i += 2) {
|
|
3961
|
+
const condition = evaluate(args[i], config);
|
|
3962
|
+
if (typeof condition !== 'boolean')
|
|
3963
|
+
return UNKNOWN;
|
|
3964
|
+
if (condition)
|
|
3965
|
+
return evaluate(args[i + 1], config);
|
|
3966
|
+
}
|
|
3967
|
+
return evaluate(args[args.length - 1], config);
|
|
3968
|
+
}
|
|
3969
|
+
case 'match': {
|
|
3970
|
+
// ["match", input, label, out, label, out, ..., fallback]
|
|
3971
|
+
const input = evaluate(args[0], config);
|
|
3972
|
+
if (input === UNKNOWN)
|
|
3973
|
+
return UNKNOWN;
|
|
3974
|
+
for (let i = 1; i < args.length - 1; i += 2) {
|
|
3975
|
+
const label = args[i];
|
|
3976
|
+
if (Array.isArray(label) ? label.includes(input) : label === input) {
|
|
3977
|
+
return evaluate(args[i + 1], config);
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
return evaluate(args[args.length - 1], config);
|
|
3981
|
+
}
|
|
3982
|
+
default:
|
|
3983
|
+
return UNKNOWN;
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
function collectIconsetIds(value, ids) {
|
|
3987
|
+
if (Array.isArray(value)) {
|
|
3988
|
+
for (const item of value)
|
|
3989
|
+
collectIconsetIds(item, ids);
|
|
3990
|
+
}
|
|
3991
|
+
else if (value && typeof value === 'object') {
|
|
3992
|
+
const record = value;
|
|
3993
|
+
const iconset = record.iconset;
|
|
3994
|
+
if (iconset && typeof iconset.id === 'string')
|
|
3995
|
+
ids.add(iconset.id);
|
|
3996
|
+
for (const item of Object.values(record))
|
|
3997
|
+
collectIconsetIds(item, ids);
|
|
3998
|
+
}
|
|
3999
|
+
}
|
|
4000
|
+
/**
|
|
4001
|
+
* Sources a config-driven style uses under its current config (`active`), and
|
|
4002
|
+
* those it references but only from hidden layers or unused iconsets (`inactive`).
|
|
4003
|
+
*
|
|
4004
|
+
* Deliberately errs towards keeping sources: a visibility expression that can't
|
|
4005
|
+
* be decided counts as visible. Sources nothing in the style references
|
|
4006
|
+
* (`extraSources`, ones a caller adds at runtime) are in neither set.
|
|
4007
|
+
*/
|
|
4008
|
+
function classifySources(style) {
|
|
4009
|
+
const inactive = new Set();
|
|
4010
|
+
const active = new Set();
|
|
4011
|
+
if (!isConfigDrivenStyle(style))
|
|
4012
|
+
return { active, inactive };
|
|
4013
|
+
const s = style;
|
|
4014
|
+
const config = {};
|
|
4015
|
+
for (const [key, entry] of Object.entries(s.schema ?? {})) {
|
|
4016
|
+
if (entry && typeof entry === 'object' && 'default' in entry)
|
|
4017
|
+
config[key] = entry.default;
|
|
4018
|
+
}
|
|
4019
|
+
const activeIconsets = new Set();
|
|
4020
|
+
for (const layer of (s.layers ?? [])) {
|
|
4021
|
+
const visible = evaluate(layer?.layout?.visibility ?? 'visible', config) !== 'none';
|
|
4022
|
+
if (typeof layer?.source === 'string') {
|
|
4023
|
+
inactive.add(layer.source);
|
|
4024
|
+
if (visible)
|
|
4025
|
+
active.add(layer.source);
|
|
4026
|
+
}
|
|
4027
|
+
if (visible) {
|
|
4028
|
+
// The whole layer, since `appearances` can also name an iconset.
|
|
4029
|
+
collectIconsetIds(layer, activeIconsets);
|
|
4030
|
+
}
|
|
4031
|
+
}
|
|
4032
|
+
for (const [id, iconset] of Object.entries(s.iconsets ?? {})) {
|
|
4033
|
+
if (typeof iconset?.source !== 'string')
|
|
4034
|
+
continue;
|
|
4035
|
+
inactive.add(iconset.source);
|
|
4036
|
+
if (activeIconsets.has(id))
|
|
4037
|
+
active.add(iconset.source);
|
|
4038
|
+
}
|
|
4039
|
+
if (typeof s.terrain?.source === 'string')
|
|
4040
|
+
active.add(s.terrain.source);
|
|
4041
|
+
for (const id of active)
|
|
4042
|
+
inactive.delete(id);
|
|
4043
|
+
return { active, inactive };
|
|
4044
|
+
}
|
|
4045
|
+
/**
|
|
4046
|
+
* Write config values into a style's `schema` defaults. `["config", key]` reads
|
|
4047
|
+
* those defaults at render time, so this one edit drives both what gets
|
|
4048
|
+
* downloaded and what the stored style shows offline. Keys the schema doesn't
|
|
4049
|
+
* declare are ignored with a warning.
|
|
4050
|
+
*/
|
|
4051
|
+
function applyStyleConfig(style, config) {
|
|
4052
|
+
const schema = style.schema;
|
|
4053
|
+
for (const [key, value] of Object.entries(config)) {
|
|
4054
|
+
const entry = schema?.[key];
|
|
4055
|
+
if (entry && typeof entry === 'object') {
|
|
4056
|
+
entry.default = value;
|
|
4057
|
+
}
|
|
4058
|
+
else {
|
|
4059
|
+
configLogger.warn(`Ignoring styleConfig "${key}": the style's schema doesn't declare it`);
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
}
|
|
4063
|
+
|
|
4064
|
+
/**
|
|
4065
|
+
* XYZ tile range covering `bounds` at zoom `z`. Shared by the download
|
|
4066
|
+
* pipeline (which fetches exactly this range) and region-scoped consumers
|
|
4067
|
+
* like MBTiles export, so both agree on which tiles belong to a region.
|
|
4068
|
+
*/
|
|
4069
|
+
function getTileRangeAtZoom(bounds, z) {
|
|
4070
|
+
const minTile = pointToTileClamped(bounds[0][0], bounds[0][1], z);
|
|
4071
|
+
const maxTile = pointToTileClamped(bounds[1][0], bounds[1][1], z);
|
|
4072
|
+
return {
|
|
4073
|
+
minX: Math.min(minTile[0], maxTile[0]),
|
|
4074
|
+
maxX: Math.max(minTile[0], maxTile[0]),
|
|
4075
|
+
minY: Math.min(minTile[1], maxTile[1]),
|
|
4076
|
+
maxY: Math.max(minTile[1], maxTile[1]),
|
|
4077
|
+
};
|
|
4078
|
+
}
|
|
4079
|
+
/** Web Mercator's latitude limit; tiles don't exist beyond it. */
|
|
4080
|
+
const MAX_MERCATOR_LAT = 85.0511287798066;
|
|
4081
|
+
/**
|
|
4082
|
+
* `tilebelt.pointToTile`, kept on the tile grid. tilebelt wraps longitude 180
|
|
4083
|
+
* to x = 0, so a region whose east edge is the antimeridian became the range
|
|
4084
|
+
* 0..maxX — nearly the whole world at every zoom. Latitudes beyond the
|
|
4085
|
+
* Mercator limit likewise fall off the grid.
|
|
4086
|
+
*/
|
|
4087
|
+
function pointToTileClamped(lon, lat, z) {
|
|
4088
|
+
const maxIndex = 2 ** z - 1;
|
|
4089
|
+
const clampedLon = Math.max(-180, Math.min(180, lon));
|
|
4090
|
+
const clampedLat = Math.max(-MAX_MERCATOR_LAT, Math.min(MAX_MERCATOR_LAT, lat));
|
|
4091
|
+
const [x, y] = tilebelt.pointToTile(clampedLon, clampedLat, z);
|
|
4092
|
+
return [
|
|
4093
|
+
clampedLon === 180 ? maxIndex : Math.max(0, Math.min(maxIndex, x)),
|
|
4094
|
+
Math.max(0, Math.min(maxIndex, y)),
|
|
4095
|
+
];
|
|
4096
|
+
}
|
|
4097
|
+
/**
|
|
4098
|
+
* Whether tile `z/x/y` falls inside `bounds` and `[minZoom, maxZoom]`.
|
|
4099
|
+
*/
|
|
4100
|
+
function isTileInRegion(z, x, y, region) {
|
|
4101
|
+
if (z < region.minZoom || z > region.maxZoom)
|
|
4102
|
+
return false;
|
|
4103
|
+
const { minX, maxX, minY, maxY } = getTileRangeAtZoom(region.bounds, z);
|
|
4104
|
+
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
|
4105
|
+
}
|
|
4106
|
+
/**
|
|
4107
|
+
* Zoom levels to download for one source in `region`: the overlap of the
|
|
4108
|
+
* region's and the source's zoom ranges. When they don't overlap, the source
|
|
4109
|
+
* still gets the zooms the map needs from it:
|
|
4110
|
+
* - source ends below the region (`maxzoom` < region min) → its `maxzoom`,
|
|
4111
|
+
* which the renderer overzooms for the region's zooms;
|
|
4112
|
+
* - source starts above the region (`minzoom` > region max) → `minzoom`
|
|
4113
|
+
* through `maxzoom` (or just `minzoom` if unbounded).
|
|
4114
|
+
*/
|
|
4115
|
+
function sourceZoomRange(region, sourceMinZoom, sourceMaxZoom) {
|
|
4116
|
+
const srcMin = sourceMinZoom !== undefined ? Math.ceil(sourceMinZoom) : undefined;
|
|
4117
|
+
const srcMax = sourceMaxZoom !== undefined ? Math.floor(sourceMaxZoom) : undefined;
|
|
4118
|
+
if (srcMax !== undefined && srcMax < region.minZoom) {
|
|
4119
|
+
return { min: srcMax, max: srcMax };
|
|
4120
|
+
}
|
|
4121
|
+
if (srcMin !== undefined && srcMin > region.maxZoom) {
|
|
4122
|
+
return { min: srcMin, max: srcMax ?? srcMin };
|
|
4123
|
+
}
|
|
4124
|
+
return {
|
|
4125
|
+
min: Math.max(region.minZoom, srcMin ?? region.minZoom),
|
|
4126
|
+
max: Math.min(region.maxZoom, srcMax ?? region.maxZoom),
|
|
4127
|
+
};
|
|
4128
|
+
}
|
|
4129
|
+
/**
|
|
4130
|
+
* A stored source's upstream `maxzoom`. Patched styles cap `maxzoom` for
|
|
4131
|
+
* rendering and stash the upstream value under `__originalMaxzoom`.
|
|
4132
|
+
*/
|
|
4133
|
+
function upstreamMaxzoom(source) {
|
|
4134
|
+
if (source?.__originalMaxzoom !== undefined)
|
|
4135
|
+
return source.__originalMaxzoom ?? undefined;
|
|
4136
|
+
return source?.maxzoom;
|
|
4137
|
+
}
|
|
4138
|
+
function hasValidBounds(region) {
|
|
4139
|
+
const b = region.bounds;
|
|
4140
|
+
return (Array.isArray(b) &&
|
|
4141
|
+
b.length === 2 &&
|
|
4142
|
+
b.every(corner => Array.isArray(corner) && corner.length === 2));
|
|
4143
|
+
}
|
|
4144
|
+
/**
|
|
4145
|
+
* Whether a stored tile was downloaded for `region`: inside its bounds, at a
|
|
4146
|
+
* zoom the download pipeline fetches for the tile's source (which can fall
|
|
4147
|
+
* outside the region's own range — see `sourceZoomRange`). `sources` is the
|
|
4148
|
+
* stored style's `sources`. Regions with malformed bounds match nothing.
|
|
4149
|
+
*/
|
|
4150
|
+
function tileBelongsToRegion(tile, region, sources) {
|
|
4151
|
+
if (!hasValidBounds(region))
|
|
4152
|
+
return false;
|
|
4153
|
+
const source = tile.sourceId !== undefined ? sources[tile.sourceId] : undefined;
|
|
4154
|
+
const { min, max } = sourceZoomRange(region, source?.minzoom, upstreamMaxzoom(source));
|
|
4155
|
+
return isTileInRegion(tile.z, tile.x, tile.y, {
|
|
4156
|
+
bounds: region.bounds,
|
|
4157
|
+
minZoom: min,
|
|
4158
|
+
maxZoom: max,
|
|
4159
|
+
});
|
|
4160
|
+
}
|
|
4161
|
+
|
|
4162
|
+
const fontLogger = logger.scope('FontService');
|
|
4163
|
+
class FontService {
|
|
4164
|
+
db = dbPromise;
|
|
4165
|
+
async downloadFonts(fontUrls, styleName, options = {}) {
|
|
4166
|
+
const db = await this.db;
|
|
4167
|
+
const { batchSize = 10, maxRetries = 3, skipExisting = true, retryDelay = 1000, timeout = 30000, validateFonts = true, storageQuotaCheck = true, quietMode = false, onProgress, } = options;
|
|
4168
|
+
const startTime = Date.now();
|
|
4169
|
+
let totalSize = 0;
|
|
4170
|
+
let downloadedFonts = 0;
|
|
4171
|
+
let skippedFonts = 0;
|
|
4172
|
+
let failedFonts = 0;
|
|
4173
|
+
const errors = [];
|
|
4174
|
+
const fontsByType = {};
|
|
4175
|
+
// Create progress tracker
|
|
4176
|
+
const progressTracker = createProgressTracker(fontUrls.length);
|
|
4177
|
+
const reportProgress = (message) => {
|
|
4178
|
+
if (!onProgress)
|
|
4179
|
+
return;
|
|
4180
|
+
const progress = progressTracker.getProgress();
|
|
4181
|
+
onProgress({
|
|
4182
|
+
completed: progress.completed,
|
|
4183
|
+
total: progress.total,
|
|
4184
|
+
percentage: progress.percentage,
|
|
4185
|
+
message,
|
|
4186
|
+
errors: errors.map(e => e.error),
|
|
4187
|
+
});
|
|
4188
|
+
};
|
|
4189
|
+
// Filter existing fonts if skipExisting is true
|
|
4190
|
+
let urlsToDownload = fontUrls;
|
|
4191
|
+
if (skipExisting) {
|
|
4192
|
+
const existingFonts = new Set();
|
|
4193
|
+
const tx = db.transaction(['fonts'], 'readonly');
|
|
4194
|
+
let cursor = await tx.objectStore('fonts').openCursor();
|
|
4195
|
+
while (cursor) {
|
|
4196
|
+
existingFonts.add(cursor.value.key);
|
|
4197
|
+
cursor = await cursor.continue();
|
|
4198
|
+
}
|
|
4199
|
+
urlsToDownload = fontUrls.filter(url => {
|
|
4200
|
+
const key = this.createFontKey(url, styleName);
|
|
4201
|
+
return !existingFonts.has(key);
|
|
4202
|
+
});
|
|
4203
|
+
skippedFonts = fontUrls.length - urlsToDownload.length;
|
|
4204
|
+
// Skipped fonts count toward the total, so mark them done up front.
|
|
4205
|
+
if (skippedFonts > 0) {
|
|
4206
|
+
progressTracker.update(skippedFonts);
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
// Check storage quota if enabled
|
|
4210
|
+
if (storageQuotaCheck && 'storage' in navigator && 'estimate' in navigator.storage) {
|
|
4211
|
+
const estimate = await navigator.storage.estimate();
|
|
4212
|
+
const usedSpace = estimate.usage || 0;
|
|
4213
|
+
const availableSpace = (estimate.quota || 0) - usedSpace;
|
|
4214
|
+
if (availableSpace < 50 * 1024 * 1024) {
|
|
4215
|
+
// Less than 50MB
|
|
4216
|
+
throw new Error('Insufficient storage space for font download');
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
// Process fonts in batches with concurrency control
|
|
4220
|
+
await processBatch(urlsToDownload, async (fontUrl) => {
|
|
4221
|
+
try {
|
|
4222
|
+
const fontKey = this.createFontKey(fontUrl, styleName);
|
|
4223
|
+
const response = await fetchResourceWithRetry(fontUrl, {
|
|
4224
|
+
retries: maxRetries,
|
|
4225
|
+
retryDelay,
|
|
4226
|
+
timeout,
|
|
4227
|
+
proxyType: 'fonts',
|
|
4228
|
+
});
|
|
4229
|
+
if (response.type === 'json') {
|
|
4230
|
+
throw new Error(`Unexpected JSON response when fetching font: ${fontUrl}`);
|
|
4231
|
+
}
|
|
4232
|
+
const fontData = response.data;
|
|
4233
|
+
const contentType = response.contentType ||
|
|
4234
|
+
(response.type === 'other' ? 'font/opentype' : `font/${response.type}`);
|
|
4235
|
+
// Validate font if enabled
|
|
4236
|
+
if (validateFonts) {
|
|
4237
|
+
await this.validateFont(fontData, contentType);
|
|
4238
|
+
}
|
|
4239
|
+
// Store font in database
|
|
4240
|
+
const fontEntry = {
|
|
4241
|
+
key: fontKey,
|
|
4242
|
+
url: fontUrl,
|
|
4243
|
+
originalUrl: fontUrl,
|
|
4244
|
+
data: fontData,
|
|
4245
|
+
contentType,
|
|
4246
|
+
type: this.detectFontType(contentType, fontUrl),
|
|
4247
|
+
size: fontData.byteLength,
|
|
4248
|
+
lastModified: Date.now(),
|
|
4249
|
+
downloadedAt: new Date().toISOString(),
|
|
4250
|
+
downloadId: styleName,
|
|
4251
|
+
metadata: {
|
|
4252
|
+
userAgent: navigator.userAgent,
|
|
4253
|
+
downloadTimestamp: Date.now(),
|
|
4254
|
+
},
|
|
4255
|
+
expires: response.expires,
|
|
4256
|
+
};
|
|
4257
|
+
await db.put('fonts', fontEntry);
|
|
4258
|
+
progressTracker.update(1, fontKey);
|
|
4259
|
+
reportProgress(`Downloaded ${fontKey}`);
|
|
4260
|
+
totalSize += fontData.byteLength;
|
|
4261
|
+
downloadedFonts++;
|
|
4262
|
+
// Track font types
|
|
4263
|
+
const fontType = this.detectFontType(contentType, fontUrl);
|
|
3389
4264
|
fontsByType[fontType] = (fontsByType[fontType] || 0) + 1;
|
|
3390
4265
|
}
|
|
3391
4266
|
catch (error) {
|
|
@@ -3395,6 +4270,9 @@ class FontService {
|
|
|
3395
4270
|
url: fontUrl,
|
|
3396
4271
|
error: errorMessage,
|
|
3397
4272
|
});
|
|
4273
|
+
// A failed font is finished too; don't let the bar stall on it.
|
|
4274
|
+
progressTracker.update(1, fontUrl);
|
|
4275
|
+
reportProgress(`Failed: ${fontUrl}`);
|
|
3398
4276
|
// Only log detailed errors if not in quiet mode
|
|
3399
4277
|
if (!quietMode) {
|
|
3400
4278
|
fontLogger.error(`Failed to download font ${fontUrl}:`, errorMessage);
|
|
@@ -3407,6 +4285,7 @@ class FontService {
|
|
|
3407
4285
|
}, { batchSize });
|
|
3408
4286
|
const downloadTime = Date.now() - startTime;
|
|
3409
4287
|
const averageSpeed = downloadTime > 0 ? (totalSize / downloadTime) * 1000 : 0;
|
|
4288
|
+
reportProgress(`Font download complete: ${downloadedFonts} downloaded, ${skippedFonts} skipped, ${failedFonts} failed`);
|
|
3410
4289
|
return {
|
|
3411
4290
|
totalFonts: fontUrls.length,
|
|
3412
4291
|
downloadedFonts,
|
|
@@ -3733,6 +4612,10 @@ class SpriteService {
|
|
|
3733
4612
|
return !existingSprites.has(key);
|
|
3734
4613
|
});
|
|
3735
4614
|
skippedSprites = spriteUrls.length - urlsToDownload.length;
|
|
4615
|
+
// Skipped sprites count toward the total, so mark them done up front.
|
|
4616
|
+
if (skippedSprites > 0) {
|
|
4617
|
+
progressTracker.update(skippedSprites);
|
|
4618
|
+
}
|
|
3736
4619
|
}
|
|
3737
4620
|
// Check storage quota if enabled
|
|
3738
4621
|
if (storageQuotaCheck && 'storage' in navigator && 'estimate' in navigator.storage) {
|
|
@@ -3848,7 +4731,9 @@ class SpriteService {
|
|
|
3848
4731
|
error: errorMessage,
|
|
3849
4732
|
});
|
|
3850
4733
|
spriteLogger.error(`Failed to download sprite ${spriteUrl}:`, error);
|
|
3851
|
-
//
|
|
4734
|
+
// Advance progress on failure too — a failed item is finished, and
|
|
4735
|
+
// otherwise the bar stalls, then jumps to 100% at the end.
|
|
4736
|
+
progressTracker.update(1, this.extractSpriteName(spriteUrl));
|
|
3852
4737
|
if (onProgress) {
|
|
3853
4738
|
const progress = progressTracker.getProgress();
|
|
3854
4739
|
onProgress({
|
|
@@ -4162,11 +5047,78 @@ var spriteService$1 = /*#__PURE__*/Object.freeze({
|
|
|
4162
5047
|
verifyAndRepairSprites: verifyAndRepairSprites
|
|
4163
5048
|
});
|
|
4164
5049
|
|
|
4165
|
-
|
|
4166
|
-
function
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
5050
|
+
/** A style URL's identity, ignoring the access token (same style, different token). */
|
|
5051
|
+
function styleUrlIdentity(url) {
|
|
5052
|
+
try {
|
|
5053
|
+
const parsed = new URL(url);
|
|
5054
|
+
parsed.searchParams.delete('access_token');
|
|
5055
|
+
return parsed.toString();
|
|
5056
|
+
}
|
|
5057
|
+
catch {
|
|
5058
|
+
return url;
|
|
5059
|
+
}
|
|
5060
|
+
}
|
|
5061
|
+
/**
|
|
5062
|
+
* Pick the storage key for a style downloaded from `styleUrl`. `baseId` comes
|
|
5063
|
+
* from the style's `id`/`name`, which unrelated styles can share ("Streets"),
|
|
5064
|
+
* so a key already held by a style from a different URL is never reused:
|
|
5065
|
+
* fall back to the key this URL was stored under before, else a free
|
|
5066
|
+
* `${baseId}-N`. Entries without `originalUrl` (saved with
|
|
5067
|
+
* `includeMetadata: false`) are assumed to be the same style, so re-downloads
|
|
5068
|
+
* don't pile up duplicates; `carryOverRegions` keeps their regions either way.
|
|
5069
|
+
*/
|
|
5070
|
+
async function resolveStyleKey(db, baseId, styleUrl) {
|
|
5071
|
+
const target = styleUrlIdentity(styleUrl);
|
|
5072
|
+
const isSameStyle = (entry) => !!entry.originalUrl && styleUrlIdentity(entry.originalUrl) === target;
|
|
5073
|
+
const existing = await db.get('styles', baseId);
|
|
5074
|
+
if (!existing || !existing.originalUrl || isSameStyle(existing))
|
|
5075
|
+
return baseId;
|
|
5076
|
+
const all = await db.getAll('styles');
|
|
5077
|
+
const previous = all.find(isSameStyle);
|
|
5078
|
+
if (previous)
|
|
5079
|
+
return previous.key;
|
|
5080
|
+
const taken = new Set(all.map(entry => entry.key));
|
|
5081
|
+
let n = 2;
|
|
5082
|
+
while (taken.has(`${baseId}-${n}`))
|
|
5083
|
+
n++;
|
|
5084
|
+
logger.warn(`Style key "${baseId}" is used by a style from another URL; storing ${styleUrl} as "${baseId}-${n}"`);
|
|
5085
|
+
return `${baseId}-${n}`;
|
|
5086
|
+
}
|
|
5087
|
+
/**
|
|
5088
|
+
* Re-downloading a style replaces its stored entry. Carry over the regions
|
|
5089
|
+
* already attached to it and re-patch the fresh style for them — otherwise
|
|
5090
|
+
* every existing region is dropped and their tiles are orphaned. Patches a
|
|
5091
|
+
* copy, so callers can keep using the unpatched style for resource downloads.
|
|
5092
|
+
*/
|
|
5093
|
+
async function carryOverRegions(db, entry) {
|
|
5094
|
+
const existing = await db.get('styles', entry.key);
|
|
5095
|
+
const regions = existing?.regions;
|
|
5096
|
+
// Config set through `styleConfig` decides what renders and what's planned,
|
|
5097
|
+
// so it has to survive a fresh copy of the style.
|
|
5098
|
+
if (existing?.styleConfig) {
|
|
5099
|
+
entry.style = JSON.parse(JSON.stringify(entry.style));
|
|
5100
|
+
applyStyleConfig(entry.style, existing.styleConfig);
|
|
5101
|
+
entry.styleConfig = existing.styleConfig;
|
|
5102
|
+
}
|
|
5103
|
+
if (!Array.isArray(regions) || regions.length === 0)
|
|
5104
|
+
return;
|
|
5105
|
+
entry.regions = regions;
|
|
5106
|
+
entry.style = JSON.parse(JSON.stringify(entry.style));
|
|
5107
|
+
// Regions' extra sources were added to the old copy, so add them to this one.
|
|
5108
|
+
for (const region of regions) {
|
|
5109
|
+
injectExtraSources(entry.style, region.extraSources);
|
|
5110
|
+
}
|
|
5111
|
+
stashOriginalModels(entry);
|
|
5112
|
+
patchStyleForOffline(entry.style, entry.key, Math.max(...regions.map(region => region.maxZoom)), undefined, entry.key);
|
|
5113
|
+
const { storedModelNames } = await Promise.resolve().then(function () { return modelService$1; });
|
|
5114
|
+
restoreUnstoredModels(entry, await storedModelNames(entry.key));
|
|
5115
|
+
logger.debug(`Carried ${regions.length} region(s) over to re-downloaded style ${entry.key}`);
|
|
5116
|
+
}
|
|
5117
|
+
// Helper functions to work with StyleEntry structure
|
|
5118
|
+
function createStyleEntry(key, style, provider = 'auto', metadata = {}) {
|
|
5119
|
+
return {
|
|
5120
|
+
key,
|
|
5121
|
+
style,
|
|
4170
5122
|
provider,
|
|
4171
5123
|
regions: [],
|
|
4172
5124
|
fonts: [],
|
|
@@ -4264,8 +5216,11 @@ async function downloadStyles(stylesUrl, options = {}) {
|
|
|
4264
5216
|
layerCount: style.layers ? style.layers.length : 0,
|
|
4265
5217
|
});
|
|
4266
5218
|
}
|
|
5219
|
+
// Mapbox Standard now arrives flat, so resolveImports may not have run.
|
|
5220
|
+
sanitizeIndoorExpressions(style);
|
|
4267
5221
|
// Check if style already exists in the database
|
|
4268
5222
|
const db = await dbPromise;
|
|
5223
|
+
style.id = await resolveStyleKey(db, style.id, stylesUrl);
|
|
4269
5224
|
if (skipExisting) {
|
|
4270
5225
|
const existingStyle = await db.get('styles', style.id);
|
|
4271
5226
|
if (existingStyle && typeof existingStyle === 'object') {
|
|
@@ -4329,7 +5284,7 @@ async function downloadStyles(stylesUrl, options = {}) {
|
|
|
4329
5284
|
timeout: timeoutMs,
|
|
4330
5285
|
retryDelay: 1000,
|
|
4331
5286
|
});
|
|
4332
|
-
const sourceData = await sourceResponse.json();
|
|
5287
|
+
const sourceData = selectTileJsonVariant(await sourceResponse.json());
|
|
4333
5288
|
style.sources[sourceKey] = { ...source, ...sourceData }; // Embed source data
|
|
4334
5289
|
sourcesEmbedded++;
|
|
4335
5290
|
logger.debug(`Embedded source data for ${sourceKey}`);
|
|
@@ -4396,6 +5351,7 @@ async function downloadStyles(stylesUrl, options = {}) {
|
|
|
4396
5351
|
}
|
|
4397
5352
|
: {});
|
|
4398
5353
|
// Save the style
|
|
5354
|
+
await carryOverRegions(db, styleStorageItem);
|
|
4399
5355
|
await db.put('styles', styleStorageItem);
|
|
4400
5356
|
logger.debug('Style with sources saved successfully');
|
|
4401
5357
|
onProgress?.({
|
|
@@ -4669,18 +5625,26 @@ async function loadStyleById(styleId) {
|
|
|
4669
5625
|
return null;
|
|
4670
5626
|
}
|
|
4671
5627
|
}
|
|
5628
|
+
/** Delete every style and everything stored for them. */
|
|
4672
5629
|
async function deleteStyles() {
|
|
4673
5630
|
try {
|
|
4674
5631
|
const db = await dbPromise;
|
|
4675
|
-
await
|
|
5632
|
+
await Promise.all(['tiles', 'fonts', 'glyphs', 'sprites', 'models', 'styles'].map(store => db.clear(store)));
|
|
5633
|
+
clearAllCaches();
|
|
4676
5634
|
logger.debug('All styles deleted successfully');
|
|
4677
5635
|
}
|
|
4678
5636
|
catch (error) {
|
|
4679
5637
|
logger.error('Error deleting styles:', error);
|
|
4680
5638
|
}
|
|
4681
5639
|
}
|
|
5640
|
+
/**
|
|
5641
|
+
* Delete a style, its regions and everything stored for it. The entry goes last,
|
|
5642
|
+
* so a failure part-way leaves the style listed and able to be deleted again.
|
|
5643
|
+
*/
|
|
4682
5644
|
async function deleteStyleById(styleId) {
|
|
4683
5645
|
try {
|
|
5646
|
+
const { RegionService } = await Promise.resolve().then(function () { return regionService; });
|
|
5647
|
+
await new RegionService().deleteStyleData(styleId);
|
|
4684
5648
|
const db = await dbPromise;
|
|
4685
5649
|
await db.delete('styles', styleId);
|
|
4686
5650
|
logger.debug(`Style with ID ${styleId} deleted successfully`);
|
|
@@ -4689,6 +5653,24 @@ async function deleteStyleById(styleId) {
|
|
|
4689
5653
|
logger.error(`Error deleting style with ID ${styleId}:`, error);
|
|
4690
5654
|
}
|
|
4691
5655
|
}
|
|
5656
|
+
/**
|
|
5657
|
+
* Bytes stored for each style, by style id: its tiles, and its other resources
|
|
5658
|
+
* (glyphs, sprites, models and fonts). Tiles shared by regions count once.
|
|
5659
|
+
*/
|
|
5660
|
+
async function getStyleStorageSizes() {
|
|
5661
|
+
const db = await dbPromise;
|
|
5662
|
+
const sizes = {};
|
|
5663
|
+
for (const storeName of ['tiles', 'glyphs', 'sprites', 'models', 'fonts']) {
|
|
5664
|
+
for await (const cursor of db.transaction(storeName).store) {
|
|
5665
|
+
const { styleId, size } = cursor.value;
|
|
5666
|
+
if (!styleId)
|
|
5667
|
+
continue;
|
|
5668
|
+
sizes[styleId] ??= { tiles: 0, resources: 0 };
|
|
5669
|
+
sizes[styleId][storeName === 'tiles' ? 'tiles' : 'resources'] += size ?? 0;
|
|
5670
|
+
}
|
|
5671
|
+
}
|
|
5672
|
+
return sizes;
|
|
5673
|
+
}
|
|
4692
5674
|
/**
|
|
4693
5675
|
* Get enhanced style statistics
|
|
4694
5676
|
*/
|
|
@@ -4914,9 +5896,10 @@ async function isStyleDownloaded(styleId, styleUrl) {
|
|
|
4914
5896
|
* Enhanced style download that supports both Mapbox GL and MapLibre GL
|
|
4915
5897
|
*/
|
|
4916
5898
|
async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
4917
|
-
const { provider: explicitProvider, accessToken: explicitAccessToken, forceProvider = false, skipExisting = false, enableSourceEmbedding = true, maxRetries = 3, timeoutMs = 30000, } = options;
|
|
5899
|
+
const { provider: explicitProvider, accessToken: explicitAccessToken, forceProvider = false, skipExisting = false, enableSourceEmbedding = true, maxRetries = 3, timeoutMs = 30000, onProgress, } = options;
|
|
4918
5900
|
const startTime = Date.now();
|
|
4919
5901
|
let sourcesEmbedded = 0;
|
|
5902
|
+
const emit = (percentage, message) => onProgress?.({ completed: percentage, total: 100, percentage, message, errors: [] });
|
|
4920
5903
|
logger.debug(`Downloading style from: ${styleUrl}`);
|
|
4921
5904
|
try {
|
|
4922
5905
|
// Auto-detect provider if not explicitly set
|
|
@@ -4937,6 +5920,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
4937
5920
|
fetchUrl = normalizeStyleUrl(styleUrl, extractedToken);
|
|
4938
5921
|
}
|
|
4939
5922
|
// Fetch the style
|
|
5923
|
+
emit(0, 'Fetching style');
|
|
4940
5924
|
const response = await fetchWithRetry(fetchUrl, {
|
|
4941
5925
|
timeout: timeoutMs,
|
|
4942
5926
|
retries: maxRetries,
|
|
@@ -4945,6 +5929,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
4945
5929
|
throw new Error(`Failed to fetch style: ${response.status} ${response.statusText}`);
|
|
4946
5930
|
}
|
|
4947
5931
|
const style = (await response.json());
|
|
5932
|
+
emit(20, 'Style fetched');
|
|
4948
5933
|
// Resolve imports (Mapbox Standard / compositional styles)
|
|
4949
5934
|
if (hasImports(style)) {
|
|
4950
5935
|
const importToken = extractedToken || '';
|
|
@@ -4958,13 +5943,19 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
4958
5943
|
layerCount: style.layers ? style.layers.length : 0,
|
|
4959
5944
|
});
|
|
4960
5945
|
}
|
|
5946
|
+
// Mapbox Standard now arrives flat, so resolveImports may not have run.
|
|
5947
|
+
sanitizeIndoorExpressions(style);
|
|
5948
|
+
emit(40, 'Processing style sources');
|
|
4961
5949
|
// Process style for the detected provider
|
|
4962
5950
|
const processedStyle = processStyleSources(style, detectedProvider, extractedToken);
|
|
4963
5951
|
// Embed TileJSON data into sources for reliable offline tile downloads
|
|
4964
5952
|
// This fetches each source's TileJSON URL and merges the result (including tiles array,
|
|
4965
5953
|
// minzoom, maxzoom) into the source config, so tile downloads don't need extra fetches
|
|
4966
5954
|
if (enableSourceEmbedding && processedStyle.sources) {
|
|
4967
|
-
|
|
5955
|
+
const sourceEntries = Object.entries(processedStyle.sources);
|
|
5956
|
+
let sourcesVisited = 0;
|
|
5957
|
+
for (const [sourceKey, sourceConfig] of sourceEntries) {
|
|
5958
|
+
emit(40 + Math.round((sourcesVisited++ / Math.max(1, sourceEntries.length)) * 40), `Embedding source ${sourceKey}`);
|
|
4968
5959
|
const source = sourceConfig;
|
|
4969
5960
|
if (source.url && typeof source.url === 'string' && !source.tiles) {
|
|
4970
5961
|
const sourceUrl = source.url;
|
|
@@ -4977,7 +5968,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
4977
5968
|
timeout: timeoutMs,
|
|
4978
5969
|
retryDelay: 1000,
|
|
4979
5970
|
});
|
|
4980
|
-
const sourceData = await sourceResponse.json();
|
|
5971
|
+
const sourceData = selectTileJsonVariant(await sourceResponse.json());
|
|
4981
5972
|
// Only copy valid MapLibre/Mapbox source properties from TileJSON
|
|
4982
5973
|
// Copying unknown properties (like "name", "tilejson") causes
|
|
4983
5974
|
// MapLibre to throw "unknown property" errors on setStyle()
|
|
@@ -5011,6 +6002,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5011
6002
|
}
|
|
5012
6003
|
}
|
|
5013
6004
|
}
|
|
6005
|
+
emit(80, 'Saving style');
|
|
5014
6006
|
// Set default glyphs URL if not present
|
|
5015
6007
|
if (!processedStyle.glyphs) {
|
|
5016
6008
|
processedStyle.glyphs = GLYPH_CONFIG.DEFAULT_URL;
|
|
@@ -5022,13 +6014,17 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5022
6014
|
throw new Error(`Style validation failed: ${validation.errors.join(', ')}`);
|
|
5023
6015
|
}
|
|
5024
6016
|
// Generate style ID
|
|
5025
|
-
const styleId = processedStyle.name?.toLowerCase().replace(/\s+/g, '-') || `style-${Date.now()}`;
|
|
5026
|
-
// Check if style already exists
|
|
5027
6017
|
const db = await dbPromise;
|
|
6018
|
+
const styleId = await resolveStyleKey(db,
|
|
6019
|
+
// The key goes into resource keys ("{key}:...") and idb:// URLs, so ":" or "/"
|
|
6020
|
+
// in a style's name would make its data look like another style's or break its URLs.
|
|
6021
|
+
processedStyle.name?.toLowerCase().replace(/[\s:/\\?#%]+/g, '-') || `style-${Date.now()}`, styleUrl);
|
|
6022
|
+
// Check if style already exists
|
|
5028
6023
|
if (skipExisting) {
|
|
5029
6024
|
const existingStyle = await db.get('styles', styleId);
|
|
5030
6025
|
if (existingStyle) {
|
|
5031
6026
|
logger.debug(`Style ${styleId} already exists, skipping`);
|
|
6027
|
+
emit(100, 'Style already exists');
|
|
5032
6028
|
return {
|
|
5033
6029
|
styleId,
|
|
5034
6030
|
success: true,
|
|
@@ -5059,7 +6055,9 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5059
6055
|
accessToken: extractedToken,
|
|
5060
6056
|
});
|
|
5061
6057
|
// Save style
|
|
6058
|
+
await carryOverRegions(db, styleEntry);
|
|
5062
6059
|
await db.put('styles', styleEntry);
|
|
6060
|
+
emit(100, 'Style saved');
|
|
5063
6061
|
const downloadTime = Date.now() - startTime;
|
|
5064
6062
|
logger.info(`Style ${styleId} downloaded in ${downloadTime}ms`);
|
|
5065
6063
|
return {
|
|
@@ -5108,12 +6106,38 @@ var styleService = /*#__PURE__*/Object.freeze({
|
|
|
5108
6106
|
downloadStyleWithProvider: downloadStyleWithProvider,
|
|
5109
6107
|
downloadStyles: downloadStyles,
|
|
5110
6108
|
getStyleStats: getStyleStats,
|
|
6109
|
+
getStyleStorageSizes: getStyleStorageSizes,
|
|
5111
6110
|
isStyleDownloaded: isStyleDownloaded,
|
|
5112
6111
|
loadStyleById: loadStyleById,
|
|
5113
6112
|
loadStyles: loadStyles
|
|
5114
6113
|
});
|
|
5115
6114
|
|
|
5116
6115
|
const regionLogger$1 = logger.scope('RegionService');
|
|
6116
|
+
/**
|
|
6117
|
+
* Where each phase sits in a download's overall progress, as [start, end] in
|
|
6118
|
+
* percent. Tiles get the most because they're usually most of the bytes.
|
|
6119
|
+
*/
|
|
6120
|
+
const PHASE_PROGRESS_RANGES = {
|
|
6121
|
+
style: [0, 5],
|
|
6122
|
+
sprites: [5, 10],
|
|
6123
|
+
glyphs: [10, 25],
|
|
6124
|
+
models: [25, 30],
|
|
6125
|
+
tiles: [30, 99],
|
|
6126
|
+
metadata: [99, 100],
|
|
6127
|
+
};
|
|
6128
|
+
/** True for a Mapbox-hosted style sprite, which has an `iconset.pbf` beside it. */
|
|
6129
|
+
function hasMapboxIconset(spriteUrl) {
|
|
6130
|
+
try {
|
|
6131
|
+
const { hostname, pathname } = new URL(spriteUrl);
|
|
6132
|
+
return (hostname === 'api.mapbox.com' &&
|
|
6133
|
+
pathname.startsWith('/styles/v1/') &&
|
|
6134
|
+
pathname.endsWith('/sprite'));
|
|
6135
|
+
}
|
|
6136
|
+
catch {
|
|
6137
|
+
// Not an absolute URL, so not a Mapbox-hosted sprite.
|
|
6138
|
+
return false;
|
|
6139
|
+
}
|
|
6140
|
+
}
|
|
5117
6141
|
/**
|
|
5118
6142
|
* True when `key` belongs to the given styleId.
|
|
5119
6143
|
*
|
|
@@ -5130,44 +6154,10 @@ function resourceKeyBelongsToStyle(key, styleId) {
|
|
|
5130
6154
|
}
|
|
5131
6155
|
class RegionService {
|
|
5132
6156
|
/**
|
|
5133
|
-
*
|
|
5134
|
-
|
|
5135
|
-
tileOverlapsWithRegion(tileX, tileY, tileZ, region) {
|
|
5136
|
-
// Validate region bounds exist and have correct structure
|
|
5137
|
-
if (!region.bounds ||
|
|
5138
|
-
!Array.isArray(region.bounds) ||
|
|
5139
|
-
region.bounds.length !== 2 ||
|
|
5140
|
-
!Array.isArray(region.bounds[0]) ||
|
|
5141
|
-
!Array.isArray(region.bounds[1]) ||
|
|
5142
|
-
region.bounds[0].length !== 2 ||
|
|
5143
|
-
region.bounds[1].length !== 2) {
|
|
5144
|
-
regionLogger$1.warn('Invalid region bounds structure:', region.bounds);
|
|
5145
|
-
return false;
|
|
5146
|
-
}
|
|
5147
|
-
// Get tile bounds using tilebelt
|
|
5148
|
-
const tileBounds = tilebelt.tileToBBOX([tileX, tileY, tileZ]);
|
|
5149
|
-
const [tileWest, tileSouth, tileEast, tileNorth] = tileBounds;
|
|
5150
|
-
// Region bounds format: [[west, south], [east, north]]
|
|
5151
|
-
const regionWest = region.bounds[0][0];
|
|
5152
|
-
const regionSouth = region.bounds[0][1];
|
|
5153
|
-
const regionEast = region.bounds[1][0];
|
|
5154
|
-
const regionNorth = region.bounds[1][1];
|
|
5155
|
-
// Check if bounding boxes overlap
|
|
5156
|
-
return !(tileEast < regionWest ||
|
|
5157
|
-
tileWest > regionEast ||
|
|
5158
|
-
tileNorth < regionSouth ||
|
|
5159
|
-
tileSouth > regionNorth);
|
|
5160
|
-
}
|
|
5161
|
-
/**
|
|
5162
|
-
* Check if a tile overlaps with any of the given regions
|
|
5163
|
-
*/
|
|
5164
|
-
tileOverlapsWithAnyRegion(tileX, tileY, tileZ, regions) {
|
|
5165
|
-
return regions.some(region => this.tileOverlapsWithRegion(tileX, tileY, tileZ, region));
|
|
5166
|
-
}
|
|
5167
|
-
/**
|
|
5168
|
-
* Delete tiles that don't overlap with any remaining regions
|
|
6157
|
+
* Delete tiles no remaining region needs — outside every remaining region's
|
|
6158
|
+
* bounds, or at a zoom none of them downloads for that tile's source.
|
|
5169
6159
|
*/
|
|
5170
|
-
async deleteNonOverlappingTiles(styleId, remainingRegions) {
|
|
6160
|
+
async deleteNonOverlappingTiles(styleId, remainingRegions, sources) {
|
|
5171
6161
|
const db = await dbPromise;
|
|
5172
6162
|
let deletedCount = 0;
|
|
5173
6163
|
regionLogger$1.debug(`Checking tiles for style ${styleId} against ${remainingRegions.length} remaining regions`);
|
|
@@ -5184,9 +6174,13 @@ class RegionService {
|
|
|
5184
6174
|
regionLogger$1.warn(`Invalid tile key format: ${tileEntry.key}`);
|
|
5185
6175
|
continue;
|
|
5186
6176
|
}
|
|
5187
|
-
const
|
|
5188
|
-
|
|
5189
|
-
|
|
6177
|
+
const tile = {
|
|
6178
|
+
sourceId: tileEntry.sourceId ?? parsed.sourceId,
|
|
6179
|
+
z: parsed.z,
|
|
6180
|
+
x: parsed.x,
|
|
6181
|
+
y: parsed.y,
|
|
6182
|
+
};
|
|
6183
|
+
if (!remainingRegions.some(region => tileBelongsToRegion(tile, region, sources))) {
|
|
5190
6184
|
regionLogger$1.debug(`Deleting non-overlapping tile: ${tileEntry.key}`);
|
|
5191
6185
|
await cursor.delete();
|
|
5192
6186
|
deletedCount++;
|
|
@@ -5240,6 +6234,46 @@ class RegionService {
|
|
|
5240
6234
|
/**
|
|
5241
6235
|
* Delete all tiles for a style
|
|
5242
6236
|
*/
|
|
6237
|
+
/**
|
|
6238
|
+
* Delete everything stored for a style except its own entry: tiles, fonts,
|
|
6239
|
+
* glyphs, sprites and models.
|
|
6240
|
+
*/
|
|
6241
|
+
/**
|
|
6242
|
+
* Undo what a failed or cancelled download left behind: its partly stored
|
|
6243
|
+
* tiles and its config change. A style this download created, which no other
|
|
6244
|
+
* region uses, goes entirely, and so does a style deleted during the download.
|
|
6245
|
+
*/
|
|
6246
|
+
async cleanUpFailedDownload(styleId, createdStyle, configBefore) {
|
|
6247
|
+
try {
|
|
6248
|
+
const db = await dbPromise;
|
|
6249
|
+
const entry = await db.get('styles', styleId);
|
|
6250
|
+
if (!entry) {
|
|
6251
|
+
await this.deleteStyleData(styleId);
|
|
6252
|
+
return;
|
|
6253
|
+
}
|
|
6254
|
+
const regions = Array.isArray(entry.regions) ? entry.regions : [];
|
|
6255
|
+
if (createdStyle && regions.length === 0) {
|
|
6256
|
+
await this.deleteStyleData(styleId);
|
|
6257
|
+
await db.delete('styles', styleId);
|
|
6258
|
+
return;
|
|
6259
|
+
}
|
|
6260
|
+
if (configBefore) {
|
|
6261
|
+
entry.style.schema = configBefore.schema;
|
|
6262
|
+
entry.styleConfig = configBefore.values;
|
|
6263
|
+
await db.put('styles', entry);
|
|
6264
|
+
}
|
|
6265
|
+
await this.deleteNonOverlappingTiles(styleId, regions, (entry.style?.sources ?? {}));
|
|
6266
|
+
clearAllCaches();
|
|
6267
|
+
}
|
|
6268
|
+
catch (error) {
|
|
6269
|
+
regionLogger$1.warn(`Could not clean up after the failed download on ${styleId}:`, error);
|
|
6270
|
+
}
|
|
6271
|
+
}
|
|
6272
|
+
async deleteStyleData(styleId) {
|
|
6273
|
+
await this.deleteAllStyleTiles(styleId);
|
|
6274
|
+
await this.deleteStyleResources(styleId);
|
|
6275
|
+
clearAllCaches();
|
|
6276
|
+
}
|
|
5243
6277
|
async deleteAllStyleTiles(styleId) {
|
|
5244
6278
|
const db = await dbPromise;
|
|
5245
6279
|
let deletedCount = 0;
|
|
@@ -5286,23 +6320,15 @@ class RegionService {
|
|
|
5286
6320
|
styleEntry.regions = [];
|
|
5287
6321
|
}
|
|
5288
6322
|
// Inject extra sources into the style so they get patched for offline use
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
};
|
|
5299
|
-
regionLogger$1.debug(`Injected extra source into style: ${extra.id}`);
|
|
5300
|
-
}
|
|
5301
|
-
}
|
|
5302
|
-
}
|
|
5303
|
-
// Patch style for offline use with the region's maxZoom and tileExtension
|
|
5304
|
-
// Pass styleId for sprites since they're stored with the style ID, not region ID
|
|
5305
|
-
patchStyleForOffline(styleEntry.style, region.id, region.maxZoom, region.tileExtension, styleId);
|
|
6323
|
+
injectExtraSources(styleEntry.style, region.extraSources);
|
|
6324
|
+
stashOriginalModels(styleEntry);
|
|
6325
|
+
// Patch style for offline use. URLs are keyed by styleId (resources are
|
|
6326
|
+
// stored per style), and maxzoom is capped at the deepest region on the
|
|
6327
|
+
// style — not just this one — so adding a shallow region doesn't hide a
|
|
6328
|
+
// deeper sibling's tiles.
|
|
6329
|
+
const otherRegions = styleEntry.regions.filter((r) => r.id !== region.id && r.regionId !== region.id);
|
|
6330
|
+
patchStyleForOffline(styleEntry.style, region.id, Math.max(region.maxZoom, ...otherRegions.map(r => r.maxZoom)), region.tileExtension, styleId);
|
|
6331
|
+
restoreUnstoredModels(styleEntry, await storedModelNames(styleId));
|
|
5306
6332
|
// Add or update region metadata on the style entry.
|
|
5307
6333
|
// `expiry` is stored as an absolute timestamp (per the type doc and all
|
|
5308
6334
|
// readers in cleanupService/cleanupManagement/RegionList). If the caller
|
|
@@ -5330,6 +6356,8 @@ class RegionService {
|
|
|
5330
6356
|
styleEntry.regions.push(regionWithMeta);
|
|
5331
6357
|
}
|
|
5332
6358
|
await db.put('styles', styleEntry);
|
|
6359
|
+
// Drop in-memory region→style / tile caches so the fetch handler sees the new region.
|
|
6360
|
+
clearAllCaches();
|
|
5333
6361
|
}
|
|
5334
6362
|
async loadRegion(region, options = {}) {
|
|
5335
6363
|
return this.downloadRegion(region, options);
|
|
@@ -5343,17 +6371,22 @@ class RegionService {
|
|
|
5343
6371
|
if (!region.styleUrl) {
|
|
5344
6372
|
throw new Error('Region must have a styleUrl');
|
|
5345
6373
|
}
|
|
5346
|
-
|
|
6374
|
+
validateZoomLevels(region.minZoom, region.maxZoom);
|
|
6375
|
+
const { onProgress, provider = 'auto', accessToken, skipGlyphs = false, skipSprites = false, skipModels = false, glyphRanges, tileOptions, styleConfig, signal, } = options;
|
|
6376
|
+
let overallPercentage = 0;
|
|
5347
6377
|
const emit = (phase, completed, total, message) => {
|
|
5348
6378
|
if (!onProgress)
|
|
5349
6379
|
return;
|
|
5350
6380
|
const safeTotal = total > 0 ? total : 1;
|
|
5351
6381
|
const clamped = Math.min(completed, safeTotal);
|
|
6382
|
+
const [start, end] = PHASE_PROGRESS_RANGES[phase];
|
|
6383
|
+
overallPercentage = Math.max(overallPercentage, start + (end - start) * (clamped / safeTotal));
|
|
5352
6384
|
onProgress({
|
|
5353
6385
|
phase,
|
|
5354
6386
|
completed: clamped,
|
|
5355
|
-
total
|
|
6387
|
+
total,
|
|
5356
6388
|
percentage: (clamped / safeTotal) * 100,
|
|
6389
|
+
overallPercentage,
|
|
5357
6390
|
message,
|
|
5358
6391
|
});
|
|
5359
6392
|
};
|
|
@@ -5392,6 +6425,18 @@ class RegionService {
|
|
|
5392
6425
|
const originalSpriteUrl = (styleEntry.originalSpriteUrl ??
|
|
5393
6426
|
storedStyle.sprite);
|
|
5394
6427
|
const originalGlyphsUrl = styleEntry.originalGlyphsUrl ?? storedStyle.glyphs;
|
|
6428
|
+
// Applied before the resource phases, which all plan from the style's config.
|
|
6429
|
+
// Kept so a failed download can put the config back as it was.
|
|
6430
|
+
const configBefore = styleConfig
|
|
6431
|
+
? JSON.parse(JSON.stringify({ schema: storedStyle.schema, values: styleEntry.styleConfig }))
|
|
6432
|
+
: undefined;
|
|
6433
|
+
if (styleConfig) {
|
|
6434
|
+
applyStyleConfig(storedStyle, styleConfig);
|
|
6435
|
+
styleEntry.styleConfig = { ...styleEntry.styleConfig, ...styleConfig };
|
|
6436
|
+
}
|
|
6437
|
+
// A reused style may predate download-time sanitizing.
|
|
6438
|
+
sanitizeIndoorExpressions(storedStyle);
|
|
6439
|
+
await (await dbPromise).put('styles', styleEntry);
|
|
5395
6440
|
// 2. Sprites
|
|
5396
6441
|
const spriteResults = [];
|
|
5397
6442
|
if (!skipSprites) {
|
|
@@ -5402,16 +6447,17 @@ class RegionService {
|
|
|
5402
6447
|
// sibling is also served under the same /styles/v1/.../<hash>/ path
|
|
5403
6448
|
// — we detect that case per-source below and append it to the list.
|
|
5404
6449
|
const baseSuffixes = ['.json', '.png', '@2x.json', '@2x.png'];
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
6450
|
+
const resolveSpriteBase = (url) => isMapboxProtocol(url) && effectiveAccessToken
|
|
6451
|
+
? resolveMapboxUrl(url, effectiveAccessToken)
|
|
6452
|
+
: url;
|
|
6453
|
+
const totalFiles = spriteSources.reduce((sum, source) => {
|
|
6454
|
+
const base = resolveSpriteBase(source.url).split('?')[0];
|
|
6455
|
+
return sum + baseSuffixes.length + (hasMapboxIconset(base) ? 1 : 0);
|
|
6456
|
+
}, 0);
|
|
5408
6457
|
let completed = 0;
|
|
5409
6458
|
emit('sprites', 0, totalFiles, 'Downloading sprites');
|
|
5410
6459
|
for (const source of spriteSources) {
|
|
5411
|
-
|
|
5412
|
-
if (isMapboxProtocol(spriteBase) && effectiveAccessToken) {
|
|
5413
|
-
spriteBase = resolveMapboxUrl(spriteBase, effectiveAccessToken);
|
|
5414
|
-
}
|
|
6460
|
+
const spriteBase = resolveSpriteBase(source.url);
|
|
5415
6461
|
const qIndex = spriteBase.indexOf('?');
|
|
5416
6462
|
const suffixes = [...baseSuffixes];
|
|
5417
6463
|
// Mapbox Standard serves an iconset.pbf alongside the sprite under
|
|
@@ -5419,18 +6465,7 @@ class RegionService {
|
|
|
5419
6465
|
// /styles/v1/{owner}/{style}/{hash}/iconset.pbf. The last path
|
|
5420
6466
|
// segment is `sprite`, so replacing it with `iconset.pbf` works.
|
|
5421
6467
|
const pathWithoutQuery = qIndex !== -1 ? spriteBase.slice(0, qIndex) : spriteBase;
|
|
5422
|
-
|
|
5423
|
-
try {
|
|
5424
|
-
const parsed = new URL(pathWithoutQuery);
|
|
5425
|
-
isMapboxStandardSprite =
|
|
5426
|
-
parsed.hostname === 'api.mapbox.com' &&
|
|
5427
|
-
parsed.pathname.startsWith('/styles/v1/') &&
|
|
5428
|
-
parsed.pathname.endsWith('/sprite');
|
|
5429
|
-
}
|
|
5430
|
-
catch {
|
|
5431
|
-
// Non-URL sprite base (e.g. relative); not a Mapbox Standard sprite.
|
|
5432
|
-
}
|
|
5433
|
-
if (isMapboxStandardSprite) {
|
|
6468
|
+
if (hasMapboxIconset(pathWithoutQuery)) {
|
|
5434
6469
|
// The path-rewrite suffix replaces the trailing `sprite` segment.
|
|
5435
6470
|
suffixes.push('__ICONSET__');
|
|
5436
6471
|
}
|
|
@@ -5494,14 +6529,16 @@ class RegionService {
|
|
|
5494
6529
|
// (plain string or `{ uri }`) — we accept both.
|
|
5495
6530
|
let modelResult;
|
|
5496
6531
|
if (!skipModels && storedStyle.models) {
|
|
5497
|
-
|
|
6532
|
+
// Upstream URLs, from before patching, so models that failed last time are retried.
|
|
6533
|
+
const rawModels = (styleEntry.originalModels ?? storedStyle.models);
|
|
6534
|
+
const alreadyStored = await storedModelNames(styleId);
|
|
5498
6535
|
const resolved = {};
|
|
5499
6536
|
for (const [name, value] of Object.entries(rawModels)) {
|
|
5500
6537
|
const uri = typeof value === 'string' ? value : value?.uri;
|
|
5501
|
-
if (!uri)
|
|
6538
|
+
if (!uri || alreadyStored.has(name))
|
|
5502
6539
|
continue;
|
|
5503
6540
|
if (uri.startsWith('idb://'))
|
|
5504
|
-
continue; //
|
|
6541
|
+
continue; // patched before upstream URLs were kept
|
|
5505
6542
|
const httpUrl = isMapboxProtocol(uri) && effectiveAccessToken
|
|
5506
6543
|
? resolveMapboxUrl(uri, effectiveAccessToken)
|
|
5507
6544
|
: uri;
|
|
@@ -5524,29 +6561,56 @@ class RegionService {
|
|
|
5524
6561
|
}
|
|
5525
6562
|
}
|
|
5526
6563
|
}
|
|
6564
|
+
// Renderer code fetched from api.mapbox.com at render time (3D decoders,
|
|
6565
|
+
// RTL text, procedural buildings). Never throws; see cacheMapboxRuntimeAssets.
|
|
6566
|
+
let runtimeAssetsCached;
|
|
6567
|
+
if (/mapbox:\/\/|api\.mapbox\.com/.test(`${region.styleUrl ?? ''} ${originalGlyphsUrl ?? ''}`)) {
|
|
6568
|
+
const { cacheMapboxRuntimeAssets } = await Promise.resolve().then(function () { return runtimeAssetService; });
|
|
6569
|
+
runtimeAssetsCached = await cacheMapboxRuntimeAssets();
|
|
6570
|
+
}
|
|
5527
6571
|
// 5. Tiles — use the stored (source-embedded) style, which still has HTTP tile URLs
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
6572
|
+
let tileResult;
|
|
6573
|
+
try {
|
|
6574
|
+
signal?.throwIfAborted();
|
|
6575
|
+
const { downloadTiles } = await Promise.resolve().then(function () { return tileService$1; });
|
|
6576
|
+
const regionForTiles = { ...region, styleId };
|
|
6577
|
+
// The tile count isn't known until the plan is made, which can take a while.
|
|
6578
|
+
emit('tiles', 0, 0, 'Planning tiles');
|
|
6579
|
+
tileResult = await downloadTiles(regionForTiles, storedStyle, styleId, {
|
|
6580
|
+
skipExisting: false,
|
|
6581
|
+
batchSize: 20,
|
|
6582
|
+
...tileOptions,
|
|
6583
|
+
signal,
|
|
6584
|
+
onProgress: progress => {
|
|
6585
|
+
emit('tiles', progress.completed, progress.total, progress.message ?? 'Downloading tiles');
|
|
6586
|
+
tileOptions?.onProgress?.(progress);
|
|
6587
|
+
},
|
|
6588
|
+
});
|
|
6589
|
+
signal?.throwIfAborted();
|
|
6590
|
+
// A region with no tiles at all isn't worth saving as if it had worked, as happens
|
|
6591
|
+
// with an expired token. Partial failures still save: tileResult reports them.
|
|
6592
|
+
const { failedTiles, downloadedTiles, skippedTiles } = tileResult;
|
|
6593
|
+
if (failedTiles > 0 && downloadedTiles === 0 && skippedTiles === 0) {
|
|
6594
|
+
throw new Error(`None of the ${failedTiles} tiles could be downloaded. Check your connection and access token.`);
|
|
6595
|
+
}
|
|
6596
|
+
// 6. Metadata — must run last, since addRegion patches style URLs to idb://.
|
|
6597
|
+
// Do NOT auto-fill tileExtension from tileResult: that's only the first
|
|
6598
|
+
// source's extension, and addRegion feeds it to patchStyleForOffline which
|
|
6599
|
+
// would override ALL sources — breaking mixed raster+vector styles. The
|
|
6600
|
+
// patcher extracts per-source extensions from tile URL patterns when
|
|
6601
|
+
// tileExtension is undefined. Callers who need to force an extension can
|
|
6602
|
+
// still pass `region.tileExtension` explicitly.
|
|
6603
|
+
emit('metadata', 0, 1, 'Saving region metadata');
|
|
6604
|
+
if (!(await (await dbPromise).get('styles', styleId))) {
|
|
6605
|
+
throw new Error('The style was deleted while this region was downloading.');
|
|
6606
|
+
}
|
|
6607
|
+
await this.addRegion({ ...region, styleId });
|
|
6608
|
+
emit('metadata', 1, 1, 'Region ready');
|
|
6609
|
+
}
|
|
6610
|
+
catch (error) {
|
|
6611
|
+
await this.cleanUpFailedDownload(styleId, Boolean(styleResult), configBefore);
|
|
6612
|
+
throw error;
|
|
6613
|
+
}
|
|
5550
6614
|
return {
|
|
5551
6615
|
regionId: region.id,
|
|
5552
6616
|
styleId,
|
|
@@ -5554,6 +6618,7 @@ class RegionService {
|
|
|
5554
6618
|
spriteResults,
|
|
5555
6619
|
glyphResult,
|
|
5556
6620
|
modelResult,
|
|
6621
|
+
runtimeAssetsCached,
|
|
5557
6622
|
tileResult,
|
|
5558
6623
|
};
|
|
5559
6624
|
}
|
|
@@ -5578,12 +6643,20 @@ class RegionService {
|
|
|
5578
6643
|
}
|
|
5579
6644
|
return undefined;
|
|
5580
6645
|
}
|
|
5581
|
-
|
|
6646
|
+
/**
|
|
6647
|
+
* Delete a region and the tiles/resources only it needed.
|
|
6648
|
+
* @param styleId - The style holding the region. Region ids are only unique
|
|
6649
|
+
* within a style, so pass it whenever known; without it the first style
|
|
6650
|
+
* containing `regionId` is used.
|
|
6651
|
+
*/
|
|
6652
|
+
async deleteRegion(regionId, styleId) {
|
|
5582
6653
|
const db = await dbPromise;
|
|
5583
|
-
regionLogger$1.debug(`Deleting region: ${regionId}`);
|
|
6654
|
+
regionLogger$1.debug(`Deleting region: ${regionId}${styleId ? ` from style ${styleId}` : ''}`);
|
|
5584
6655
|
try {
|
|
5585
6656
|
// Find which style contains this region
|
|
5586
|
-
const allStyles =
|
|
6657
|
+
const allStyles = styleId
|
|
6658
|
+
? [await db.get('styles', styleId)].filter((entry) => entry !== undefined)
|
|
6659
|
+
: await db.getAll('styles');
|
|
5587
6660
|
regionLogger$1.debug(`Found ${allStyles.length} styles to search through`);
|
|
5588
6661
|
let foundStyle = null;
|
|
5589
6662
|
let foundRegion = null;
|
|
@@ -5602,7 +6675,7 @@ class RegionService {
|
|
|
5602
6675
|
}
|
|
5603
6676
|
}
|
|
5604
6677
|
if (!foundStyle || !foundRegion) {
|
|
5605
|
-
regionLogger$1.warn(`Region ${regionId} not found in any style`);
|
|
6678
|
+
regionLogger$1.warn(`Region ${regionId} not found in ${styleId ? `style ${styleId}` : 'any style'}`);
|
|
5606
6679
|
return;
|
|
5607
6680
|
}
|
|
5608
6681
|
regionLogger$1.debug(`Found region in style: ${foundStyle.key}`, foundRegion);
|
|
@@ -5613,11 +6686,7 @@ class RegionService {
|
|
|
5613
6686
|
// If this was the last region for the style, delete the entire style and all its resources
|
|
5614
6687
|
if (remainingRegions.length === 0) {
|
|
5615
6688
|
regionLogger$1.info(`Style ${foundStyle.key} has no remaining regions, deleting entire style and all resources`);
|
|
5616
|
-
|
|
5617
|
-
const deletedTileCount = await this.deleteAllStyleTiles(foundStyle.key);
|
|
5618
|
-
regionLogger$1.debug(`Deleted ${deletedTileCount} tiles`);
|
|
5619
|
-
// Delete all style resources (fonts, glyphs, sprites)
|
|
5620
|
-
await this.deleteStyleResources(foundStyle.key);
|
|
6689
|
+
await this.deleteStyleData(foundStyle.key);
|
|
5621
6690
|
// Delete the style entry itself
|
|
5622
6691
|
await db.delete('styles', foundStyle.key);
|
|
5623
6692
|
regionLogger$1.info(`Completely deleted style ${foundStyle.key} and all associated resources`);
|
|
@@ -5625,11 +6694,18 @@ class RegionService {
|
|
|
5625
6694
|
else {
|
|
5626
6695
|
regionLogger$1.debug(`Style ${foundStyle.key} has ${remainingRegions.length} remaining regions, cleaning up non-overlapping tiles`);
|
|
5627
6696
|
// Delete tiles that don't overlap with any remaining regions
|
|
5628
|
-
const deletedTileCount = await this.deleteNonOverlappingTiles(foundStyle.key, remainingRegions);
|
|
6697
|
+
const deletedTileCount = await this.deleteNonOverlappingTiles(foundStyle.key, remainingRegions, (foundStyle.style?.sources ?? {}));
|
|
6698
|
+
// Re-patch for the remaining regions: shrink maxzoom to the deepest
|
|
6699
|
+
// survivor, and re-key URLs by styleId. Styles patched before URLs
|
|
6700
|
+
// were style-keyed still point at `idb://{deletedRegionId}/...`,
|
|
6701
|
+
// which no longer resolves once the region is gone.
|
|
6702
|
+
patchStyleForOffline(foundStyle.style, foundStyle.key, Math.max(...remainingRegions.map((r) => r.maxZoom)), undefined, foundStyle.key);
|
|
6703
|
+
restoreUnstoredModels(foundStyle, await storedModelNames(foundStyle.key));
|
|
5629
6704
|
// Update the style entry with the remaining regions
|
|
5630
6705
|
await db.put('styles', foundStyle);
|
|
5631
6706
|
regionLogger$1.info(`Region ${regionId} deleted successfully from style ${foundStyle.key}, cleaned up ${deletedTileCount} non-overlapping tiles`);
|
|
5632
6707
|
}
|
|
6708
|
+
clearAllCaches();
|
|
5633
6709
|
regionLogger$1.info(`Region deletion completed successfully for: ${regionId}`);
|
|
5634
6710
|
}
|
|
5635
6711
|
catch (error) {
|
|
@@ -5680,6 +6756,13 @@ async function loadAllStoredRegions() {
|
|
|
5680
6756
|
}
|
|
5681
6757
|
}
|
|
5682
6758
|
|
|
6759
|
+
var regionService = /*#__PURE__*/Object.freeze({
|
|
6760
|
+
__proto__: null,
|
|
6761
|
+
RegionService: RegionService,
|
|
6762
|
+
loadAllStoredRegions: loadAllStoredRegions,
|
|
6763
|
+
resourceKeyBelongsToStyle: resourceKeyBelongsToStyle
|
|
6764
|
+
});
|
|
6765
|
+
|
|
5683
6766
|
const cleanupLogger = logger.scope('CleanupService');
|
|
5684
6767
|
class CleanupService {
|
|
5685
6768
|
db = dbPromise;
|
|
@@ -5778,9 +6861,9 @@ class CleanupService {
|
|
|
5778
6861
|
let deletedCount = 0;
|
|
5779
6862
|
for (const region of uniqueRegionsToDelete) {
|
|
5780
6863
|
try {
|
|
5781
|
-
const
|
|
6864
|
+
const bytesBefore = await this.getStyleStoredBytes(region.styleId);
|
|
5782
6865
|
await this.deleteRegionCallback(region.id, region.styleId);
|
|
5783
|
-
result.freedSpace +=
|
|
6866
|
+
result.freedSpace += Math.max(0, bytesBefore - (await this.getStyleStoredBytes(region.styleId)));
|
|
5784
6867
|
deletedCount++;
|
|
5785
6868
|
onProgress?.({
|
|
5786
6869
|
phase: 'cleaning',
|
|
@@ -5844,7 +6927,7 @@ class CleanupService {
|
|
|
5844
6927
|
neverExpiring: 0,
|
|
5845
6928
|
};
|
|
5846
6929
|
for (const region of regions) {
|
|
5847
|
-
const regionSize = await this.getRegionSize(region.id);
|
|
6930
|
+
const regionSize = await this.getRegionSize(region.id, region.styleId);
|
|
5848
6931
|
totalSize += regionSize;
|
|
5849
6932
|
// Track by style
|
|
5850
6933
|
const styleId = region.styleId || 'unknown';
|
|
@@ -5946,8 +7029,8 @@ class CleanupService {
|
|
|
5946
7029
|
async getExpiredResourceCount() {
|
|
5947
7030
|
const db = await this.db;
|
|
5948
7031
|
const now = Date.now();
|
|
5949
|
-
const counts = { tiles: 0, fonts: 0, sprites: 0, glyphs: 0, total: 0 };
|
|
5950
|
-
const stores = ['tiles', 'fonts', 'sprites', 'glyphs'];
|
|
7032
|
+
const counts = { tiles: 0, fonts: 0, sprites: 0, glyphs: 0, models: 0, total: 0 };
|
|
7033
|
+
const stores = ['tiles', 'fonts', 'sprites', 'glyphs', 'models'];
|
|
5951
7034
|
for (const storeName of stores) {
|
|
5952
7035
|
try {
|
|
5953
7036
|
const tx = db.transaction([storeName], 'readonly');
|
|
@@ -5993,46 +7076,77 @@ class CleanupService {
|
|
|
5993
7076
|
async getAllRegions() {
|
|
5994
7077
|
return loadAllStoredRegions();
|
|
5995
7078
|
}
|
|
7079
|
+
/**
|
|
7080
|
+
* Bytes of the tiles stored for a region: tiles on its style inside its
|
|
7081
|
+
* bounds, at the zooms the download pipeline fetched for each source. Tiles
|
|
7082
|
+
* are stored per style, so regions that overlap share (and both count)
|
|
7083
|
+
* their common tiles; shared style resources are not included.
|
|
7084
|
+
*/
|
|
5996
7085
|
async getRegionSize(regionId, styleIdParam) {
|
|
5997
7086
|
const db = await this.db;
|
|
5998
|
-
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
let
|
|
6002
|
-
|
|
6003
|
-
|
|
6004
|
-
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
const found = styleEntry.regions.find(r => r.id === regionId);
|
|
6009
|
-
if (found) {
|
|
6010
|
-
styleId = styleEntry.key;
|
|
6011
|
-
regionMinZoom = found.minZoom ?? 0;
|
|
6012
|
-
regionMaxZoom = found.maxZoom ?? 22;
|
|
6013
|
-
break;
|
|
6014
|
-
}
|
|
6015
|
-
}
|
|
7087
|
+
const candidates = styleIdParam
|
|
7088
|
+
? [await db.get('styles', styleIdParam)]
|
|
7089
|
+
: await db.getAll('styles');
|
|
7090
|
+
let styleEntry;
|
|
7091
|
+
let region;
|
|
7092
|
+
for (const entry of candidates) {
|
|
7093
|
+
region = entry?.regions?.find((r) => r.id === regionId || r.regionId === regionId);
|
|
7094
|
+
if (region) {
|
|
7095
|
+
styleEntry = entry;
|
|
7096
|
+
break;
|
|
6016
7097
|
}
|
|
6017
7098
|
}
|
|
6018
|
-
if (!
|
|
7099
|
+
if (!styleEntry || !region) {
|
|
6019
7100
|
return 0;
|
|
6020
7101
|
}
|
|
6021
|
-
|
|
7102
|
+
const styleId = styleEntry.key;
|
|
7103
|
+
const sources = (styleEntry.style?.sources ?? {});
|
|
7104
|
+
let totalSize = 0;
|
|
6022
7105
|
const tx = db.transaction(['tiles'], 'readonly');
|
|
6023
7106
|
for await (const cursor of tx.objectStore('tiles')) {
|
|
6024
7107
|
const tile = cursor.value;
|
|
6025
7108
|
if (tile.styleId !== styleId)
|
|
6026
7109
|
continue;
|
|
6027
|
-
// Filter by zoom range when possible
|
|
6028
7110
|
const parsed = parseTileKey(tile.key);
|
|
6029
|
-
if (parsed
|
|
7111
|
+
if (!parsed)
|
|
6030
7112
|
continue;
|
|
7113
|
+
const coords = {
|
|
7114
|
+
sourceId: tile.sourceId ?? parsed.sourceId,
|
|
7115
|
+
z: parsed.z,
|
|
7116
|
+
x: parsed.x,
|
|
7117
|
+
y: parsed.y,
|
|
7118
|
+
};
|
|
7119
|
+
if (tileBelongsToRegion(coords, region, sources)) {
|
|
7120
|
+
totalSize += tile.size || 0;
|
|
6031
7121
|
}
|
|
6032
|
-
totalSize += tile.size || 0;
|
|
6033
7122
|
}
|
|
6034
7123
|
return totalSize;
|
|
6035
7124
|
}
|
|
7125
|
+
/**
|
|
7126
|
+
* Bytes stored for a style: its tiles plus the fonts, glyphs, sprites and
|
|
7127
|
+
* models keyed to it. Measured before and after a deletion to report the
|
|
7128
|
+
* space actually freed (a region's own size over-counts tiles it shares).
|
|
7129
|
+
*/
|
|
7130
|
+
async getStyleStoredBytes(styleId) {
|
|
7131
|
+
if (!styleId)
|
|
7132
|
+
return 0;
|
|
7133
|
+
const db = await this.db;
|
|
7134
|
+
let total = 0;
|
|
7135
|
+
const tx = db.transaction(['tiles'], 'readonly');
|
|
7136
|
+
for await (const cursor of tx.objectStore('tiles')) {
|
|
7137
|
+
if (cursor.value.styleId === styleId)
|
|
7138
|
+
total += cursor.value.size || 0;
|
|
7139
|
+
}
|
|
7140
|
+
for (const storeName of ['fonts', 'glyphs', 'sprites', 'models']) {
|
|
7141
|
+
const storeTx = db.transaction([storeName], 'readonly');
|
|
7142
|
+
for await (const cursor of storeTx.objectStore(storeName)) {
|
|
7143
|
+
const entry = cursor.value;
|
|
7144
|
+
if (resourceKeyBelongsToStyle(entry.key, styleId))
|
|
7145
|
+
total += entry.size || 0;
|
|
7146
|
+
}
|
|
7147
|
+
}
|
|
7148
|
+
return total;
|
|
7149
|
+
}
|
|
6036
7150
|
async calculateTotalStorageSize() {
|
|
6037
7151
|
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
|
6038
7152
|
const estimate = await navigator.storage.estimate();
|
|
@@ -6046,6 +7160,7 @@ class CleanupService {
|
|
|
6046
7160
|
'fonts',
|
|
6047
7161
|
'sprites',
|
|
6048
7162
|
'glyphs',
|
|
7163
|
+
'models',
|
|
6049
7164
|
'styles',
|
|
6050
7165
|
];
|
|
6051
7166
|
for (const storeName of stores) {
|
|
@@ -6087,7 +7202,7 @@ class CleanupService {
|
|
|
6087
7202
|
if (currentSize >= targetSize)
|
|
6088
7203
|
break;
|
|
6089
7204
|
selected.push(region);
|
|
6090
|
-
const regionSize = await this.getRegionSize(region.id);
|
|
7205
|
+
const regionSize = await this.getRegionSize(region.id, region.styleId);
|
|
6091
7206
|
currentSize += regionSize > 0 ? regionSize : 10 * 1024 * 1024; // Fallback to 10MB if size unknown
|
|
6092
7207
|
}
|
|
6093
7208
|
return selected;
|
|
@@ -6178,7 +7293,7 @@ class TileService {
|
|
|
6178
7293
|
*/
|
|
6179
7294
|
async downloadTiles(region, style, styleId, options = {}) {
|
|
6180
7295
|
const db = await this.db;
|
|
6181
|
-
const { onProgress, batchSize = 10, maxRetries = 3, skipExisting = true, timeout = 10000, retryDelay = 1000, priorityZoomLevels = [], storageQuotaCheck = true, validateTiles = false, compressTiles = false, bandwidthLimit, probeSourcesBeforeDownload = true, skipKnownSparseSources = true, } = options;
|
|
7296
|
+
const { onProgress, batchSize = 10, maxRetries = 3, skipExisting = true, timeout = 10000, retryDelay = 1000, priorityZoomLevels = [], storageQuotaCheck = true, validateTiles = false, compressTiles = false, bandwidthLimit, probeSourcesBeforeDownload = true, skipKnownSparseSources = true, skipInactiveSources = true, signal, } = options;
|
|
6182
7297
|
const startTime = Date.now();
|
|
6183
7298
|
let totalSize = 0;
|
|
6184
7299
|
let downloadedTiles = 0;
|
|
@@ -6219,31 +7334,31 @@ class TileService {
|
|
|
6219
7334
|
if (tileSources.size === 0) {
|
|
6220
7335
|
throw new Error('No valid tile sources found in style definition');
|
|
6221
7336
|
}
|
|
6222
|
-
//
|
|
6223
|
-
//
|
|
7337
|
+
// Features a config-driven style has switched off (Mapbox Standard's HD
|
|
7338
|
+
// roads, indoor, landmark icons...) don't need their tiles.
|
|
7339
|
+
const { active: activeSources, inactive: inactiveSources } = classifySources(style);
|
|
7340
|
+
if (skipInactiveSources) {
|
|
7341
|
+
for (const sourceId of inactiveSources) {
|
|
7342
|
+
if (tileSources.delete(sourceId)) {
|
|
7343
|
+
tileLogger.info(`Skipping source "${sourceId}" — nothing visible uses it under the style's config`);
|
|
7344
|
+
}
|
|
7345
|
+
}
|
|
7346
|
+
}
|
|
7347
|
+
// Some sources have zoom ranges entirely outside the user's region (e.g.
|
|
7348
|
+
// procedural-buildings at z15 when the user requested z0-z14, or a z14-capped
|
|
7349
|
+
// basemap for a z15-z17 region). Generate the extra coordinates they need so
|
|
6224
7350
|
// these sources aren't silently skipped.
|
|
7351
|
+
const generatedZooms = new Set();
|
|
7352
|
+
for (let z = region.minZoom; z <= region.maxZoom; z++)
|
|
7353
|
+
generatedZooms.add(z);
|
|
6225
7354
|
for (const [sourceId, sourceConfig] of tileSources) {
|
|
6226
|
-
const
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
if (extraMinZ !== null) {
|
|
6234
|
-
const upperBound = srcMax !== undefined ? srcMax : extraMinZ;
|
|
6235
|
-
tileLogger.debug(`Source ${sourceId} needs zoom ${extraMinZ}-${upperBound} beyond region max ${region.maxZoom}, generating extra tiles`);
|
|
6236
|
-
const extraRegion = { ...region, minZoom: extraMinZ, maxZoom: upperBound };
|
|
6237
|
-
const extraCoords = this.generateTileCoordinates(extraRegion);
|
|
6238
|
-
tileCoords.push(...extraCoords);
|
|
6239
|
-
}
|
|
6240
|
-
// Extend downward (lower zoom) — sources that need zooms below region.minZoom
|
|
6241
|
-
if (extraMaxZ !== null) {
|
|
6242
|
-
const lowerBound = srcMin !== undefined ? srcMin : extraMaxZ;
|
|
6243
|
-
tileLogger.debug(`Source ${sourceId} needs zoom ${lowerBound}-${extraMaxZ} below region min ${region.minZoom}, generating extra tiles`);
|
|
6244
|
-
const extraRegion = { ...region, minZoom: lowerBound, maxZoom: extraMaxZ };
|
|
6245
|
-
const extraCoords = this.generateTileCoordinates(extraRegion);
|
|
6246
|
-
tileCoords.push(...extraCoords);
|
|
7355
|
+
const { min, max } = sourceZoomRange(region, sourceConfig.minzoom, sourceConfig.maxzoom);
|
|
7356
|
+
for (let z = min; z <= max; z++) {
|
|
7357
|
+
if (generatedZooms.has(z))
|
|
7358
|
+
continue;
|
|
7359
|
+
generatedZooms.add(z);
|
|
7360
|
+
tileLogger.debug(`Source ${sourceId} needs zoom ${z} outside region ${region.minZoom}-${region.maxZoom}, generating extra tiles`);
|
|
7361
|
+
tileCoords.push(...this.generateTileCoordinates({ ...region, minZoom: z, maxZoom: z }));
|
|
6247
7362
|
}
|
|
6248
7363
|
}
|
|
6249
7364
|
// Sort by priority zoom levels if requested (lower zoom first)
|
|
@@ -6286,14 +7401,16 @@ class TileService {
|
|
|
6286
7401
|
// issuing any network request. They 404 for nearly every coord in a
|
|
6287
7402
|
// typical region; the downstream probe step would catch them, but the
|
|
6288
7403
|
// probe itself logs 404s in devtools. Matching here keeps the console
|
|
6289
|
-
// clean.
|
|
6290
|
-
|
|
6291
|
-
|
|
7404
|
+
// clean. A sparse source a switched-on feature needs is kept instead, and
|
|
7405
|
+
// not probed, because its tiles cluster and the three probe tiles could
|
|
7406
|
+
// easily all miss.
|
|
7407
|
+
const knownSparse = tiles.some(template => urlReferencesKnownSparseTileset(template));
|
|
7408
|
+
const neededSparseSource = knownSparse && activeSources.has(sourceId);
|
|
7409
|
+
if (skipKnownSparseSources && knownSparse && !neededSparseSource) {
|
|
6292
7410
|
tileLogger.info(`Skipping source "${sourceId}" — references a known sparse Mapbox Standard sub-tileset (no probe/download issued)`);
|
|
6293
7411
|
continue;
|
|
6294
7412
|
}
|
|
6295
|
-
const sourceMinZ =
|
|
6296
|
-
const sourceMaxZ = Math.floor(sourceConfig.maxzoom ?? region.maxZoom);
|
|
7413
|
+
const { min: sourceMinZ, max: sourceMaxZ } = sourceZoomRange(region, sourceConfig.minzoom, sourceConfig.maxzoom);
|
|
6297
7414
|
let coordsForSource = tileCoords.filter(coord => coord.z >= sourceMinZ && coord.z <= sourceMaxZ);
|
|
6298
7415
|
tileLogger.debug(`After zoom filter (${sourceMinZ}-${sourceMaxZ}): ${coordsForSource.length} tiles`);
|
|
6299
7416
|
if (coordsForSource.length === 0) {
|
|
@@ -6327,7 +7444,7 @@ class TileService {
|
|
|
6327
7444
|
// A single probe on the wrong coord would false-pass and cause a
|
|
6328
7445
|
// flood of 404s during the download. Probing start/middle/end of
|
|
6329
7446
|
// the plan catches this case.
|
|
6330
|
-
if (probeSourcesBeforeDownload) {
|
|
7447
|
+
if (probeSourcesBeforeDownload && !neededSparseSource) {
|
|
6331
7448
|
const picks = [
|
|
6332
7449
|
coordsForSource[0],
|
|
6333
7450
|
coordsForSource[Math.floor(coordsForSource.length / 2)],
|
|
@@ -6386,6 +7503,9 @@ class TileService {
|
|
|
6386
7503
|
.map(([z, count]) => `Z${z}: ${count} tiles`)
|
|
6387
7504
|
.join(', '));
|
|
6388
7505
|
await processBatch(plan.coords, async (coord) => {
|
|
7506
|
+
// Cancelled: let the remaining tiles go by without fetching them.
|
|
7507
|
+
if (signal?.aborted)
|
|
7508
|
+
return;
|
|
6389
7509
|
const { x, y, z } = coord;
|
|
6390
7510
|
const label = `${plan.sourceId}:${z}/${x}/${y}`;
|
|
6391
7511
|
let tileUrl = '';
|
|
@@ -6495,7 +7615,9 @@ class TileService {
|
|
|
6495
7615
|
const errorObject = _error;
|
|
6496
7616
|
errorMessage = errorObject instanceof Error ? errorObject.message : String(errorObject);
|
|
6497
7617
|
// 404s on sparse tilesets (landmarks, POIs) are expected — don't count as failures
|
|
6498
|
-
|
|
7618
|
+
// Match the start only: the message goes on to include the URL, whose
|
|
7619
|
+
// coordinates or token can contain "404".
|
|
7620
|
+
const is404 = /^(Resource not found \(404\)|HTTP 404\b)/.test(errorMessage);
|
|
6499
7621
|
const isExpected = errorMessage.includes('NonRetryableError');
|
|
6500
7622
|
if (is404) {
|
|
6501
7623
|
skippedTiles++;
|
|
@@ -6673,13 +7795,7 @@ class TileService {
|
|
|
6673
7795
|
tileLogger.debug(`Approximate area: ${areaApproxKm2.toFixed(2)} km² (improved calculation)`);
|
|
6674
7796
|
tileLogger.debug(`Region dimensions: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km`);
|
|
6675
7797
|
for (let z = region.minZoom; z <= region.maxZoom; z++) {
|
|
6676
|
-
const
|
|
6677
|
-
const minTile = tilebelt.pointToTile(bounds[0][0], bounds[0][1], z);
|
|
6678
|
-
const maxTile = tilebelt.pointToTile(bounds[1][0], bounds[1][1], z);
|
|
6679
|
-
const minX = Math.min(minTile[0], maxTile[0]);
|
|
6680
|
-
const maxX = Math.max(minTile[0], maxTile[0]);
|
|
6681
|
-
const minY = Math.min(minTile[1], maxTile[1]);
|
|
6682
|
-
const maxY = Math.max(minTile[1], maxTile[1]);
|
|
7798
|
+
const { minX, maxX, minY, maxY } = getTileRangeAtZoom(region.bounds, z);
|
|
6683
7799
|
const tilesAtZoom = (maxX - minX + 1) * (maxY - minY + 1);
|
|
6684
7800
|
tilesByZoom[z] = tilesAtZoom;
|
|
6685
7801
|
tileLogger.debug(`Zoom ${z}: ${tilesAtZoom} tiles (X: ${minX}-${maxX}, Y: ${minY}-${maxY})`);
|
|
@@ -6713,7 +7829,10 @@ class TileService {
|
|
|
6713
7829
|
}
|
|
6714
7830
|
tileLogger.debug('Processing sources in extractTileSources:', Object.keys(style.sources));
|
|
6715
7831
|
for (const [sourceId, sourceConfig] of Object.entries(style.sources)) {
|
|
6716
|
-
|
|
7832
|
+
// Shallow copy: the maxzoom override below must not mutate the caller's style.
|
|
7833
|
+
const config = {
|
|
7834
|
+
...sourceConfig,
|
|
7835
|
+
};
|
|
6717
7836
|
tileLogger.debug(`Source ${sourceId} RAW DATA:`, JSON.stringify(config, null, 2));
|
|
6718
7837
|
tileLogger.debug(`Processing source ${sourceId}:`, {
|
|
6719
7838
|
type: config.type,
|
|
@@ -6724,18 +7843,26 @@ class TileService {
|
|
|
6724
7843
|
});
|
|
6725
7844
|
// Handle tile-based sources (vector, raster, raster-dem, batched-model,
|
|
6726
7845
|
// raster-array). `raster-array` is used by Mapbox Standard for layers
|
|
6727
|
-
// like `mapbox-landmarks` (mapbox.mapbox-landmark-icons-v1)
|
|
6728
|
-
//
|
|
6729
|
-
// TileJSON
|
|
7846
|
+
// like `mapbox-landmarks` (mapbox.mapbox-landmark-icons-v1). Each type has
|
|
7847
|
+
// its own tile endpoint (/v4/, /raster/v1/, /rasterarrays/v1/, /3dtiles/v1/),
|
|
7848
|
+
// but TileJSON gives the URL for all of them, so the resolution path below
|
|
7849
|
+
// handles them uniformly.
|
|
6730
7850
|
if (config.type === 'vector' ||
|
|
6731
7851
|
config.type === 'raster' ||
|
|
6732
7852
|
config.type === 'raster-dem' ||
|
|
6733
7853
|
config.type === 'raster-array' ||
|
|
6734
7854
|
config.type === 'batched-model') {
|
|
7855
|
+
// A style already patched for offline (every region after the first)
|
|
7856
|
+
// has idb:// tiles and a maxzoom capped to earlier regions; plan from
|
|
7857
|
+
// the upstream values patchStyleForOffline stashed instead.
|
|
7858
|
+
const sourceTiles = config.__originalTiles ?? config.tiles;
|
|
7859
|
+
if (config.__originalMaxzoom !== undefined) {
|
|
7860
|
+
config.maxzoom = config.__originalMaxzoom ?? undefined;
|
|
7861
|
+
}
|
|
6735
7862
|
// Handle direct tile URLs in the source config
|
|
6736
|
-
if (
|
|
7863
|
+
if (sourceTiles && Array.isArray(sourceTiles) && sourceTiles.length > 0) {
|
|
6737
7864
|
// Resolve mapbox:// tile URLs to HTTPS, then filter for HTTP(S) URLs
|
|
6738
|
-
const resolvedTiles =
|
|
7865
|
+
const resolvedTiles = sourceTiles.map((tile) => {
|
|
6739
7866
|
if (isMapboxProtocol(tile)) {
|
|
6740
7867
|
// Try to find an access token from the style or source URL
|
|
6741
7868
|
const accessToken = this.extractAccessTokenFromStyle(style);
|
|
@@ -6807,7 +7934,9 @@ class TileService {
|
|
|
6807
7934
|
});
|
|
6808
7935
|
tileLogger.debug(`TileJSON fetch response type: ${response.type}`);
|
|
6809
7936
|
if (response.type === 'json') {
|
|
6810
|
-
const jsonData = response.data
|
|
7937
|
+
const jsonData = response.data
|
|
7938
|
+
? selectTileJsonVariant(response.data)
|
|
7939
|
+
: null;
|
|
6811
7940
|
tileLogger.debug(`TileJSON data keys: ${Object.keys(jsonData || {}).join(', ')}`);
|
|
6812
7941
|
if (jsonData &&
|
|
6813
7942
|
typeof jsonData === 'object' &&
|
|
@@ -6911,16 +8040,9 @@ class TileService {
|
|
|
6911
8040
|
}
|
|
6912
8041
|
}
|
|
6913
8042
|
if (tileSources.size === 0) {
|
|
8043
|
+
// No fabricated fallback: a relative '{z}/{x}/{y}.pbf' template resolves
|
|
8044
|
+
// against the page and stores whatever the app server returns as tiles.
|
|
6914
8045
|
tileLogger.warn('No valid tile sources found in style', Object.keys(style.sources));
|
|
6915
|
-
// As a last resort, try to use a common vector tile source pattern if we can't extract any
|
|
6916
|
-
if (style.sources && Object.keys(style.sources).length > 0) {
|
|
6917
|
-
const firstSourceId = Object.keys(style.sources)[0];
|
|
6918
|
-
tileLogger.debug(`Attempting to create fallback source from ${firstSourceId}`);
|
|
6919
|
-
tileSources.set(firstSourceId, {
|
|
6920
|
-
type: 'vector',
|
|
6921
|
-
tiles: ['{z}/{x}/{y}.pbf'],
|
|
6922
|
-
});
|
|
6923
|
-
}
|
|
6924
8046
|
}
|
|
6925
8047
|
return tileSources;
|
|
6926
8048
|
}
|
|
@@ -7328,341 +8450,146 @@ class GlyphService {
|
|
|
7328
8450
|
health: {
|
|
7329
8451
|
corruptedGlyphs: stats.corruptedGlyphs.length,
|
|
7330
8452
|
corruptionRate: stats.count > 0 ? (stats.corruptedGlyphs.length / stats.count) * 100 : 0,
|
|
7331
|
-
},
|
|
7332
|
-
temporal: {
|
|
7333
|
-
oldestGlyph: stats.oldestGlyph,
|
|
7334
|
-
newestGlyph: stats.newestGlyph,
|
|
7335
|
-
ageSpan: stats.oldestGlyph && stats.newestGlyph
|
|
7336
|
-
? stats.newestGlyph.lastModified - stats.oldestGlyph.lastModified
|
|
7337
|
-
: 0,
|
|
7338
|
-
},
|
|
7339
|
-
};
|
|
7340
|
-
}
|
|
7341
|
-
/**
|
|
7342
|
-
* Remove glyphs older than the specified age. When `options.styleId` is
|
|
7343
|
-
* provided, only glyphs belonging to that style (per
|
|
7344
|
-
* `resourceKeyBelongsToStyle`) are eligible.
|
|
7345
|
-
*/
|
|
7346
|
-
async cleanupOldGlyphs(maxAge = 30, options = {}) {
|
|
7347
|
-
const db = await this.db;
|
|
7348
|
-
const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
|
|
7349
|
-
const { styleId } = options;
|
|
7350
|
-
let deletedCount = 0;
|
|
7351
|
-
const tx = db.transaction('glyphs', 'readwrite');
|
|
7352
|
-
for await (const cursor of tx.store) {
|
|
7353
|
-
const glyphEntry = cursor.value;
|
|
7354
|
-
const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
|
|
7355
|
-
if (belongs && glyphEntry.lastModified < cutoffTime) {
|
|
7356
|
-
await cursor.delete();
|
|
7357
|
-
deletedCount++;
|
|
7358
|
-
}
|
|
7359
|
-
}
|
|
7360
|
-
return deletedCount;
|
|
7361
|
-
}
|
|
7362
|
-
async verifyAndRepairGlyphs() {
|
|
7363
|
-
const db = await this.db;
|
|
7364
|
-
let verified = 0;
|
|
7365
|
-
// eslint-disable-next-line prefer-const -- will be incremented when repair logic is added
|
|
7366
|
-
let repaired = 0;
|
|
7367
|
-
let removed = 0;
|
|
7368
|
-
const tx = db.transaction('glyphs', 'readwrite');
|
|
7369
|
-
for await (const cursor of tx.store) {
|
|
7370
|
-
const glyphEntry = cursor.value;
|
|
7371
|
-
try {
|
|
7372
|
-
// Basic validation
|
|
7373
|
-
if (!glyphEntry.data || glyphEntry.size === 0) {
|
|
7374
|
-
await cursor.delete();
|
|
7375
|
-
removed++;
|
|
7376
|
-
}
|
|
7377
|
-
else {
|
|
7378
|
-
this.validateGlyphData(glyphEntry.data);
|
|
7379
|
-
verified++;
|
|
7380
|
-
}
|
|
7381
|
-
}
|
|
7382
|
-
catch {
|
|
7383
|
-
// Remove corrupted glyphs (they can be re-downloaded)
|
|
7384
|
-
await cursor.delete();
|
|
7385
|
-
removed++;
|
|
7386
|
-
}
|
|
7387
|
-
}
|
|
7388
|
-
return { verified, repaired, removed };
|
|
7389
|
-
}
|
|
7390
|
-
validateGlyphData(data) {
|
|
7391
|
-
if (data.byteLength < 10) {
|
|
7392
|
-
throw new Error('Glyph data too short');
|
|
7393
|
-
}
|
|
7394
|
-
// Basic Protocol Buffer validation
|
|
7395
|
-
const view = new Uint8Array(data);
|
|
7396
|
-
// Check for valid protobuf structure (basic check)
|
|
7397
|
-
if (view[0] === 0x00 && view[1] === 0x00 && view[2] === 0x00 && view[3] === 0x00) {
|
|
7398
|
-
throw new Error('Invalid glyph data - appears to be empty');
|
|
7399
|
-
}
|
|
7400
|
-
}
|
|
7401
|
-
normalizeFontstack(fontstack) {
|
|
7402
|
-
try {
|
|
7403
|
-
return decodeURIComponent(fontstack);
|
|
7404
|
-
}
|
|
7405
|
-
catch {
|
|
7406
|
-
return fontstack;
|
|
7407
|
-
}
|
|
7408
|
-
}
|
|
7409
|
-
buildGlyphPath(fontstack, range) {
|
|
7410
|
-
const normalizedFontstack = this.normalizeFontstack(fontstack);
|
|
7411
|
-
return `${normalizedFontstack}/${range}.pbf`;
|
|
7412
|
-
}
|
|
7413
|
-
parseGlyphKey(key) {
|
|
7414
|
-
let remaining = key;
|
|
7415
|
-
let styleId;
|
|
7416
|
-
if (remaining.includes('::')) {
|
|
7417
|
-
const [prefix, ...rest] = remaining.split('::');
|
|
7418
|
-
styleId = prefix;
|
|
7419
|
-
remaining = rest.join('::');
|
|
7420
|
-
}
|
|
7421
|
-
else if (remaining.includes(':') && !remaining.includes('/')) {
|
|
7422
|
-
const [prefix, rest] = remaining.split(':');
|
|
7423
|
-
styleId = styleId ?? prefix;
|
|
7424
|
-
remaining = rest;
|
|
7425
|
-
}
|
|
7426
|
-
const trimmed = remaining.endsWith('.pbf') ? remaining.slice(0, -4) : remaining;
|
|
7427
|
-
if (trimmed.includes('/')) {
|
|
7428
|
-
const [fontstack, range] = trimmed.split('/');
|
|
7429
|
-
if (fontstack && range) {
|
|
7430
|
-
return { styleId, fontstack, range };
|
|
7431
|
-
}
|
|
7432
|
-
}
|
|
7433
|
-
if (trimmed.includes('_')) {
|
|
7434
|
-
const [fontstack, range] = trimmed.split('_');
|
|
7435
|
-
if (fontstack && range) {
|
|
7436
|
-
return { styleId, fontstack, range };
|
|
7437
|
-
}
|
|
7438
|
-
}
|
|
7439
|
-
return { styleId, fontstack: trimmed, range: '' };
|
|
7440
|
-
}
|
|
7441
|
-
createGlyphKey(fontstack, range, styleName) {
|
|
7442
|
-
// Create a consistent key from the style name, fontstack and range
|
|
7443
|
-
// Format: stylename::fontstack/range.pbf (if styleName provided) or fontstack/range.pbf
|
|
7444
|
-
const glyphPath = this.buildGlyphPath(fontstack, range);
|
|
7445
|
-
if (styleName) {
|
|
7446
|
-
return `${styleName}::${glyphPath}`;
|
|
7447
|
-
}
|
|
7448
|
-
// Fallback to original format for backward compatibility
|
|
7449
|
-
return glyphPath;
|
|
7450
|
-
}
|
|
7451
|
-
}
|
|
7452
|
-
// Export singleton instance and functions for backward compatibility
|
|
7453
|
-
const glyphService = new GlyphService();
|
|
7454
|
-
const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => glyphService.downloadGlyphs(glyphUrl, fontstacks, styleName, ranges, options);
|
|
7455
|
-
const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
|
|
7456
|
-
const getGlyphStats = () => glyphService.getGlyphStats();
|
|
7457
|
-
const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
|
|
7458
|
-
const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
|
|
7459
|
-
const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
|
|
7460
|
-
|
|
7461
|
-
var glyphService$1 = /*#__PURE__*/Object.freeze({
|
|
7462
|
-
__proto__: null,
|
|
7463
|
-
GlyphService: GlyphService,
|
|
7464
|
-
cleanupOldGlyphs: cleanupOldGlyphs,
|
|
7465
|
-
downloadGlyphs: downloadGlyphs,
|
|
7466
|
-
getGlyphAnalytics: getGlyphAnalytics,
|
|
7467
|
-
getGlyphStats: getGlyphStats,
|
|
7468
|
-
glyphService: glyphService,
|
|
7469
|
-
loadGlyphs: loadGlyphs,
|
|
7470
|
-
verifyAndRepairGlyphs: verifyAndRepairGlyphs
|
|
7471
|
-
});
|
|
7472
|
-
|
|
7473
|
-
const modelLogger = logger.scope('ModelService');
|
|
7474
|
-
/**
|
|
7475
|
-
* Build the storage key for a model. Kept consistent with sprite/glyph
|
|
7476
|
-
* conventions: `{styleId}::model::{modelName}`.
|
|
7477
|
-
*/
|
|
7478
|
-
function modelKey(styleId, modelName) {
|
|
7479
|
-
return `${styleId}::model::${modelName}`;
|
|
7480
|
-
}
|
|
7481
|
-
/** True when the given key belongs to the given styleId's model store prefix. */
|
|
7482
|
-
function modelKeyBelongsToStyle(key, styleId) {
|
|
7483
|
-
return key.startsWith(`${styleId}::model::`);
|
|
7484
|
-
}
|
|
7485
|
-
/**
|
|
7486
|
-
* Service for downloading, storing, and serving Mapbox 3D model (.glb) files.
|
|
7487
|
-
*
|
|
7488
|
-
* Mapbox Standard declares 32 models at the top of `style.models`:
|
|
7489
|
-
*
|
|
7490
|
-
* ```json
|
|
7491
|
-
* {
|
|
7492
|
-
* "maple1-lod1": "mapbox://models/mapbox/maple1-v4-lod1.glb",
|
|
7493
|
-
* ...
|
|
7494
|
-
* }
|
|
7495
|
-
* ```
|
|
7496
|
-
*
|
|
7497
|
-
* `model` layers (e.g. `trees`, `wind-turbine-towers`) pick one by name at
|
|
7498
|
-
* render time. For offline use each referenced URL is fetched and stored
|
|
7499
|
-
* here, and `patchStyleForOffline` rewrites the dictionary entries to
|
|
7500
|
-
* `idb://{styleId}/model/{name}` URLs.
|
|
7501
|
-
*/
|
|
7502
|
-
class ModelService {
|
|
7503
|
-
db = dbPromise;
|
|
7504
|
-
/**
|
|
7505
|
-
* Download the set of models referenced by `style.models` for one style.
|
|
7506
|
-
*
|
|
7507
|
-
* @param models `{ modelName: resolvedHttpUrl }` — URLs must already be
|
|
7508
|
-
* resolved (mapbox:// URLs should be resolved by the caller).
|
|
7509
|
-
* @param styleId The owning style's key.
|
|
7510
|
-
*/
|
|
7511
|
-
async downloadModels(models, styleId, options = {}) {
|
|
7512
|
-
const db = await this.db;
|
|
7513
|
-
const { onProgress, batchSize = 4, maxRetries = 3, skipExisting = true, timeoutMs = 30000, } = options;
|
|
7514
|
-
const entries = Object.entries(models);
|
|
7515
|
-
const progressTracker = createProgressTracker(entries.length);
|
|
7516
|
-
const result = {
|
|
7517
|
-
totalModels: entries.length,
|
|
7518
|
-
downloadedModels: 0,
|
|
7519
|
-
skippedModels: 0,
|
|
7520
|
-
failedModels: 0,
|
|
7521
|
-
totalSize: 0,
|
|
7522
|
-
errors: [],
|
|
7523
|
-
};
|
|
7524
|
-
const emit = () => onProgress?.(progressTracker.getProgress());
|
|
7525
|
-
emit();
|
|
7526
|
-
if (entries.length === 0)
|
|
7527
|
-
return result;
|
|
7528
|
-
// Pre-compute existing keys for skipExisting
|
|
7529
|
-
const existingKeys = new Set();
|
|
7530
|
-
if (skipExisting) {
|
|
7531
|
-
const tx = db.transaction('models', 'readonly');
|
|
7532
|
-
for await (const cursor of tx.store) {
|
|
7533
|
-
existingKeys.add(cursor.value.key);
|
|
7534
|
-
}
|
|
7535
|
-
}
|
|
7536
|
-
await processBatch(entries, async ([modelName, url]) => {
|
|
7537
|
-
const key = modelKey(styleId, modelName);
|
|
7538
|
-
const label = `${styleId}::${modelName}`;
|
|
7539
|
-
if (skipExisting && existingKeys.has(key)) {
|
|
7540
|
-
result.skippedModels++;
|
|
7541
|
-
progressTracker.update(1, label);
|
|
7542
|
-
emit();
|
|
7543
|
-
return;
|
|
7544
|
-
}
|
|
7545
|
-
try {
|
|
7546
|
-
const response = await fetchResourceWithRetry(url, {
|
|
7547
|
-
retries: maxRetries,
|
|
7548
|
-
timeout: timeoutMs,
|
|
7549
|
-
proxyType: 'tiles',
|
|
7550
|
-
});
|
|
7551
|
-
if (response.type === 'json') {
|
|
7552
|
-
throw new Error('Unexpected JSON response for model');
|
|
7553
|
-
}
|
|
7554
|
-
const data = response.data;
|
|
7555
|
-
const contentType = ('contentType' in response && response.contentType) || 'model/gltf-binary';
|
|
7556
|
-
const entry = {
|
|
7557
|
-
key,
|
|
7558
|
-
data,
|
|
7559
|
-
contentType,
|
|
7560
|
-
size: data.byteLength,
|
|
7561
|
-
url,
|
|
7562
|
-
styleId,
|
|
7563
|
-
modelName,
|
|
7564
|
-
lastModified: Date.now(),
|
|
7565
|
-
downloadedAt: new Date().toISOString(),
|
|
7566
|
-
expires: response.expires,
|
|
7567
|
-
};
|
|
7568
|
-
await db.put('models', entry);
|
|
7569
|
-
result.downloadedModels++;
|
|
7570
|
-
result.totalSize += data.byteLength;
|
|
7571
|
-
progressTracker.update(1, label);
|
|
7572
|
-
}
|
|
7573
|
-
catch (err) {
|
|
7574
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
7575
|
-
result.failedModels++;
|
|
7576
|
-
result.errors.push({ url, error: message });
|
|
7577
|
-
modelLogger.warn(`Failed to download model "${modelName}" from ${url}:`, err);
|
|
7578
|
-
progressTracker.update(1, label, message);
|
|
7579
|
-
}
|
|
7580
|
-
emit();
|
|
7581
|
-
}, { batchSize });
|
|
7582
|
-
modelLogger.info(`Models downloaded for style ${styleId}: ${result.downloadedModels} new, ${result.skippedModels} skipped, ${result.failedModels} failed`);
|
|
7583
|
-
return result;
|
|
7584
|
-
}
|
|
7585
|
-
/** Retrieve a single model by `{styleId, modelName}`. */
|
|
7586
|
-
async getModel(styleId, modelName) {
|
|
7587
|
-
const db = await this.db;
|
|
7588
|
-
return db.get('models', modelKey(styleId, modelName));
|
|
7589
|
-
}
|
|
7590
|
-
/** Aggregate stats across all stored models. */
|
|
7591
|
-
async getModelStats() {
|
|
7592
|
-
const db = await this.db;
|
|
7593
|
-
const stats = {
|
|
7594
|
-
count: 0,
|
|
7595
|
-
totalSize: 0,
|
|
7596
|
-
averageSize: 0,
|
|
7597
|
-
models: [],
|
|
7598
|
-
modelsByStyle: {},
|
|
8453
|
+
},
|
|
8454
|
+
temporal: {
|
|
8455
|
+
oldestGlyph: stats.oldestGlyph,
|
|
8456
|
+
newestGlyph: stats.newestGlyph,
|
|
8457
|
+
ageSpan: stats.oldestGlyph && stats.newestGlyph
|
|
8458
|
+
? stats.newestGlyph.lastModified - stats.oldestGlyph.lastModified
|
|
8459
|
+
: 0,
|
|
8460
|
+
},
|
|
7599
8461
|
};
|
|
7600
|
-
const tx = db.transaction('models', 'readonly');
|
|
7601
|
-
for await (const cursor of tx.store) {
|
|
7602
|
-
const m = cursor.value;
|
|
7603
|
-
stats.count++;
|
|
7604
|
-
stats.totalSize += m.size;
|
|
7605
|
-
stats.models.push({ name: m.modelName, size: m.size, lastModified: m.lastModified });
|
|
7606
|
-
stats.modelsByStyle[m.styleId] = (stats.modelsByStyle[m.styleId] ?? 0) + 1;
|
|
7607
|
-
}
|
|
7608
|
-
stats.averageSize = stats.count > 0 ? stats.totalSize / stats.count : 0;
|
|
7609
|
-
return stats;
|
|
7610
8462
|
}
|
|
7611
8463
|
/**
|
|
7612
|
-
*
|
|
8464
|
+
* Remove glyphs older than the specified age. When `options.styleId` is
|
|
8465
|
+
* provided, only glyphs belonging to that style (per
|
|
8466
|
+
* `resourceKeyBelongsToStyle`) are eligible.
|
|
7613
8467
|
*/
|
|
7614
|
-
async
|
|
8468
|
+
async cleanupOldGlyphs(maxAge = 30, options = {}) {
|
|
7615
8469
|
const db = await this.db;
|
|
7616
|
-
const
|
|
7617
|
-
|
|
7618
|
-
|
|
8470
|
+
const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
|
|
8471
|
+
const { styleId } = options;
|
|
8472
|
+
let deletedCount = 0;
|
|
8473
|
+
const tx = db.transaction('glyphs', 'readwrite');
|
|
7619
8474
|
for await (const cursor of tx.store) {
|
|
7620
|
-
|
|
8475
|
+
const glyphEntry = cursor.value;
|
|
8476
|
+
const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
|
|
8477
|
+
if (belongs && glyphEntry.lastModified < cutoffTime) {
|
|
7621
8478
|
await cursor.delete();
|
|
7622
|
-
|
|
8479
|
+
deletedCount++;
|
|
7623
8480
|
}
|
|
7624
8481
|
}
|
|
7625
|
-
return
|
|
8482
|
+
return deletedCount;
|
|
7626
8483
|
}
|
|
7627
|
-
|
|
7628
|
-
* Basic integrity check: remove entries with empty/missing data.
|
|
7629
|
-
*/
|
|
7630
|
-
async verifyAndRepairModels() {
|
|
8484
|
+
async verifyAndRepairGlyphs() {
|
|
7631
8485
|
const db = await this.db;
|
|
7632
8486
|
let verified = 0;
|
|
8487
|
+
// eslint-disable-next-line prefer-const -- will be incremented when repair logic is added
|
|
8488
|
+
let repaired = 0;
|
|
7633
8489
|
let removed = 0;
|
|
7634
|
-
const tx = db.transaction('
|
|
8490
|
+
const tx = db.transaction('glyphs', 'readwrite');
|
|
7635
8491
|
for await (const cursor of tx.store) {
|
|
7636
|
-
const
|
|
7637
|
-
|
|
8492
|
+
const glyphEntry = cursor.value;
|
|
8493
|
+
try {
|
|
8494
|
+
// Basic validation
|
|
8495
|
+
if (!glyphEntry.data || glyphEntry.size === 0) {
|
|
8496
|
+
await cursor.delete();
|
|
8497
|
+
removed++;
|
|
8498
|
+
}
|
|
8499
|
+
else {
|
|
8500
|
+
this.validateGlyphData(glyphEntry.data);
|
|
8501
|
+
verified++;
|
|
8502
|
+
}
|
|
8503
|
+
}
|
|
8504
|
+
catch {
|
|
8505
|
+
// Remove corrupted glyphs (they can be re-downloaded)
|
|
7638
8506
|
await cursor.delete();
|
|
7639
8507
|
removed++;
|
|
7640
8508
|
}
|
|
7641
|
-
|
|
7642
|
-
|
|
8509
|
+
}
|
|
8510
|
+
return { verified, repaired, removed };
|
|
8511
|
+
}
|
|
8512
|
+
validateGlyphData(data) {
|
|
8513
|
+
if (data.byteLength < 10) {
|
|
8514
|
+
throw new Error('Glyph data too short');
|
|
8515
|
+
}
|
|
8516
|
+
// Basic Protocol Buffer validation
|
|
8517
|
+
const view = new Uint8Array(data);
|
|
8518
|
+
// Check for valid protobuf structure (basic check)
|
|
8519
|
+
if (view[0] === 0x00 && view[1] === 0x00 && view[2] === 0x00 && view[3] === 0x00) {
|
|
8520
|
+
throw new Error('Invalid glyph data - appears to be empty');
|
|
8521
|
+
}
|
|
8522
|
+
}
|
|
8523
|
+
normalizeFontstack(fontstack) {
|
|
8524
|
+
try {
|
|
8525
|
+
return decodeURIComponent(fontstack);
|
|
8526
|
+
}
|
|
8527
|
+
catch {
|
|
8528
|
+
return fontstack;
|
|
8529
|
+
}
|
|
8530
|
+
}
|
|
8531
|
+
buildGlyphPath(fontstack, range) {
|
|
8532
|
+
const normalizedFontstack = this.normalizeFontstack(fontstack);
|
|
8533
|
+
return `${normalizedFontstack}/${range}.pbf`;
|
|
8534
|
+
}
|
|
8535
|
+
parseGlyphKey(key) {
|
|
8536
|
+
let remaining = key;
|
|
8537
|
+
let styleId;
|
|
8538
|
+
if (remaining.includes('::')) {
|
|
8539
|
+
const [prefix, ...rest] = remaining.split('::');
|
|
8540
|
+
styleId = prefix;
|
|
8541
|
+
remaining = rest.join('::');
|
|
8542
|
+
}
|
|
8543
|
+
else if (remaining.includes(':') && !remaining.includes('/')) {
|
|
8544
|
+
const [prefix, rest] = remaining.split(':');
|
|
8545
|
+
styleId = styleId ?? prefix;
|
|
8546
|
+
remaining = rest;
|
|
8547
|
+
}
|
|
8548
|
+
const trimmed = remaining.endsWith('.pbf') ? remaining.slice(0, -4) : remaining;
|
|
8549
|
+
if (trimmed.includes('/')) {
|
|
8550
|
+
const [fontstack, range] = trimmed.split('/');
|
|
8551
|
+
if (fontstack && range) {
|
|
8552
|
+
return { styleId, fontstack, range };
|
|
7643
8553
|
}
|
|
7644
8554
|
}
|
|
7645
|
-
|
|
8555
|
+
if (trimmed.includes('_')) {
|
|
8556
|
+
const [fontstack, range] = trimmed.split('_');
|
|
8557
|
+
if (fontstack && range) {
|
|
8558
|
+
return { styleId, fontstack, range };
|
|
8559
|
+
}
|
|
8560
|
+
}
|
|
8561
|
+
return { styleId, fontstack: trimmed, range: '' };
|
|
8562
|
+
}
|
|
8563
|
+
createGlyphKey(fontstack, range, styleName) {
|
|
8564
|
+
// Create a consistent key from the style name, fontstack and range
|
|
8565
|
+
// Format: stylename::fontstack/range.pbf (if styleName provided) or fontstack/range.pbf
|
|
8566
|
+
const glyphPath = this.buildGlyphPath(fontstack, range);
|
|
8567
|
+
if (styleName) {
|
|
8568
|
+
return `${styleName}::${glyphPath}`;
|
|
8569
|
+
}
|
|
8570
|
+
// Fallback to original format for backward compatibility
|
|
8571
|
+
return glyphPath;
|
|
7646
8572
|
}
|
|
7647
8573
|
}
|
|
7648
|
-
//
|
|
7649
|
-
const
|
|
7650
|
-
const
|
|
7651
|
-
const
|
|
7652
|
-
const
|
|
7653
|
-
const
|
|
7654
|
-
const
|
|
8574
|
+
// Export singleton instance and functions for backward compatibility
|
|
8575
|
+
const glyphService = new GlyphService();
|
|
8576
|
+
const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => glyphService.downloadGlyphs(glyphUrl, fontstacks, styleName, ranges, options);
|
|
8577
|
+
const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
|
|
8578
|
+
const getGlyphStats = () => glyphService.getGlyphStats();
|
|
8579
|
+
const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
|
|
8580
|
+
const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
|
|
8581
|
+
const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
|
|
7655
8582
|
|
|
7656
|
-
var
|
|
8583
|
+
var glyphService$1 = /*#__PURE__*/Object.freeze({
|
|
7657
8584
|
__proto__: null,
|
|
7658
|
-
|
|
7659
|
-
|
|
7660
|
-
|
|
7661
|
-
|
|
7662
|
-
|
|
7663
|
-
|
|
7664
|
-
|
|
7665
|
-
|
|
8585
|
+
GlyphService: GlyphService,
|
|
8586
|
+
cleanupOldGlyphs: cleanupOldGlyphs,
|
|
8587
|
+
downloadGlyphs: downloadGlyphs,
|
|
8588
|
+
getGlyphAnalytics: getGlyphAnalytics,
|
|
8589
|
+
getGlyphStats: getGlyphStats,
|
|
8590
|
+
glyphService: glyphService,
|
|
8591
|
+
loadGlyphs: loadGlyphs,
|
|
8592
|
+
verifyAndRepairGlyphs: verifyAndRepairGlyphs
|
|
7666
8593
|
});
|
|
7667
8594
|
|
|
7668
8595
|
class ResourceService {
|
|
@@ -7762,23 +8689,26 @@ class AnalyticsService {
|
|
|
7762
8689
|
return getGlyphStats();
|
|
7763
8690
|
}
|
|
7764
8691
|
async getComprehensiveStorageAnalytics(getRegionAnalytics) {
|
|
7765
|
-
const [tileStats, fontStats, spriteStats, glyphStats, regionAnalytics] = await Promise.all([
|
|
8692
|
+
const [tileStats, fontStats, spriteStats, glyphStats, modelStats, styleStats, regionAnalytics] = await Promise.all([
|
|
7766
8693
|
this.getAllTileStats(),
|
|
7767
8694
|
this.getAllFontStats(),
|
|
7768
8695
|
this.getAllSpriteStats(),
|
|
7769
8696
|
this.getAllGlyphStats(),
|
|
8697
|
+
getModelStats(),
|
|
8698
|
+
getStyleStats(),
|
|
7770
8699
|
getRegionAnalytics(),
|
|
7771
8700
|
]);
|
|
7772
|
-
const totalStorageSize = (tileStats.totalSize || 0) +
|
|
7773
|
-
(fontStats.totalSize || 0) +
|
|
7774
|
-
(spriteStats.totalSize || 0) +
|
|
7775
|
-
(glyphStats.totalSize || 0);
|
|
7776
8701
|
const storageByType = {
|
|
8702
|
+
// The style documents themselves, which the panel's per-style totals include.
|
|
8703
|
+
styles: styleStats.totalSize || 0,
|
|
7777
8704
|
tiles: tileStats.totalSize || 0,
|
|
7778
8705
|
fonts: fontStats.totalSize || 0,
|
|
7779
8706
|
sprites: spriteStats.totalSize || 0,
|
|
7780
8707
|
glyphs: glyphStats.totalSize || 0,
|
|
8708
|
+
// Mapbox Standard 3D models (.glb) — often several MB each.
|
|
8709
|
+
models: modelStats.totalSize || 0,
|
|
7781
8710
|
};
|
|
8711
|
+
const totalStorageSize = Object.values(storageByType).reduce((sum, size) => sum + size, 0);
|
|
7782
8712
|
const recommendations = this.generateStorageRecommendations({
|
|
7783
8713
|
tiles: tileStats,
|
|
7784
8714
|
fonts: fontStats,
|
|
@@ -7792,6 +8722,7 @@ class AnalyticsService {
|
|
|
7792
8722
|
fonts: fontStats,
|
|
7793
8723
|
sprites: spriteStats,
|
|
7794
8724
|
glyphs: glyphStats,
|
|
8725
|
+
models: modelStats,
|
|
7795
8726
|
regions: regionAnalytics,
|
|
7796
8727
|
totalStorageSize,
|
|
7797
8728
|
storageByType,
|
|
@@ -7923,6 +8854,53 @@ function buildVectorJsonMetadata(style, sourceIds) {
|
|
|
7923
8854
|
return JSON.stringify({ vector_layers: merged });
|
|
7924
8855
|
}
|
|
7925
8856
|
const serviceLogger = logger.scope('ImportExportService');
|
|
8857
|
+
/** Map stored tile extensions to MBTiles 1.3 `format` values (`pbf`, `jpg`, ...). */
|
|
8858
|
+
function normalizeMBTilesFormat(ext) {
|
|
8859
|
+
const lower = ext.toLowerCase();
|
|
8860
|
+
if (lower === 'mvt')
|
|
8861
|
+
return 'pbf';
|
|
8862
|
+
if (lower === 'jpeg')
|
|
8863
|
+
return 'jpg';
|
|
8864
|
+
return lower;
|
|
8865
|
+
}
|
|
8866
|
+
/**
|
|
8867
|
+
* MBTiles holds one tile per z/x/y, so tiles from several style sources can't
|
|
8868
|
+
* share a file — they'd overwrite each other at every shared coordinate.
|
|
8869
|
+
* Keep only `sourceId` when given, otherwise the source with the most tiles
|
|
8870
|
+
* (the basemap in practice), and warn about what was left out.
|
|
8871
|
+
*/
|
|
8872
|
+
function selectSingleSource(tiles, sourceId) {
|
|
8873
|
+
const bySource = new Map();
|
|
8874
|
+
for (const tile of tiles) {
|
|
8875
|
+
const group = bySource.get(tile.sourceId);
|
|
8876
|
+
if (group)
|
|
8877
|
+
group.push(tile);
|
|
8878
|
+
else
|
|
8879
|
+
bySource.set(tile.sourceId, [tile]);
|
|
8880
|
+
}
|
|
8881
|
+
if (sourceId !== undefined) {
|
|
8882
|
+
const selected = bySource.get(sourceId);
|
|
8883
|
+
if (!selected) {
|
|
8884
|
+
const available = [...bySource.keys()].join(', ') || 'none';
|
|
8885
|
+
throw new Error(`Source "${sourceId}" has no tiles in this region (available: ${available})`);
|
|
8886
|
+
}
|
|
8887
|
+
return selected;
|
|
8888
|
+
}
|
|
8889
|
+
if (bySource.size <= 1)
|
|
8890
|
+
return tiles;
|
|
8891
|
+
let chosen = '';
|
|
8892
|
+
let chosenTiles = [];
|
|
8893
|
+
for (const [id, group] of bySource) {
|
|
8894
|
+
if (group.length > chosenTiles.length) {
|
|
8895
|
+
chosen = id;
|
|
8896
|
+
chosenTiles = group;
|
|
8897
|
+
}
|
|
8898
|
+
}
|
|
8899
|
+
const skipped = [...bySource.keys()].filter(id => id !== chosen);
|
|
8900
|
+
serviceLogger.warn(`Region has tiles from ${bySource.size} sources; MBTiles holds one. Exporting "${chosen}", ` +
|
|
8901
|
+
`skipping ${skipped.join(', ')}. Pass { sourceId } to choose another.`);
|
|
8902
|
+
return chosenTiles;
|
|
8903
|
+
}
|
|
7926
8904
|
class ImportExportService {
|
|
7927
8905
|
db = dbPromise;
|
|
7928
8906
|
constructor() {
|
|
@@ -7947,10 +8925,11 @@ class ImportExportService {
|
|
|
7947
8925
|
if (!region) {
|
|
7948
8926
|
throw new Error(`Region ${regionId} not found`);
|
|
7949
8927
|
}
|
|
7950
|
-
const
|
|
7951
|
-
|
|
7952
|
-
//
|
|
7953
|
-
|
|
8928
|
+
const regionTiles = await this.exportTiles(region, onProgress);
|
|
8929
|
+
const tiles = selectSingleSource(regionTiles, options.sourceId);
|
|
8930
|
+
// Pick format: caller override → stored tile extension → region.tileExtension
|
|
8931
|
+
// → default pbf. Drives both the metadata row and whether tile bytes get gzipped.
|
|
8932
|
+
const format = normalizeMBTilesFormat(String(options.format || tiles[0]?.format || region.tileExtension || 'pbf'));
|
|
7954
8933
|
const isVector = VECTOR_FORMATS.has(format);
|
|
7955
8934
|
onProgress({
|
|
7956
8935
|
stage: 'processing',
|
|
@@ -8175,11 +9154,19 @@ class ImportExportService {
|
|
|
8175
9154
|
/**
|
|
8176
9155
|
* Export tiles data
|
|
8177
9156
|
*/
|
|
8178
|
-
async exportTiles(
|
|
9157
|
+
async exportTiles(region, onProgress) {
|
|
8179
9158
|
const db = await this.db;
|
|
8180
|
-
|
|
8181
|
-
|
|
8182
|
-
|
|
9159
|
+
const styleId = region.styleId || region.id;
|
|
9160
|
+
// Tiles are stored per style, not per region. When the style holds other
|
|
9161
|
+
// regions too, keep only tiles inside this region's bounds and the zooms
|
|
9162
|
+
// the download pipeline fetches for each source (which can fall outside
|
|
9163
|
+
// the region's own range — see sourceZoomRange), or sibling regions' tiles
|
|
9164
|
+
// leak into the export. A sole region owns all of its style's tiles — and
|
|
9165
|
+
// an imported region's bounds come from MBTiles metadata, which can be
|
|
9166
|
+
// tighter than the tiles — so don't filter then.
|
|
9167
|
+
const styleEntry = await db.get('styles', styleId);
|
|
9168
|
+
const sharesStyle = (styleEntry?.regions?.length ?? 0) > 1;
|
|
9169
|
+
const styleSources = (styleEntry?.style?.sources ?? {});
|
|
8183
9170
|
const transaction = db.transaction(['tiles'], 'readonly');
|
|
8184
9171
|
const store = transaction.objectStore('tiles');
|
|
8185
9172
|
const tiles = [];
|
|
@@ -8188,22 +9175,31 @@ class ImportExportService {
|
|
|
8188
9175
|
let processed = 0;
|
|
8189
9176
|
while (cursor) {
|
|
8190
9177
|
const tile = cursor.value;
|
|
8191
|
-
|
|
8192
|
-
|
|
9178
|
+
const parsed = parseTileKey(String(tile.key));
|
|
9179
|
+
const z = tile.z ?? parsed?.z;
|
|
9180
|
+
const x = tile.x ?? parsed?.x;
|
|
9181
|
+
const y = tile.y ?? parsed?.y;
|
|
9182
|
+
if (tile.styleId === styleId &&
|
|
9183
|
+
z !== undefined &&
|
|
9184
|
+
x !== undefined &&
|
|
9185
|
+
y !== undefined &&
|
|
9186
|
+
(!sharesStyle ||
|
|
9187
|
+
tileBelongsToRegion({ sourceId: tile.sourceId ?? parsed?.sourceId, z, x, y }, region, styleSources))) {
|
|
8193
9188
|
tiles.push({
|
|
8194
|
-
z
|
|
8195
|
-
x
|
|
8196
|
-
y
|
|
9189
|
+
z,
|
|
9190
|
+
x,
|
|
9191
|
+
y,
|
|
8197
9192
|
data: tile.data,
|
|
8198
|
-
format: 'pbf',
|
|
8199
|
-
sourceId: tile.sourceId ?? 'default',
|
|
9193
|
+
format: (parsed?.ext || 'pbf').toLowerCase(),
|
|
9194
|
+
sourceId: tile.sourceId ?? parsed?.sourceId ?? 'default',
|
|
8200
9195
|
});
|
|
8201
9196
|
}
|
|
8202
9197
|
processed++;
|
|
8203
9198
|
if (onProgress && processed % 100 === 0) {
|
|
8204
9199
|
onProgress({
|
|
8205
9200
|
stage: 'exporting',
|
|
8206
|
-
|
|
9201
|
+
// Total is unknown while walking the cursor; approach 70% asymptotically.
|
|
9202
|
+
percentage: 30 + 40 * (1 - 1 / (1 + processed / 1000)),
|
|
8207
9203
|
message: `Exported ${processed} tiles...`,
|
|
8208
9204
|
currentItem: `${tile.z ?? 0}/${tile.x ?? 0}/${tile.y ?? 0}`,
|
|
8209
9205
|
completedItems: processed,
|
|
@@ -8384,7 +9380,7 @@ class ImportExportService {
|
|
|
8384
9380
|
sourceId,
|
|
8385
9381
|
downloadedAt: new Date().toISOString(),
|
|
8386
9382
|
size: tile.data instanceof ArrayBuffer ? tile.data.byteLength : 0,
|
|
8387
|
-
type: 'vector',
|
|
9383
|
+
type: VECTOR_FORMATS.has(ext) ? 'vector' : 'raster',
|
|
8388
9384
|
url: `tile://${tile.z}/${tile.x}/${tile.y}`,
|
|
8389
9385
|
lastModified: Date.now(),
|
|
8390
9386
|
});
|
|
@@ -8428,7 +9424,7 @@ const createRegionManagement = (services) => ({
|
|
|
8428
9424
|
addRegion: async (region) => services.regionService.addRegion(region),
|
|
8429
9425
|
downloadRegion: async (region, options) => services.regionService.downloadRegion(region, options),
|
|
8430
9426
|
loadRegion: async (region, options) => services.regionService.loadRegion(region, options),
|
|
8431
|
-
deleteRegion: async (regionId) => services.regionService.deleteRegion(regionId),
|
|
9427
|
+
deleteRegion: async (regionId, styleId) => services.regionService.deleteRegion(regionId, styleId),
|
|
8432
9428
|
listRegions: async () => services.regionService.listRegions(),
|
|
8433
9429
|
listStoredRegions: async () => services.regionService.listStoredRegions(),
|
|
8434
9430
|
getStoredRegion: async (regionId) => {
|
|
@@ -8450,7 +9446,7 @@ const createCleanupManagement = (services) => ({
|
|
|
8450
9446
|
let deletedCount = 0;
|
|
8451
9447
|
for (const region of allRegions) {
|
|
8452
9448
|
if (region.expiry && region.expiry < now) {
|
|
8453
|
-
await services.regionService.deleteRegion(region.id);
|
|
9449
|
+
await services.regionService.deleteRegion(region.id, region.styleId);
|
|
8454
9450
|
deletedCount++;
|
|
8455
9451
|
}
|
|
8456
9452
|
}
|
|
@@ -8901,7 +9897,7 @@ const en = {
|
|
|
8901
9897
|
'language.ar': 'Arabic',
|
|
8902
9898
|
// Header
|
|
8903
9899
|
'header.title': 'Offline Manager',
|
|
8904
|
-
'header.subtitle': '{{
|
|
9900
|
+
'header.subtitle': '{{regions}} • {{size}} total',
|
|
8905
9901
|
// Actions
|
|
8906
9902
|
'actions.addRegion': 'Add Region',
|
|
8907
9903
|
'actions.refresh': 'Refresh',
|
|
@@ -8917,6 +9913,8 @@ const en = {
|
|
|
8917
9913
|
'regionForm.namePlaceholder': 'e.g., Downtown District',
|
|
8918
9914
|
'regionForm.minZoom': 'Min Zoom',
|
|
8919
9915
|
'regionForm.maxZoom': 'Max Zoom',
|
|
9916
|
+
'regionForm.buildingsHint': "Mapbox Standard's buildings start at zoom 15. Set Max Zoom to 15 or more to include them.",
|
|
9917
|
+
'regionForm.zoomRangeError': 'Max Zoom must be at least Min Zoom.',
|
|
8920
9918
|
'regionForm.styleUrl': 'Style URL',
|
|
8921
9919
|
'regionForm.provider': 'Style Provider',
|
|
8922
9920
|
'regionForm.providerAuto': 'Auto-detect',
|
|
@@ -8946,7 +9944,10 @@ const en = {
|
|
|
8946
9944
|
'regionList.downloaded': 'Downloaded',
|
|
8947
9945
|
'regionList.size': 'Size',
|
|
8948
9946
|
'regionList.tiles': 'Tiles',
|
|
9947
|
+
'regionList.resources': 'Fonts, icons and models',
|
|
8949
9948
|
'regionList.styleData': 'Style',
|
|
9949
|
+
'regionList.regionCountOne': '1 region',
|
|
9950
|
+
'regionList.regionCount': '{{count}} regions',
|
|
8950
9951
|
'regionList.totalSize': 'Total',
|
|
8951
9952
|
'regionList.styleId': 'Style ID',
|
|
8952
9953
|
// Region Details
|
|
@@ -9032,6 +10033,13 @@ const en = {
|
|
|
9032
10033
|
'mbtiles.importFailed': 'Import failed. Please try again.',
|
|
9033
10034
|
// Active Downloads
|
|
9034
10035
|
'download.activeCount': 'Active Downloads ({{count}})',
|
|
10036
|
+
'download.status.style': 'Preparing style',
|
|
10037
|
+
'download.status.sprites': 'Sprites: {{completed}} of {{total}}',
|
|
10038
|
+
'download.status.glyphs': 'Fonts: {{completed}} of {{total}}',
|
|
10039
|
+
'download.status.models': '3D models: {{completed}} of {{total}}',
|
|
10040
|
+
'download.status.tiles': 'Tiles: {{completed}} of {{total}}',
|
|
10041
|
+
'download.status.planning': 'Planning tiles',
|
|
10042
|
+
'download.status.saving': 'Saving region',
|
|
9035
10043
|
// Panel Manager additional strings
|
|
9036
10044
|
'panel.downloadedRegions': 'Downloaded Regions',
|
|
9037
10045
|
'panel.noExpiry': 'No expiry',
|
|
@@ -9053,6 +10061,8 @@ const en = {
|
|
|
9053
10061
|
'control.saveSelectedRegion': 'Save Selected Region',
|
|
9054
10062
|
// Alerts/Messages
|
|
9055
10063
|
'alert.unknownError': 'Unknown error occurred',
|
|
10064
|
+
'alert.downloadFailed': 'Download failed',
|
|
10065
|
+
'alert.needsMapboxGl': 'This style uses Mapbox GL JS features, such as 3D models or style config, so only a Mapbox GL map can show it.',
|
|
9056
10066
|
'alert.downloadNotImplemented': 'Download feature not implemented in this refactored version',
|
|
9057
10067
|
'alert.mapNotAvailable': 'Map not available',
|
|
9058
10068
|
'alert.invalidBounds': 'Invalid bounds for region',
|
|
@@ -9109,7 +10119,7 @@ const ar = {
|
|
|
9109
10119
|
'language.ar': 'العربية',
|
|
9110
10120
|
// Header - الرأس
|
|
9111
10121
|
'header.title': 'مدير الخرائط غير المتصلة',
|
|
9112
|
-
'header.subtitle': '{{
|
|
10122
|
+
'header.subtitle': '{{regions}} • {{size}} إجمالي',
|
|
9113
10123
|
// Actions - الإجراءات
|
|
9114
10124
|
'actions.addRegion': 'إضافة منطقة',
|
|
9115
10125
|
'actions.refresh': 'تحديث',
|
|
@@ -9125,6 +10135,8 @@ const ar = {
|
|
|
9125
10135
|
'regionForm.namePlaceholder': 'مثال: وسط المدينة',
|
|
9126
10136
|
'regionForm.minZoom': 'أدنى تكبير',
|
|
9127
10137
|
'regionForm.maxZoom': 'أقصى تكبير',
|
|
10138
|
+
'regionForm.buildingsHint': 'تبدأ مباني Mapbox Standard عند مستوى التكبير 15. اضبط أقصى تكبير على 15 أو أكثر لتضمينها.',
|
|
10139
|
+
'regionForm.zoomRangeError': 'يجب ألا يقل أقصى تكبير عن أدنى تكبير.',
|
|
9128
10140
|
'regionForm.styleUrl': 'رابط النمط',
|
|
9129
10141
|
'regionForm.provider': 'مزود النمط',
|
|
9130
10142
|
'regionForm.providerAuto': 'اكتشاف تلقائي',
|
|
@@ -9154,7 +10166,10 @@ const ar = {
|
|
|
9154
10166
|
'regionList.downloaded': 'تم التحميل',
|
|
9155
10167
|
'regionList.size': 'الحجم',
|
|
9156
10168
|
'regionList.tiles': 'البلاطات',
|
|
10169
|
+
'regionList.resources': 'الخطوط والرموز والنماذج',
|
|
9157
10170
|
'regionList.styleData': 'النمط',
|
|
10171
|
+
'regionList.regionCountOne': 'منطقة واحدة',
|
|
10172
|
+
'regionList.regionCount': '{{count}} منطقة',
|
|
9158
10173
|
'regionList.totalSize': 'الإجمالي',
|
|
9159
10174
|
'regionList.styleId': 'معرف النمط',
|
|
9160
10175
|
// Region Details - تفاصيل المنطقة
|
|
@@ -9240,6 +10255,13 @@ const ar = {
|
|
|
9240
10255
|
'mbtiles.importFailed': 'فشل الاستيراد. يرجى المحاولة مرة أخرى.',
|
|
9241
10256
|
// Active Downloads - التحميلات النشطة
|
|
9242
10257
|
'download.activeCount': 'التحميلات النشطة ({{count}})',
|
|
10258
|
+
'download.status.style': 'جارٍ تجهيز النمط',
|
|
10259
|
+
'download.status.sprites': 'الرموز: {{completed}} من {{total}}',
|
|
10260
|
+
'download.status.glyphs': 'الخطوط: {{completed}} من {{total}}',
|
|
10261
|
+
'download.status.models': 'النماذج ثلاثية الأبعاد: {{completed}} من {{total}}',
|
|
10262
|
+
'download.status.tiles': 'البلاطات: {{completed}} من {{total}}',
|
|
10263
|
+
'download.status.planning': 'جارٍ تخطيط البلاطات',
|
|
10264
|
+
'download.status.saving': 'جارٍ حفظ المنطقة',
|
|
9243
10265
|
// Panel Manager additional strings - سلاسل إضافية للوحة
|
|
9244
10266
|
'panel.downloadedRegions': 'المناطق المحملة',
|
|
9245
10267
|
'panel.noExpiry': 'بدون انتهاء',
|
|
@@ -9261,6 +10283,8 @@ const ar = {
|
|
|
9261
10283
|
'control.saveSelectedRegion': 'حفظ المنطقة المحددة',
|
|
9262
10284
|
// Alerts/Messages - التنبيهات/الرسائل
|
|
9263
10285
|
'alert.unknownError': 'حدث خطأ غير معروف',
|
|
10286
|
+
'alert.downloadFailed': 'فشل التحميل',
|
|
10287
|
+
'alert.needsMapboxGl': 'يستخدم هذا النمط ميزات Mapbox GL JS، مثل النماذج ثلاثية الأبعاد أو إعدادات النمط، لذا لا يمكن عرضه إلا على خريطة Mapbox GL.',
|
|
9264
10288
|
'alert.downloadNotImplemented': 'ميزة التحميل غير مطبقة في هذه النسخة',
|
|
9265
10289
|
'alert.mapNotAvailable': 'الخريطة غير متاحة',
|
|
9266
10290
|
'alert.invalidBounds': 'حدود غير صالحة للمنطقة',
|
|
@@ -9395,6 +10419,9 @@ const i18n = new I18nManager();
|
|
|
9395
10419
|
const t = (key, replacements) => {
|
|
9396
10420
|
return i18n.t(key, replacements);
|
|
9397
10421
|
};
|
|
10422
|
+
// Re-export i18next for advanced usage
|
|
10423
|
+
/** "1 region" or "3 regions", in the current language. */
|
|
10424
|
+
const regionCountLabel = (count) => count === 1 ? t('regionList.regionCountOne') : t('regionList.regionCount', { count });
|
|
9398
10425
|
|
|
9399
10426
|
/**
|
|
9400
10427
|
* Base Component Class
|
|
@@ -9615,7 +10642,7 @@ class Button extends BaseComponent {
|
|
|
9615
10642
|
createProgressBadge() {
|
|
9616
10643
|
this.progressBadge = document.createElement('span');
|
|
9617
10644
|
this.progressBadge.className =
|
|
9618
|
-
'absolute
|
|
10645
|
+
'progress-badge absolute top-1/2 right-full mr-1.5 -translate-y-1/2 whitespace-nowrap bg-blue-600 text-white rounded-md px-1.5 py-0.5 text-xs font-semibold shadow-md pointer-events-none hidden';
|
|
9619
10646
|
// Accessibility: badge ID for aria-describedby
|
|
9620
10647
|
const badgeId = `btn-badge-${Date.now()}`;
|
|
9621
10648
|
this.progressBadge.id = badgeId;
|
|
@@ -9632,6 +10659,20 @@ class Button extends BaseComponent {
|
|
|
9632
10659
|
else {
|
|
9633
10660
|
this.element.textContent = text;
|
|
9634
10661
|
}
|
|
10662
|
+
this.restoreProgressBadge();
|
|
10663
|
+
}
|
|
10664
|
+
/**
|
|
10665
|
+
* Replace the button's content, keeping its progress badge.
|
|
10666
|
+
*/
|
|
10667
|
+
setContent(html) {
|
|
10668
|
+
this.element.innerHTML = html;
|
|
10669
|
+
this.restoreProgressBadge();
|
|
10670
|
+
}
|
|
10671
|
+
/** Replacing the button's content removes the badge, so put it back. */
|
|
10672
|
+
restoreProgressBadge() {
|
|
10673
|
+
if (this.progressBadge) {
|
|
10674
|
+
this.element.appendChild(this.progressBadge);
|
|
10675
|
+
}
|
|
9635
10676
|
}
|
|
9636
10677
|
/**
|
|
9637
10678
|
* Update button state
|
|
@@ -9727,7 +10768,7 @@ class ButtonManager {
|
|
|
9727
10768
|
* Reset button to default state
|
|
9728
10769
|
*/
|
|
9729
10770
|
resetToDefault() {
|
|
9730
|
-
this.button.
|
|
10771
|
+
this.button.setContent(icons.cloud({ size: 20, color: 'black' }));
|
|
9731
10772
|
this.button.setDisabled(false);
|
|
9732
10773
|
this.button.getElement().title = 'Offline Map Manager';
|
|
9733
10774
|
this.button.hideProgressBadge();
|
|
@@ -9840,8 +10881,7 @@ class Modal extends BaseComponent {
|
|
|
9840
10881
|
titleSection.className = 'flex flex-col';
|
|
9841
10882
|
if (this.config.title) {
|
|
9842
10883
|
const title = document.createElement('h2');
|
|
9843
|
-
title.className =
|
|
9844
|
-
'text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-300';
|
|
10884
|
+
title.className = 'text-xl font-semibold text-gray-900 dark:text-white';
|
|
9845
10885
|
title.textContent = this.config.title;
|
|
9846
10886
|
titleSection.appendChild(title);
|
|
9847
10887
|
}
|
|
@@ -10020,14 +11060,14 @@ class RegionDetailsModal {
|
|
|
10020
11060
|
}
|
|
10021
11061
|
content.innerHTML = `
|
|
10022
11062
|
<div class="glass-input p-4 rounded-xl border-0 bg-gray-50/50 dark:bg-gray-800/50">
|
|
10023
|
-
<h3 class="m-0 text-xl font-
|
|
11063
|
+
<h3 class="m-0 text-xl font-semibold text-gray-900 dark:text-white">
|
|
10024
11064
|
${region.name}
|
|
10025
11065
|
</h3>
|
|
10026
11066
|
</div>
|
|
10027
11067
|
|
|
10028
11068
|
<div class="grid grid-cols-2 gap-4">
|
|
10029
11069
|
<div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
|
|
10030
|
-
<label class="block mb-1.5 text-
|
|
11070
|
+
<label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
|
|
10031
11071
|
${t('regionDetails.bounds')}
|
|
10032
11072
|
</label>
|
|
10033
11073
|
<div class="text-sm font-mono text-gray-700 dark:text-gray-300 leading-relaxed">
|
|
@@ -10037,7 +11077,7 @@ class RegionDetailsModal {
|
|
|
10037
11077
|
</div>
|
|
10038
11078
|
|
|
10039
11079
|
<div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
|
|
10040
|
-
<label class="block mb-1.5 text-
|
|
11080
|
+
<label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
|
|
10041
11081
|
${t('regionDetails.zoomRange')}
|
|
10042
11082
|
</label>
|
|
10043
11083
|
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
@@ -10049,7 +11089,7 @@ class RegionDetailsModal {
|
|
|
10049
11089
|
${region.created
|
|
10050
11090
|
? `
|
|
10051
11091
|
<div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
|
|
10052
|
-
<label class="block mb-1.5 text-
|
|
11092
|
+
<label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
|
|
10053
11093
|
${t('regionDetails.created')}
|
|
10054
11094
|
</label>
|
|
10055
11095
|
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
@@ -10804,11 +11844,13 @@ class PanelRenderer extends BaseComponent {
|
|
|
10804
11844
|
headerContainer;
|
|
10805
11845
|
actionButtonsContainer;
|
|
10806
11846
|
regionsList;
|
|
11847
|
+
awaitingProgressRender = false;
|
|
10807
11848
|
downloadProgressContainer;
|
|
10808
11849
|
languageSelector;
|
|
10809
11850
|
// Debounce mechanism
|
|
10810
11851
|
refreshTimeout;
|
|
10811
11852
|
isRefreshing = false;
|
|
11853
|
+
refreshQueued = false;
|
|
10812
11854
|
// Language change unsubscribe function
|
|
10813
11855
|
unsubscribeLanguage;
|
|
10814
11856
|
// Stored event handler for cleanup
|
|
@@ -10877,7 +11919,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
10877
11919
|
titleSection.innerHTML = `
|
|
10878
11920
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">${t('header.title')}</h2>
|
|
10879
11921
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
10880
|
-
${t('header.subtitle', {
|
|
11922
|
+
${t('header.subtitle', { regions: regionCountLabel(regions.length), size: formatBytes(analytics.totalStorageSize) })}
|
|
10881
11923
|
</p>
|
|
10882
11924
|
`;
|
|
10883
11925
|
// Actions section
|
|
@@ -10951,6 +11993,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
10951
11993
|
this.element.removeChild(this.downloadProgressContainer);
|
|
10952
11994
|
this.downloadProgressContainer = undefined;
|
|
10953
11995
|
}
|
|
11996
|
+
this.awaitingProgressRender = false;
|
|
10954
11997
|
// Only show if there are active downloads
|
|
10955
11998
|
if (this.downloadManager?.hasActiveDownloads()) {
|
|
10956
11999
|
this.downloadProgressContainer = document.createElement('div');
|
|
@@ -10973,9 +12016,10 @@ class PanelRenderer extends BaseComponent {
|
|
|
10973
12016
|
}
|
|
10974
12017
|
try {
|
|
10975
12018
|
// Load styles from IndexedDB and get stats
|
|
10976
|
-
const { loadStyles, getStyleStats } = await Promise.resolve().then(function () { return styleService; });
|
|
12019
|
+
const { loadStyles, getStyleStats, getStyleStorageSizes } = await Promise.resolve().then(function () { return styleService; });
|
|
10977
12020
|
const styles = await loadStyles();
|
|
10978
12021
|
const statsResult = await getStyleStats();
|
|
12022
|
+
const storageSizes = await getStyleStorageSizes();
|
|
10979
12023
|
panelLogger.debug('Loaded styles:', styles);
|
|
10980
12024
|
panelLogger.debug('Style stats:', statsResult);
|
|
10981
12025
|
// Build style size map from stats
|
|
@@ -10998,12 +12042,14 @@ class PanelRenderer extends BaseComponent {
|
|
|
10998
12042
|
for (const style of styles) {
|
|
10999
12043
|
const styleRegions = regionsByStyle[style.key] || [];
|
|
11000
12044
|
const styleJsonSize = styleSizeMap[style.key] || 0;
|
|
11001
|
-
const regionTilesSize =
|
|
11002
|
-
const
|
|
12045
|
+
const regionTilesSize = storageSizes[style.key]?.tiles ?? 0;
|
|
12046
|
+
const resourcesSize = storageSizes[style.key]?.resources ?? 0;
|
|
12047
|
+
const totalSize = styleJsonSize + regionTilesSize + resourcesSize;
|
|
11003
12048
|
// Add style item with computed sizes
|
|
11004
12049
|
const styleWithSizes = {
|
|
11005
12050
|
...style,
|
|
11006
12051
|
styleJsonSize,
|
|
12052
|
+
resourcesSize,
|
|
11007
12053
|
regionTilesSize,
|
|
11008
12054
|
totalSize,
|
|
11009
12055
|
};
|
|
@@ -11119,7 +12165,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
11119
12165
|
<div class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
|
11120
12166
|
${region.bounds ? `<span class="font-medium">SW:</span> ${region.bounds[0][1].toFixed(4)}, ${region.bounds[0][0].toFixed(4)} <span class="mx-1">→</span> <span class="font-medium">NE:</span> ${region.bounds[1][1].toFixed(4)}, ${region.bounds[1][0].toFixed(4)}` : t('panel.noBounds')}
|
|
11121
12167
|
</div>
|
|
11122
|
-
<div class="flex items-center gap-4 text-xs text-slate-
|
|
12168
|
+
<div class="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400 mt-2">
|
|
11123
12169
|
<span>${t('regionList.zoom')}: ${region.minZoom}-${region.maxZoom}</span>
|
|
11124
12170
|
${region.downloadedAt ? `<span>${t('regionList.downloaded')}: ${new Date(region.downloadedAt).toLocaleDateString()}</span>` : ''}
|
|
11125
12171
|
${region.size ? `<span>${t('regionList.size')}: ${formatBytes(region.size)}</span>` : ''}
|
|
@@ -11142,16 +12188,16 @@ class PanelRenderer extends BaseComponent {
|
|
|
11142
12188
|
<div class="flex items-center justify-between">
|
|
11143
12189
|
<div class="flex-1">
|
|
11144
12190
|
<h3 class="font-semibold text-slate-800 dark:text-slate-100 text-base">
|
|
11145
|
-
${escapeHtml$1(
|
|
12191
|
+
${escapeHtml$1(styleDisplayName(style))}
|
|
11146
12192
|
</h3>
|
|
11147
12193
|
<p class="text-sm text-slate-600 dark:text-slate-300 mt-1">
|
|
11148
12194
|
${t('regionList.styleId')}: ${escapeHtml$1(style.key)}
|
|
11149
12195
|
</p>
|
|
11150
12196
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
|
11151
|
-
${regionCount}
|
|
12197
|
+
${regionCountLabel(regionCount)} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
|
|
11152
12198
|
</p>
|
|
11153
|
-
<p class="text-xs text-slate-
|
|
11154
|
-
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
12199
|
+
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
|
12200
|
+
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.resources')}: ${formatBytes(style.resourcesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
11155
12201
|
</p>
|
|
11156
12202
|
</div>
|
|
11157
12203
|
<div class="flex items-center gap-2">
|
|
@@ -11185,7 +12231,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
11185
12231
|
<div class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
|
11186
12232
|
${region.bounds ? `<span class="font-medium">SW:</span> ${region.bounds[0][1].toFixed(4)}, ${region.bounds[0][0].toFixed(4)} <span class="mx-1">→</span> <span class="font-medium">NE:</span> ${region.bounds[1][1].toFixed(4)}, ${region.bounds[1][0].toFixed(4)}` : t('panel.noBounds')}
|
|
11187
12233
|
</div>
|
|
11188
|
-
<div class="flex items-center gap-4 text-xs text-slate-
|
|
12234
|
+
<div class="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400 mt-2">
|
|
11189
12235
|
<span>${t('regionList.zoom')}: ${region.minZoom}-${region.maxZoom}</span>
|
|
11190
12236
|
${region.downloadedAt ? `<span>${t('regionList.downloaded')}: ${new Date(region.downloadedAt).toLocaleDateString()}</span>` : ''}
|
|
11191
12237
|
${region.tileSize ? `<span>${t('regionList.tiles')}: ${formatBytes(region.tileSize)}</span>` : ''}
|
|
@@ -11224,16 +12270,16 @@ class PanelRenderer extends BaseComponent {
|
|
|
11224
12270
|
<div class="flex items-center justify-between">
|
|
11225
12271
|
<div class="flex-1">
|
|
11226
12272
|
<h3 class="font-semibold text-blue-900 dark:text-blue-100 text-base">
|
|
11227
|
-
${escapeHtml$1(
|
|
12273
|
+
${escapeHtml$1(styleDisplayName(style))}
|
|
11228
12274
|
</h3>
|
|
11229
12275
|
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
|
11230
12276
|
${t('regionList.styleId')}: ${escapeHtml$1(style.key)}
|
|
11231
12277
|
</p>
|
|
11232
12278
|
<p class="text-sm text-blue-600 dark:text-blue-400 mt-1">
|
|
11233
|
-
${regionCount}
|
|
12279
|
+
${regionCountLabel(regionCount)} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
|
|
11234
12280
|
</p>
|
|
11235
12281
|
<p class="text-xs text-blue-500 dark:text-blue-500 mt-1">
|
|
11236
|
-
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
12282
|
+
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.resources')}: ${formatBytes(style.resourcesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
11237
12283
|
</p>
|
|
11238
12284
|
</div>
|
|
11239
12285
|
<div class="flex items-center gap-2">
|
|
@@ -11273,6 +12319,39 @@ class PanelRenderer extends BaseComponent {
|
|
|
11273
12319
|
</div>
|
|
11274
12320
|
`;
|
|
11275
12321
|
}
|
|
12322
|
+
/**
|
|
12323
|
+
* Show the latest download progress. Called on every update, so it only
|
|
12324
|
+
* redraws the progress section rather than the whole panel.
|
|
12325
|
+
*/
|
|
12326
|
+
updateDownloadProgress(downloads) {
|
|
12327
|
+
if (downloads.size === 0)
|
|
12328
|
+
return;
|
|
12329
|
+
const container = this.downloadProgressContainer;
|
|
12330
|
+
if (container?.isConnected) {
|
|
12331
|
+
container.innerHTML = this.createDownloadProgressHTML(downloads);
|
|
12332
|
+
return;
|
|
12333
|
+
}
|
|
12334
|
+
// The download started while the panel was open, so it has no section yet.
|
|
12335
|
+
if (this.element.isConnected && !this.awaitingProgressRender) {
|
|
12336
|
+
this.awaitingProgressRender = true;
|
|
12337
|
+
void this.refresh();
|
|
12338
|
+
}
|
|
12339
|
+
}
|
|
12340
|
+
/** What the current phase is doing, such as "Tiles: 64 of 128". */
|
|
12341
|
+
describeDownloadPhase(download) {
|
|
12342
|
+
const phase = download.phase;
|
|
12343
|
+
if (phase === 'style')
|
|
12344
|
+
return t('download.status.style');
|
|
12345
|
+
if (phase === 'tiles' && !download.total)
|
|
12346
|
+
return t('download.status.planning');
|
|
12347
|
+
if (phase === 'sprites' || phase === 'glyphs' || phase === 'models' || phase === 'tiles') {
|
|
12348
|
+
return t(`download.status.${phase}`, {
|
|
12349
|
+
completed: Number(download.completed) || 0,
|
|
12350
|
+
total: Number(download.total) || 0,
|
|
12351
|
+
});
|
|
12352
|
+
}
|
|
12353
|
+
return t('download.status.saving');
|
|
12354
|
+
}
|
|
11276
12355
|
/**
|
|
11277
12356
|
* Create download progress HTML
|
|
11278
12357
|
*/
|
|
@@ -11286,11 +12365,14 @@ class PanelRenderer extends BaseComponent {
|
|
|
11286
12365
|
<div class="space-y-2">
|
|
11287
12366
|
${downloadArray
|
|
11288
12367
|
.map(download => `
|
|
11289
|
-
<div class="flex items-center justify-between text-sm">
|
|
11290
|
-
<
|
|
11291
|
-
|
|
11292
|
-
<div class="
|
|
11293
|
-
|
|
12368
|
+
<div class="flex items-center justify-between gap-4 text-sm">
|
|
12369
|
+
<div class="min-w-0">
|
|
12370
|
+
<div class="truncate text-blue-800 dark:text-blue-200">${escapeHtml$1(download.regionName || download.regionId)}</div>
|
|
12371
|
+
<div class="text-xs text-gray-600 dark:text-gray-300">${escapeHtml$1(this.describeDownloadPhase(download))}</div>
|
|
12372
|
+
</div>
|
|
12373
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
12374
|
+
<div class="w-24 h-2 bg-blue-200 dark:bg-blue-800 rounded-full overflow-hidden" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(download.percentage || 0)}">
|
|
12375
|
+
<div class="h-full bg-blue-600" style="width: ${download.percentage || 0}%"></div>
|
|
11294
12376
|
</div>
|
|
11295
12377
|
<span class="text-blue-700 dark:text-blue-300 min-w-12">${Math.round(download.percentage || 0)}%</span>
|
|
11296
12378
|
</div>
|
|
@@ -11367,7 +12449,15 @@ class PanelRenderer extends BaseComponent {
|
|
|
11367
12449
|
cancelText: t('app.cancel'),
|
|
11368
12450
|
onConfirm: async () => {
|
|
11369
12451
|
try {
|
|
11370
|
-
|
|
12452
|
+
// Deleting a style's last region deletes the style too.
|
|
12453
|
+
const { loadStyleById } = await Promise.resolve().then(function () { return styleService; });
|
|
12454
|
+
const shownEntry = this.isStyleShown(region.styleId)
|
|
12455
|
+
? await loadStyleById(region.styleId)
|
|
12456
|
+
: null;
|
|
12457
|
+
await this.offlineManager.deleteRegion(regionId, region.styleId);
|
|
12458
|
+
if (shownEntry && !(await loadStyleById(region.styleId))) {
|
|
12459
|
+
this.showOnlineStyle(shownEntry);
|
|
12460
|
+
}
|
|
11371
12461
|
this.modalManager.close();
|
|
11372
12462
|
// Refresh the panel to show updated regions
|
|
11373
12463
|
await this.refresh();
|
|
@@ -11420,7 +12510,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
11420
12510
|
}
|
|
11421
12511
|
// Delete the existing region (and its tiles/resources)
|
|
11422
12512
|
panelLogger.debug('Deleting region before re-download:', regionId);
|
|
11423
|
-
await this.offlineManager.deleteRegion(regionId);
|
|
12513
|
+
await this.offlineManager.deleteRegion(regionId, region.styleId);
|
|
11424
12514
|
// Prepare form data for re-download
|
|
11425
12515
|
const formData = {
|
|
11426
12516
|
name: region.name,
|
|
@@ -11641,7 +12731,8 @@ class PanelRenderer extends BaseComponent {
|
|
|
11641
12731
|
clearTimeout(this.refreshTimeout);
|
|
11642
12732
|
}
|
|
11643
12733
|
if (this.isRefreshing) {
|
|
11644
|
-
|
|
12734
|
+
// Run again once this one finishes, or the panel misses what changed meanwhile.
|
|
12735
|
+
this.refreshQueued = true;
|
|
11645
12736
|
return;
|
|
11646
12737
|
}
|
|
11647
12738
|
this.refreshTimeout = setTimeout(async () => {
|
|
@@ -11685,6 +12776,10 @@ class PanelRenderer extends BaseComponent {
|
|
|
11685
12776
|
}
|
|
11686
12777
|
finally {
|
|
11687
12778
|
this.isRefreshing = false;
|
|
12779
|
+
if (this.refreshQueued) {
|
|
12780
|
+
this.refreshQueued = false;
|
|
12781
|
+
void this.refresh();
|
|
12782
|
+
}
|
|
11688
12783
|
}
|
|
11689
12784
|
}
|
|
11690
12785
|
/**
|
|
@@ -11822,6 +12917,20 @@ class PanelRenderer extends BaseComponent {
|
|
|
11822
12917
|
/**
|
|
11823
12918
|
* Handle loading a style to the map
|
|
11824
12919
|
*/
|
|
12920
|
+
/** Whether the map is drawing this stored style, through `idb://` or the Service Worker. */
|
|
12921
|
+
isStyleShown(styleKey) {
|
|
12922
|
+
const shown = this.map?.getStyle?.();
|
|
12923
|
+
if (!shown)
|
|
12924
|
+
return false;
|
|
12925
|
+
const text = JSON.stringify(shown);
|
|
12926
|
+
return text.includes(`idb://${styleKey}/`) || text.includes(`/__offline__/${styleKey}/`);
|
|
12927
|
+
}
|
|
12928
|
+
/** After its stored copy is deleted, show a style from its original URL, not a blank map. */
|
|
12929
|
+
showOnlineStyle(entry) {
|
|
12930
|
+
if (!entry.originalUrl)
|
|
12931
|
+
return;
|
|
12932
|
+
this.map?.setStyle?.(entry.originalUrl);
|
|
12933
|
+
}
|
|
11825
12934
|
async handleLoadStyle(styleData) {
|
|
11826
12935
|
if (!this.map) {
|
|
11827
12936
|
panelLogger.warn('Map not available for loading style');
|
|
@@ -11840,6 +12949,10 @@ class PanelRenderer extends BaseComponent {
|
|
|
11840
12949
|
alert(t('error.invalidStyleData'));
|
|
11841
12950
|
return;
|
|
11842
12951
|
}
|
|
12952
|
+
if (needsMapboxGl(styleEntry.style) && !isMapboxGlMap(this.map)) {
|
|
12953
|
+
alert(t('alert.needsMapboxGl'));
|
|
12954
|
+
return;
|
|
12955
|
+
}
|
|
11843
12956
|
panelLogger.debug('Loading style from IndexedDB:', {
|
|
11844
12957
|
key: styleEntry.key,
|
|
11845
12958
|
provider: styleEntry.provider,
|
|
@@ -12024,13 +13137,16 @@ class PanelRenderer extends BaseComponent {
|
|
|
12024
13137
|
const style = styleData;
|
|
12025
13138
|
const confirmModal = new ConfirmationModal({
|
|
12026
13139
|
title: t('delete.styleTitle'),
|
|
12027
|
-
message: t('delete.styleMessage', { name: style
|
|
13140
|
+
message: t('delete.styleMessage', { name: styleDisplayName(style) }),
|
|
12028
13141
|
confirmText: t('actions.deleteStyle'),
|
|
12029
13142
|
cancelText: t('app.cancel'),
|
|
12030
13143
|
onConfirm: async () => {
|
|
12031
13144
|
try {
|
|
12032
13145
|
const { deleteStyleById } = await Promise.resolve().then(function () { return styleService; });
|
|
13146
|
+
const wasShown = this.isStyleShown(style.key);
|
|
12033
13147
|
await deleteStyleById(style.key);
|
|
13148
|
+
if (wasShown)
|
|
13149
|
+
this.showOnlineStyle(styleData);
|
|
12034
13150
|
this.modalManager.close();
|
|
12035
13151
|
// Refresh the panel
|
|
12036
13152
|
await this.refresh();
|
|
@@ -12135,7 +13251,7 @@ class PolygonControl {
|
|
|
12135
13251
|
this.saveButton = document.createElement('button');
|
|
12136
13252
|
this.saveButton.type = 'button';
|
|
12137
13253
|
const prefix = this.options.cssPrefix || 'maplibregl';
|
|
12138
|
-
this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-
|
|
13254
|
+
this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 !bg-green-600 border border-green-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:!bg-green-700 transition-all duration-200`;
|
|
12139
13255
|
this.saveButton.innerHTML = icons.check({ size: 16, color: 'white' });
|
|
12140
13256
|
this.saveButton.title = 'Save Selected Region';
|
|
12141
13257
|
this.saveButton.addEventListener('click', () => this.handleSave());
|
|
@@ -12202,6 +13318,33 @@ class PolygonControl {
|
|
|
12202
13318
|
handleMapMove = () => {
|
|
12203
13319
|
this.updateVisualization();
|
|
12204
13320
|
};
|
|
13321
|
+
/**
|
|
13322
|
+
* The area to select: the middle of what's on screen. On a tilted or rotated map
|
|
13323
|
+
* `getBounds()` reaches far past the view, so insetting it would miss the part
|
|
13324
|
+
* nearest the camera. There, measure from the map's centre to the screen's edges.
|
|
13325
|
+
*/
|
|
13326
|
+
getSelectionBbox(minLng, minLat, maxLng, maxLat) {
|
|
13327
|
+
if (!this.map.getPitch() && !this.map.getBearing()) {
|
|
13328
|
+
const lngInset = (maxLng - minLng) * 0.2;
|
|
13329
|
+
const latInset = (maxLat - minLat) * 0.2;
|
|
13330
|
+
return [minLng + lngInset, minLat + latInset, maxLng - lngInset, maxLat - latInset];
|
|
13331
|
+
}
|
|
13332
|
+
const center = this.map.getCenter();
|
|
13333
|
+
const { clientWidth, clientHeight } = this.map.getContainer();
|
|
13334
|
+
const lngScale = Math.cos((center.lat * Math.PI) / 180);
|
|
13335
|
+
const degreesFromCenter = (point) => {
|
|
13336
|
+
const { lng, lat } = this.map.unproject(point);
|
|
13337
|
+
return Math.hypot((lng - center.lng) * lngScale, lat - center.lat);
|
|
13338
|
+
};
|
|
13339
|
+
const halfSize = 0.6 *
|
|
13340
|
+
Math.min(degreesFromCenter([0, clientHeight / 2]), degreesFromCenter([clientWidth / 2, clientHeight]));
|
|
13341
|
+
return [
|
|
13342
|
+
center.lng - halfSize / lngScale,
|
|
13343
|
+
center.lat - halfSize,
|
|
13344
|
+
center.lng + halfSize / lngScale,
|
|
13345
|
+
center.lat + halfSize,
|
|
13346
|
+
];
|
|
13347
|
+
}
|
|
12205
13348
|
/**
|
|
12206
13349
|
* Update polygon visualization based on current map bounds
|
|
12207
13350
|
*/
|
|
@@ -12211,9 +13354,7 @@ class PolygonControl {
|
|
|
12211
13354
|
return;
|
|
12212
13355
|
const [minLng, minLat] = bboxArray[0];
|
|
12213
13356
|
const [maxLng, maxLat] = bboxArray[1];
|
|
12214
|
-
const
|
|
12215
|
-
const latDiff = (maxLat - minLat) * 0.2;
|
|
12216
|
-
const clippedBbox = [minLng + lngDiff, minLat + latDiff, maxLng - lngDiff, maxLat - latDiff];
|
|
13357
|
+
const clippedBbox = this.getSelectionBbox(minLng, minLat, maxLng, maxLat);
|
|
12217
13358
|
// Store current bounds for later use
|
|
12218
13359
|
this.currentBounds = clippedBbox;
|
|
12219
13360
|
const clippedPolygon = bboxPolygon([
|
|
@@ -12280,7 +13421,7 @@ class PolygonControl {
|
|
|
12280
13421
|
source: 'selectionPolygon',
|
|
12281
13422
|
layout: {},
|
|
12282
13423
|
paint: {
|
|
12283
|
-
'line-color': '#
|
|
13424
|
+
'line-color': '#A8116F', // Revision magenta, the UI's accent
|
|
12284
13425
|
'line-width': 3,
|
|
12285
13426
|
'line-dasharray': [2, 2],
|
|
12286
13427
|
},
|
|
@@ -12314,6 +13455,30 @@ class PolygonControl {
|
|
|
12314
13455
|
*/
|
|
12315
13456
|
// import { MaplibreMap } from 'maplibre-gl';
|
|
12316
13457
|
const formLogger = logger.scope('RegionFormModal');
|
|
13458
|
+
/** Mapbox Standard's buildings start at this zoom. */
|
|
13459
|
+
const STANDARD_BUILDINGS_MIN_ZOOM = 15;
|
|
13460
|
+
let fieldCount = 0;
|
|
13461
|
+
/**
|
|
13462
|
+
* Point each label at the one field beside it, so screen readers name the field
|
|
13463
|
+
* and clicking the label focuses it. Groups with several fields are left alone.
|
|
13464
|
+
*/
|
|
13465
|
+
function linkLabelsToFields(form) {
|
|
13466
|
+
for (const label of Array.from(form.querySelectorAll('label'))) {
|
|
13467
|
+
const fields = label.parentElement?.querySelectorAll('input, select, textarea');
|
|
13468
|
+
if (label.htmlFor || fields?.length !== 1)
|
|
13469
|
+
continue;
|
|
13470
|
+
const field = fields[0];
|
|
13471
|
+
field.id ||= `map-gl-offline-field-${++fieldCount}`;
|
|
13472
|
+
label.htmlFor = field.id;
|
|
13473
|
+
}
|
|
13474
|
+
}
|
|
13475
|
+
/**
|
|
13476
|
+
* True when a region of Mapbox Standard stops below the zoom where its buildings
|
|
13477
|
+
* start. Standard Satellite has no buildings, so it doesn't count.
|
|
13478
|
+
*/
|
|
13479
|
+
function regionMissesBuildings(styleUrl, maxZoom) {
|
|
13480
|
+
return /mapbox\/standard(?![\w-])/.test(styleUrl) && maxZoom < STANDARD_BUILDINGS_MIN_ZOOM;
|
|
13481
|
+
}
|
|
12317
13482
|
class RegionFormModal {
|
|
12318
13483
|
options;
|
|
12319
13484
|
modal;
|
|
@@ -12321,6 +13486,7 @@ class RegionFormModal {
|
|
|
12321
13486
|
minZoomInput;
|
|
12322
13487
|
maxZoomInput;
|
|
12323
13488
|
styleUrlInput;
|
|
13489
|
+
buildingsHint;
|
|
12324
13490
|
// Enhanced Mapbox GL support
|
|
12325
13491
|
providerSelect;
|
|
12326
13492
|
accessTokenInput;
|
|
@@ -12371,13 +13537,13 @@ class RegionFormModal {
|
|
|
12371
13537
|
'grid grid-cols-2 gap-4 p-4 rounded-xl glass-input border border-gray-100/50 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/50';
|
|
12372
13538
|
infoGroup.innerHTML = `
|
|
12373
13539
|
<div class="flex flex-col justify-center">
|
|
12374
|
-
<strong class="text-
|
|
12375
|
-
<div class="text-xl font-
|
|
13540
|
+
<strong class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.area')}</strong>
|
|
13541
|
+
<div class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
12376
13542
|
${area.toLocaleString()} <span class="text-sm font-medium text-gray-500">km²</span>
|
|
12377
13543
|
</div>
|
|
12378
13544
|
</div>
|
|
12379
13545
|
<div>
|
|
12380
|
-
<strong class="text-
|
|
13546
|
+
<strong class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.bounds')}</strong>
|
|
12381
13547
|
<div class="text-xs font-mono text-gray-600 dark:text-gray-300 leading-relaxed bg-white/50 dark:bg-black/20 p-2 rounded-lg">
|
|
12382
13548
|
${west.toFixed(4)}, ${south.toFixed(4)}<br>
|
|
12383
13549
|
${east.toFixed(4)}, ${north.toFixed(4)}
|
|
@@ -12433,6 +13599,12 @@ class RegionFormModal {
|
|
|
12433
13599
|
zoomGroup.appendChild(minZoomDiv);
|
|
12434
13600
|
zoomGroup.appendChild(maxZoomDiv);
|
|
12435
13601
|
form.appendChild(zoomGroup);
|
|
13602
|
+
this.buildingsHint = document.createElement('p');
|
|
13603
|
+
this.buildingsHint.className = 'm-0 text-sm text-amber-700 dark:text-amber-400 hidden';
|
|
13604
|
+
this.buildingsHint.setAttribute('role', 'status');
|
|
13605
|
+
this.buildingsHint.textContent = t('regionForm.buildingsHint');
|
|
13606
|
+
form.appendChild(this.buildingsHint);
|
|
13607
|
+
this.maxZoomInput.addEventListener('input', () => this.updateBuildingsHint());
|
|
12436
13608
|
// Style URL input
|
|
12437
13609
|
const styleGroup = document.createElement('div');
|
|
12438
13610
|
styleGroup.innerHTML = `
|
|
@@ -12445,9 +13617,13 @@ class RegionFormModal {
|
|
|
12445
13617
|
this.styleUrlInput.value = styleUrl;
|
|
12446
13618
|
this.styleUrlInput.className =
|
|
12447
13619
|
'w-full px-4 py-3 rounded-xl text-base glass-input text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500/50 transition-all shadow-sm';
|
|
12448
|
-
this.styleUrlInput.addEventListener('input', () =>
|
|
13620
|
+
this.styleUrlInput.addEventListener('input', () => {
|
|
13621
|
+
this.handleStyleUrlChange();
|
|
13622
|
+
this.updateBuildingsHint();
|
|
13623
|
+
});
|
|
12449
13624
|
styleGroup.appendChild(this.styleUrlInput);
|
|
12450
13625
|
form.appendChild(styleGroup);
|
|
13626
|
+
this.updateBuildingsHint();
|
|
12451
13627
|
// Provider selection
|
|
12452
13628
|
const providerGroup = document.createElement('div');
|
|
12453
13629
|
providerGroup.innerHTML = `
|
|
@@ -12505,6 +13681,7 @@ class RegionFormModal {
|
|
|
12505
13681
|
}
|
|
12506
13682
|
// Initialize provider detection
|
|
12507
13683
|
this.detectProviderFromUrl();
|
|
13684
|
+
linkLabelsToFields(form);
|
|
12508
13685
|
return form;
|
|
12509
13686
|
}
|
|
12510
13687
|
/**
|
|
@@ -12619,6 +13796,11 @@ class RegionFormModal {
|
|
|
12619
13796
|
/**
|
|
12620
13797
|
* Handle style URL changes to auto-detect provider
|
|
12621
13798
|
*/
|
|
13799
|
+
/** Warn when a region would be saved without the style's buildings. */
|
|
13800
|
+
updateBuildingsHint() {
|
|
13801
|
+
const missesBuildings = regionMissesBuildings(this.styleUrlInput?.value ?? '', Number(this.maxZoomInput?.value));
|
|
13802
|
+
this.buildingsHint?.classList.toggle('hidden', !missesBuildings);
|
|
13803
|
+
}
|
|
12622
13804
|
handleStyleUrlChange() {
|
|
12623
13805
|
this.detectProviderFromUrl();
|
|
12624
13806
|
}
|
|
@@ -12695,10 +13877,21 @@ class RegionFormModal {
|
|
|
12695
13877
|
footer.appendChild(saveButton.getElement());
|
|
12696
13878
|
return footer;
|
|
12697
13879
|
}
|
|
13880
|
+
/** Check the zoom fields, showing the browser's message on the first bad one. */
|
|
13881
|
+
zoomRangeIsValid() {
|
|
13882
|
+
const { minZoomInput: min, maxZoomInput: max } = this;
|
|
13883
|
+
if (!min || !max)
|
|
13884
|
+
return true;
|
|
13885
|
+
const reversed = min.value !== '' && max.value !== '' && Number(min.value) > Number(max.value);
|
|
13886
|
+
max.setCustomValidity(reversed ? t('regionForm.zoomRangeError') : '');
|
|
13887
|
+
return min.reportValidity() && max.reportValidity();
|
|
13888
|
+
}
|
|
12698
13889
|
/**
|
|
12699
13890
|
* Handle form save
|
|
12700
13891
|
*/
|
|
12701
13892
|
async handleSave() {
|
|
13893
|
+
if (!this.zoomRangeIsValid())
|
|
13894
|
+
return;
|
|
12702
13895
|
try {
|
|
12703
13896
|
const selectedSources = this.getSelectedExtraSources();
|
|
12704
13897
|
const formData = {
|
|
@@ -12815,14 +14008,14 @@ class RegionControl {
|
|
|
12815
14008
|
this.cancelButton = document.createElement('button');
|
|
12816
14009
|
this.cancelButton.type = 'button';
|
|
12817
14010
|
const prefix = this.options.cssPrefix || 'maplibregl';
|
|
12818
|
-
this.cancelButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-
|
|
14011
|
+
this.cancelButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 !bg-red-600 border border-red-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:!bg-red-700 transition-all duration-200`;
|
|
12819
14012
|
this.cancelButton.innerHTML = icons.x({ size: 16, color: 'white' });
|
|
12820
14013
|
this.cancelButton.title = 'Cancel Selection';
|
|
12821
14014
|
this.cancelButton.addEventListener('click', () => this.cancelSelection());
|
|
12822
14015
|
// Create save button (green checkmark)
|
|
12823
14016
|
this.saveButton = document.createElement('button');
|
|
12824
14017
|
this.saveButton.type = 'button';
|
|
12825
|
-
this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-
|
|
14018
|
+
this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 !bg-green-600 border border-green-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:!bg-green-700 transition-all duration-200`;
|
|
12826
14019
|
this.saveButton.innerHTML = icons.check({ size: 16, color: 'white' });
|
|
12827
14020
|
this.saveButton.title = 'Save Selected Region';
|
|
12828
14021
|
this.saveButton.addEventListener('click', () => this.handleSaveClick());
|
|
@@ -13052,6 +14245,8 @@ class DownloadManager {
|
|
|
13052
14245
|
offlineManager;
|
|
13053
14246
|
/** Map of region IDs to their download progress */
|
|
13054
14247
|
currentDownloads = new Map();
|
|
14248
|
+
/** Aborts each running download, by region id. */
|
|
14249
|
+
controllers = new Map();
|
|
13055
14250
|
options;
|
|
13056
14251
|
/**
|
|
13057
14252
|
* Create a new DownloadManager instance.
|
|
@@ -13066,14 +14261,16 @@ class DownloadManager {
|
|
|
13066
14261
|
* Calculates percentage and notifies listeners via the onProgressUpdate callback.
|
|
13067
14262
|
* @internal
|
|
13068
14263
|
*/
|
|
13069
|
-
updateProgress(regionId, regionName, phase, completed, total, currentResource) {
|
|
13070
|
-
const percentage =
|
|
14264
|
+
updateProgress(regionId, regionName, phase, completed, total, currentResource, overallPercentage) {
|
|
14265
|
+
const percentage = Math.round(overallPercentage);
|
|
14266
|
+
const phasePercentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
13071
14267
|
this.currentDownloads.set(regionId, {
|
|
13072
14268
|
regionId,
|
|
13073
14269
|
regionName,
|
|
13074
14270
|
completed,
|
|
13075
14271
|
total,
|
|
13076
14272
|
percentage,
|
|
14273
|
+
phasePercentage,
|
|
13077
14274
|
currentResource,
|
|
13078
14275
|
phase,
|
|
13079
14276
|
});
|
|
@@ -13108,14 +14305,20 @@ class DownloadManager {
|
|
|
13108
14305
|
extraSources: formData.extraSources,
|
|
13109
14306
|
};
|
|
13110
14307
|
const regionId = regionConfig.id;
|
|
14308
|
+
const controller = new AbortController();
|
|
14309
|
+
this.controllers.set(regionId, controller);
|
|
13111
14310
|
this.updateUIForDownloadStart();
|
|
13112
14311
|
try {
|
|
13113
14312
|
await this.offlineManager.downloadRegion(regionConfig, {
|
|
13114
14313
|
provider: formData.provider || 'auto',
|
|
13115
14314
|
accessToken: formData.accessToken,
|
|
14315
|
+
signal: controller.signal,
|
|
13116
14316
|
onProgress: progress => {
|
|
14317
|
+
// A cancelled download keeps reporting until it stops; don't show it again.
|
|
14318
|
+
if (controller.signal.aborted)
|
|
14319
|
+
return;
|
|
13117
14320
|
const uiPhase = progress.phase === 'metadata' ? undefined : progress.phase;
|
|
13118
|
-
this.updateProgress(regionId, regionConfig.name, uiPhase, progress.completed, progress.total, progress.message ?? `Downloading ${progress.phase}
|
|
14321
|
+
this.updateProgress(regionId, regionConfig.name, uiPhase, progress.completed, progress.total, progress.message ?? `Downloading ${progress.phase}`, progress.overallPercentage);
|
|
13119
14322
|
},
|
|
13120
14323
|
tileOptions: {
|
|
13121
14324
|
maxConcurrency: 10,
|
|
@@ -13124,8 +14327,16 @@ class DownloadManager {
|
|
|
13124
14327
|
this.handleDownloadComplete(regionId);
|
|
13125
14328
|
}
|
|
13126
14329
|
catch (error) {
|
|
13127
|
-
|
|
13128
|
-
|
|
14330
|
+
if (controller.signal.aborted) {
|
|
14331
|
+
downloadLogger.debug(`Download of ${regionId} was cancelled`);
|
|
14332
|
+
}
|
|
14333
|
+
else {
|
|
14334
|
+
downloadLogger.error('Error downloading region:', error);
|
|
14335
|
+
this.handleDownloadError(regionId, error);
|
|
14336
|
+
}
|
|
14337
|
+
}
|
|
14338
|
+
finally {
|
|
14339
|
+
this.controllers.delete(regionId);
|
|
13129
14340
|
}
|
|
13130
14341
|
}
|
|
13131
14342
|
/**
|
|
@@ -13148,6 +14359,8 @@ class DownloadManager {
|
|
|
13148
14359
|
* @param regionId - The ID of the region download to cancel
|
|
13149
14360
|
*/
|
|
13150
14361
|
cancelDownload(regionId) {
|
|
14362
|
+
// Stops the download; downloadRegion then saves nothing and removes its partial tiles.
|
|
14363
|
+
this.controllers.get(regionId)?.abort();
|
|
13151
14364
|
this.currentDownloads.delete(regionId);
|
|
13152
14365
|
if (this.currentDownloads.size === 0) {
|
|
13153
14366
|
this.resetUI();
|
|
@@ -13159,6 +14372,8 @@ class DownloadManager {
|
|
|
13159
14372
|
* Clears all download tracking and resets the UI.
|
|
13160
14373
|
*/
|
|
13161
14374
|
cancelAllDownloads() {
|
|
14375
|
+
for (const controller of this.controllers.values())
|
|
14376
|
+
controller.abort();
|
|
13162
14377
|
this.currentDownloads.clear();
|
|
13163
14378
|
this.resetUI();
|
|
13164
14379
|
this.options.onProgressUpdate?.(this.currentDownloads);
|
|
@@ -13308,6 +14523,9 @@ class OfflineManagerControl {
|
|
|
13308
14523
|
regionControl;
|
|
13309
14524
|
downloadManager;
|
|
13310
14525
|
modalManager = new ModalManager();
|
|
14526
|
+
/** Percentages of this batch's downloads, finished ones at 100, for the badge. */
|
|
14527
|
+
badgeBatch = new Map();
|
|
14528
|
+
badgePercentage = 0;
|
|
13311
14529
|
/** Whether the bounding box visualization layer has been added to the map */
|
|
13312
14530
|
bboxLayerAdded = false;
|
|
13313
14531
|
/** Map library module for protocol registration (null if not provided) */
|
|
@@ -13362,8 +14580,11 @@ class OfflineManagerControl {
|
|
|
13362
14580
|
this.mapLib.addProtocol('idb', async (params) => {
|
|
13363
14581
|
const response = await idbFetchHandler(params.url);
|
|
13364
14582
|
if (!response.ok) {
|
|
13365
|
-
|
|
13366
|
-
|
|
14583
|
+
// MapLibre treats a 404 as "no tile here", which is normal once the map
|
|
14584
|
+
// moves outside a saved region. Any other error gets logged for every tile.
|
|
14585
|
+
throw Object.assign(new Error(`IDB fetch failed (${response.status}): ${params.url}`), {
|
|
14586
|
+
status: response.status,
|
|
14587
|
+
});
|
|
13367
14588
|
}
|
|
13368
14589
|
// MapLibre's makeRequest bypasses JSON parsing for protocol handlers,
|
|
13369
14590
|
// so we must parse JSON responses ourselves (e.g. sprite atlas, TileJSON).
|
|
@@ -13619,43 +14840,27 @@ class OfflineManagerControl {
|
|
|
13619
14840
|
* Handle progress updates from download manager
|
|
13620
14841
|
*/
|
|
13621
14842
|
handleProgressUpdate(downloads) {
|
|
13622
|
-
//
|
|
13623
|
-
|
|
13624
|
-
|
|
13625
|
-
|
|
13626
|
-
|
|
13627
|
-
|
|
13628
|
-
|
|
13629
|
-
|
|
13630
|
-
|
|
13631
|
-
|
|
13632
|
-
|
|
13633
|
-
|
|
13634
|
-
|
|
13635
|
-
|
|
13636
|
-
|
|
13637
|
-
|
|
13638
|
-
|
|
13639
|
-
|
|
13640
|
-
|
|
13641
|
-
case 'tiles':
|
|
13642
|
-
displayText = `${percentage}%`;
|
|
13643
|
-
break;
|
|
13644
|
-
default:
|
|
13645
|
-
displayText = `${percentage}%`;
|
|
13646
|
-
}
|
|
13647
|
-
}
|
|
13648
|
-
else {
|
|
13649
|
-
// Multiple downloads - show average
|
|
13650
|
-
let totalPercentage = 0;
|
|
13651
|
-
for (const download of downloads.values()) {
|
|
13652
|
-
totalPercentage += download.percentage;
|
|
13653
|
-
}
|
|
13654
|
-
const avgPercentage = Math.round(totalPercentage / downloads.size);
|
|
13655
|
-
displayText = `${avgPercentage}%`;
|
|
13656
|
-
}
|
|
13657
|
-
this.updateProgressBadge(displayText, true);
|
|
14843
|
+
// The panel has the detail for each phase; the badge only has room for one
|
|
14844
|
+
// figure. With several downloads it averages all of this batch's, counting
|
|
14845
|
+
// finished ones as 100, so one finishing doesn't drop the figure, and never
|
|
14846
|
+
// goes back: a download added mid-batch holds it until the average catches up.
|
|
14847
|
+
for (const [regionId, download] of downloads) {
|
|
14848
|
+
this.badgeBatch.set(regionId, download.percentage);
|
|
14849
|
+
}
|
|
14850
|
+
for (const regionId of this.badgeBatch.keys()) {
|
|
14851
|
+
if (!downloads.has(regionId))
|
|
14852
|
+
this.badgeBatch.set(regionId, 100);
|
|
14853
|
+
}
|
|
14854
|
+
if (downloads.size === 0) {
|
|
14855
|
+
this.badgeBatch.clear();
|
|
14856
|
+
this.badgePercentage = 0;
|
|
14857
|
+
}
|
|
14858
|
+
else {
|
|
14859
|
+
const total = [...this.badgeBatch.values()].reduce((sum, value) => sum + value, 0);
|
|
14860
|
+
this.badgePercentage = Math.max(this.badgePercentage, Math.round(total / this.badgeBatch.size));
|
|
14861
|
+
this.updateProgressBadge(`${this.badgePercentage}%`, true);
|
|
13658
14862
|
}
|
|
14863
|
+
this.panelRenderer?.updateDownloadProgress(downloads);
|
|
13659
14864
|
}
|
|
13660
14865
|
/**
|
|
13661
14866
|
* Handle download completion
|
|
@@ -13671,6 +14876,7 @@ class OfflineManagerControl {
|
|
|
13671
14876
|
*/
|
|
13672
14877
|
handleDownloadError(regionId, error) {
|
|
13673
14878
|
controlLogger.error(`Download error for region ${regionId}:`, error);
|
|
14879
|
+
alert(`${t('alert.downloadFailed')}: ${error instanceof Error ? error.message : t('alert.unknownError')}`);
|
|
13674
14880
|
// Use the panel's refresh method instead of renderPanel to avoid conflicts
|
|
13675
14881
|
if (this.panelRenderer) {
|
|
13676
14882
|
this.panelRenderer.refresh();
|
|
@@ -13814,7 +15020,7 @@ class OfflineManagerControl {
|
|
|
13814
15020
|
type: 'line',
|
|
13815
15021
|
source: sourceId,
|
|
13816
15022
|
paint: {
|
|
13817
|
-
'line-color': '#
|
|
15023
|
+
'line-color': '#A8116F', // Revision magenta, the UI's accent
|
|
13818
15024
|
'line-width': 3,
|
|
13819
15025
|
'line-opacity': 0.8,
|
|
13820
15026
|
},
|
|
@@ -13867,6 +15073,10 @@ class OfflineManagerControl {
|
|
|
13867
15073
|
controlLogger.error(`Style ${styleId} not found in IndexedDB`);
|
|
13868
15074
|
return;
|
|
13869
15075
|
}
|
|
15076
|
+
if (needsMapboxGl(styleEntry.style) && !isMapboxGlMap(this.map)) {
|
|
15077
|
+
controlLogger.error(`Style ${styleId} needs Mapbox GL JS, so this map can't show it`);
|
|
15078
|
+
return;
|
|
15079
|
+
}
|
|
13870
15080
|
// The style is already patched with idb:// URLs by addRegion() in regionService.
|
|
13871
15081
|
// DO NOT call patchStyleForOffline() again — double-patching rewrites the downloadId
|
|
13872
15082
|
// in URLs and can break tile/glyph lookups.
|
|
@@ -13925,9 +15135,9 @@ class OfflineManagerControl {
|
|
|
13925
15135
|
${styles
|
|
13926
15136
|
.map(style => `
|
|
13927
15137
|
<button class="w-full p-3 text-left border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" data-style-id="${escapeHtml$1(style.key)}">
|
|
13928
|
-
<div class="font-medium text-gray-900 dark:text-white">${escapeHtml$1(style
|
|
15138
|
+
<div class="font-medium text-gray-900 dark:text-white">${escapeHtml$1(styleDisplayName(style))}</div>
|
|
13929
15139
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
13930
|
-
${style.regions?.length || 0}
|
|
15140
|
+
${regionCountLabel(style.regions?.length || 0)} •
|
|
13931
15141
|
${Object.keys(style.style.sources || {}).length} sources
|
|
13932
15142
|
</div>
|
|
13933
15143
|
</button>
|
|
@@ -14025,5 +15235,51 @@ class OfflineManagerControl {
|
|
|
14025
15235
|
}
|
|
14026
15236
|
}
|
|
14027
15237
|
|
|
14028
|
-
|
|
15238
|
+
const assetLogger = logger.scope('RuntimeAssets');
|
|
15239
|
+
const ASSET_TIMEOUT_MS = 30000;
|
|
15240
|
+
/**
|
|
15241
|
+
* Cache the code Mapbox GL fetches from api.mapbox.com while rendering: the
|
|
15242
|
+
* Draco and meshopt decoders 3D models need, plus the RTL text and procedural
|
|
15243
|
+
* building modules. The Service Worker serves them back from this cache. They
|
|
15244
|
+
* live in the Cache API rather than IndexedDB because they belong to the
|
|
15245
|
+
* renderer, not to any style or region.
|
|
15246
|
+
*
|
|
15247
|
+
* Failures only log: the rest of a region is still usable without these.
|
|
15248
|
+
*
|
|
15249
|
+
* @returns how many assets are now cached
|
|
15250
|
+
*/
|
|
15251
|
+
async function cacheMapboxRuntimeAssets() {
|
|
15252
|
+
if (typeof caches === 'undefined')
|
|
15253
|
+
return 0;
|
|
15254
|
+
let cache;
|
|
15255
|
+
try {
|
|
15256
|
+
cache = await caches.open(MAPBOX_RUNTIME_CACHE);
|
|
15257
|
+
}
|
|
15258
|
+
catch (error) {
|
|
15259
|
+
assetLogger.warn('Cache API unavailable, skipping runtime assets:', error);
|
|
15260
|
+
return 0;
|
|
15261
|
+
}
|
|
15262
|
+
const results = await Promise.all(MAPBOX_RUNTIME_ASSETS.map(async (path) => {
|
|
15263
|
+
const url = `${MAPBOX_RUNTIME_ORIGIN}${path}`;
|
|
15264
|
+
try {
|
|
15265
|
+
if (await cache.match(url))
|
|
15266
|
+
return true;
|
|
15267
|
+
const response = await fetchWithRetry(url, { retries: 0, timeout: ASSET_TIMEOUT_MS });
|
|
15268
|
+
await cache.put(url, response);
|
|
15269
|
+
return true;
|
|
15270
|
+
}
|
|
15271
|
+
catch (error) {
|
|
15272
|
+
assetLogger.warn(`Failed to cache ${url}:`, error);
|
|
15273
|
+
return false;
|
|
15274
|
+
}
|
|
15275
|
+
}));
|
|
15276
|
+
return results.filter(Boolean).length;
|
|
15277
|
+
}
|
|
15278
|
+
|
|
15279
|
+
var runtimeAssetService = /*#__PURE__*/Object.freeze({
|
|
15280
|
+
__proto__: null,
|
|
15281
|
+
cacheMapboxRuntimeAssets: cacheMapboxRuntimeAssets
|
|
15282
|
+
});
|
|
15283
|
+
|
|
15284
|
+
export { AnalyticsService, CONTENT_TYPES, CategorizedError, CleanupService, DB_NAME, DB_VERSION, DOWNLOAD_DEFAULTS, ERROR_MESSAGES, ErrorType, FontService, GLYPH_BLOCK_SIZE, GLYPH_CONFIG, GZIP_MAGIC_BYTES, GlyphService, ImportExportService, LogLevel, MAPBOX_API, MAPBOX_CACHE_TTL, MAPBOX_CLASSIC_STYLES, MAPBOX_STANDARD_SPARSE_TILESETS, MAP_PROVIDERS, MAX_GLYPH_CODEPOINT, MaintenanceService, ModelService, OfflineManagerControl, OfflineMapDBVersionError, OfflineMapManager, RESOURCE_TYPES, RegionService, ResourceService, STORAGE_CONFIG, STORE_NAMES, STYLE_CONFIG, SUCCESS_MESSAGES, ScopedLogger, SpriteService, TILE_CONFIG, TileService, URL_SCHEMES, VALIDATION_PATTERNS, applyProxy, categorizeError, cleanupCompressedTiles, cleanupExpiredTiles, cleanupOldFonts, cleanupOldGlyphs, cleanupOldModels, cleanupOldSprites, cleanupOldStyles, cleanupOldTiles, cleanupService, clearAllCaches, configureLogger, configureProxy, configureSqlJs, convertStyleForServiceWorker, countCompressedTiles, createProgressTracker, createTileKey, dbPromise, OfflineMapManager as default, deleteStyleById, deleteStyles, deriveTileExtension, detectCssPrefix, detectStyleProvider, downloadFonts, downloadGlyphs, downloadModels, downloadSprites, downloadStyleWithProvider, downloadStyles, downloadTiles, escapeHtml$1 as escapeHtml, extractAccessToken, extractAllFontNames, extractFontNamesFromTextField, extractTileExtensionFromUrl, fetchResourceWithRetry, fetchWithRetry, fontService, formatBytes, formatDate, generateGlyphUrlsFromStyle, getExpiredResourceCount, getFontAnalytics, getFontStats, getGlyphAnalytics, getGlyphStats, getIcon, getModel, getModelStats, getRegionAnalytics, getSpriteAnalytics, getSpriteStats, getSqlJs, getStyleStats, getStyleStorageSizes, getTileAnalytics, getTileStats, getUrlHostname, getUserErrorMessage, glyphService, hasImports, hostMatches, i18n, icons, idbFetchHandler, injectExtraSources, isMapboxGlMap, isMapboxHost, isMapboxProtocol, isStyleDownloaded, loadAllStoredRegions, loadGlyphs, loadStyleById, loadStyles, logger, modelKeyBelongsToStyle, modelService, needsMapboxGl, normalizeSpriteProperty, normalizeStyleUrl, optimizeStorage, parseCacheExpiry, parseTileKey, patchStyleForOffline, performCleanup, processBatch, processStyleSources, registerOfflineServiceWorker, resetOfflineMapDB, resolveImports, resolveMapboxUrl, resourceKeyBelongsToStyle, restoreUnstoredModels, rewriteMapboxCdnTileUrl, safeExecute, sanitizeIndoorExpressions, selectTileJsonVariant, setupAutoCleanup, spriteService, stashOriginalModels, stopAutoCleanup, storedModelNames, styleDisplayName, t, tileService, unregisterOfflineServiceWorker, urlReferencesKnownSparseTileset, validateBounds, validateRegionOptions, validateResource, validateStyleForProvider, validateZoomLevels, verifyAndRepairFonts, verifyAndRepairGlyphs, verifyAndRepairModels, verifyAndRepairSprites };
|
|
14029
15285
|
//# sourceMappingURL=index.esm.js.map
|