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.js
CHANGED
|
@@ -186,18 +186,25 @@ const MAPBOX_API = {
|
|
|
186
186
|
/**
|
|
187
187
|
* Mapbox Standard sub-tilesets that are sparse-by-design across the planet.
|
|
188
188
|
* These only have tiles where the underlying feature (indoor venues, landmark
|
|
189
|
-
* POIs, 3D procedural buildings) actually exists, and return 404 for
|
|
190
|
-
*
|
|
189
|
+
* POIs, 3D procedural buildings) actually exists, and return 404 for every other
|
|
190
|
+
* coordinate, though they can be dense inside a major city. Unless the style's
|
|
191
|
+
* config has switched on a feature that needs one, the tile downloader pre-skips
|
|
192
|
+
* any source whose tile
|
|
191
193
|
* URL templates reference one of these tileset IDs, so we never issue probe
|
|
192
194
|
* or download requests for them — eliminating the 404 noise in devtools.
|
|
193
195
|
*
|
|
194
196
|
* Matching is done against the tileset segment of the tile URL template
|
|
195
197
|
* (e.g. `https://api.mapbox.com/v4/mapbox.indoor-v3/{z}/{x}/{y}.vector.pbf`
|
|
196
198
|
* or `mapbox://mapbox.indoor-v3`).
|
|
199
|
+
*
|
|
200
|
+
* The ids below are the ones the live Mapbox Standard style declares — note
|
|
201
|
+
* that the landmark-POI tileset is `mapbox.mapbox-landmark-pois-v1` (doubled
|
|
202
|
+
* `mapbox` segment), unlike the other two. The matcher is delimiter-anchored,
|
|
203
|
+
* so an approximate id silently never matches.
|
|
197
204
|
*/
|
|
198
205
|
const MAPBOX_STANDARD_SPARSE_TILESETS = [
|
|
199
206
|
'mapbox.indoor-v3',
|
|
200
|
-
'mapbox.landmark-pois-v1',
|
|
207
|
+
'mapbox.mapbox-landmark-pois-v1',
|
|
201
208
|
'mapbox.procedural-buildings-v1',
|
|
202
209
|
];
|
|
203
210
|
// Map Providers
|
|
@@ -384,7 +391,10 @@ async function openOfflineMapDB() {
|
|
|
384
391
|
// Migration: v2 -> v3
|
|
385
392
|
// Move regions from 'regions' store to styles.regions[]
|
|
386
393
|
if (oldVersion > 0 && oldVersion < 3) {
|
|
387
|
-
|
|
394
|
+
// Unwrap to the raw IDBTransaction: on idb's wrapper, getAll()/get()
|
|
395
|
+
// return Promises, so the onsuccess callbacks the migration relies
|
|
396
|
+
// on would never fire and no region would be migrated.
|
|
397
|
+
migrateRegionsToStyles(idb.unwrap(transaction));
|
|
388
398
|
}
|
|
389
399
|
// Migration: v3 -> v4
|
|
390
400
|
// Adds the `models` store for Mapbox Standard 3D model assets.
|
|
@@ -1045,6 +1055,20 @@ function formatBytes(bytes) {
|
|
|
1045
1055
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
1046
1056
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
1047
1057
|
}
|
|
1058
|
+
/**
|
|
1059
|
+
* A stored style's display name: its own `name`, or else the last meaningful
|
|
1060
|
+
* part of its URL ("liberty" for `.../styles/liberty`), or else its key.
|
|
1061
|
+
*/
|
|
1062
|
+
function styleDisplayName(entry) {
|
|
1063
|
+
if (entry.style?.name)
|
|
1064
|
+
return entry.style.name;
|
|
1065
|
+
const path = (entry.originalUrl ?? '').split(/[?#]/)[0];
|
|
1066
|
+
const segments = path
|
|
1067
|
+
.split('/')
|
|
1068
|
+
.map(segment => segment.replace(/\.json$/i, ''))
|
|
1069
|
+
.filter(segment => segment && !/^(style|styles)$/i.test(segment) && !segment.includes(':'));
|
|
1070
|
+
return segments.at(-1) ?? entry.key;
|
|
1071
|
+
}
|
|
1048
1072
|
/**
|
|
1049
1073
|
* Format date to human readable string
|
|
1050
1074
|
*/
|
|
@@ -1272,6 +1296,39 @@ function deriveTileExtension(tiles) {
|
|
|
1272
1296
|
* They have different shapes so cannot be shared; the key computation
|
|
1273
1297
|
* can be and is.
|
|
1274
1298
|
*/
|
|
1299
|
+
/**
|
|
1300
|
+
* `decodeURIComponent` that returns the input unchanged instead of throwing
|
|
1301
|
+
* on a malformed escape (e.g. a literal `%` in a font or source name), so a
|
|
1302
|
+
* bad path becomes a lookup miss / 404 rather than a rejected fetch.
|
|
1303
|
+
*/
|
|
1304
|
+
function safeDecodeURIComponent(value) {
|
|
1305
|
+
try {
|
|
1306
|
+
return decodeURIComponent(value);
|
|
1307
|
+
}
|
|
1308
|
+
catch {
|
|
1309
|
+
return value;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
// ---------------------------------------------------------------------------
|
|
1313
|
+
// Mapbox GL runtime assets
|
|
1314
|
+
// ---------------------------------------------------------------------------
|
|
1315
|
+
const MAPBOX_RUNTIME_ORIGIN = 'https://api.mapbox.com';
|
|
1316
|
+
const MAPBOX_RUNTIME_CACHE = 'map-gl-offline-runtime-v1';
|
|
1317
|
+
/**
|
|
1318
|
+
* Code Mapbox GL fetches from api.mapbox.com while rendering, so it has to be
|
|
1319
|
+
* available offline too. Paths are versioned, so a renderer that moves to a new
|
|
1320
|
+
* version misses here; `npm run check:mapbox-standard` compares this list with
|
|
1321
|
+
* the installed renderer. They match Mapbox GL 3.34; an app on a version with
|
|
1322
|
+
* other paths gets no offline copy of those files.
|
|
1323
|
+
*/
|
|
1324
|
+
const MAPBOX_RUNTIME_ASSETS = [
|
|
1325
|
+
'/mapbox-gl-js/draco_decoder_gltf_v1.5.6.wasm',
|
|
1326
|
+
'/mapbox-gl-js/meshopt_base_v1.2.wasm',
|
|
1327
|
+
'/mapbox-gl-js/meshopt_simd_v1.2.wasm',
|
|
1328
|
+
'/mapbox-gl-js/rtl_text_v1.0.0.wasm',
|
|
1329
|
+
'/mapbox-gl-js/building-gen/building_gen_v1.2.6.wasm',
|
|
1330
|
+
'/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.3.0/mapbox-gl-rtl-text.js',
|
|
1331
|
+
];
|
|
1275
1332
|
/**
|
|
1276
1333
|
* Extensions to try in order when the requested extension misses. `glb` is
|
|
1277
1334
|
* last so batched-model sources (Mapbox Standard 3D buildings) resolve when
|
|
@@ -1309,9 +1366,10 @@ function findStyleByRegionIdIn(styles, regionId) {
|
|
|
1309
1366
|
// Glyph candidate keys
|
|
1310
1367
|
// ---------------------------------------------------------------------------
|
|
1311
1368
|
/**
|
|
1312
|
-
* Parse `FontA,FontB,FontC/0-255.pbf` into (fontstacks, rangePart). Mapbox
|
|
1313
|
-
* requests a comma-joined font-family fallback chain
|
|
1314
|
-
*
|
|
1369
|
+
* Parse `FontA,FontB,FontC/0-255.pbf` into (fontstacks, rangePart). Older Mapbox
|
|
1370
|
+
* GL requests a comma-joined font-family fallback chain (current versions fetch
|
|
1371
|
+
* each font and merge them client-side); each glyph is stored individually, so
|
|
1372
|
+
* the caller tries each fontstack in order.
|
|
1315
1373
|
*/
|
|
1316
1374
|
function parseGlyphPath(decodedPath) {
|
|
1317
1375
|
const pathParts = decodedPath.split('/');
|
|
@@ -1323,6 +1381,47 @@ function parseGlyphPath(decodedPath) {
|
|
|
1323
1381
|
.filter(Boolean);
|
|
1324
1382
|
return { fontstacks, rangePart };
|
|
1325
1383
|
}
|
|
1384
|
+
function readVarint(bytes, start) {
|
|
1385
|
+
let value = 0;
|
|
1386
|
+
let multiplier = 1;
|
|
1387
|
+
for (let pos = start; pos < bytes.length && pos < start + 5; pos++) {
|
|
1388
|
+
value += (bytes[pos] & 0x7f) * multiplier;
|
|
1389
|
+
if (bytes[pos] < 0x80)
|
|
1390
|
+
return { value, next: pos + 1 };
|
|
1391
|
+
multiplier *= 128;
|
|
1392
|
+
}
|
|
1393
|
+
return null;
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* True for a glyph range PBF holding only its font name and range. Servers
|
|
1397
|
+
* return these for blocks a font doesn't cover, e.g. `DIN Pro Medium/1536-1791`.
|
|
1398
|
+
* Serving one in place of a later font in the chain blanks those labels.
|
|
1399
|
+
*
|
|
1400
|
+
* Reads `glyphs { fontstack stacks = 1 { name = 1; range = 2; glyph = 3 } }`.
|
|
1401
|
+
* Anything unexpected counts as not empty, so unknown data is still served.
|
|
1402
|
+
*/
|
|
1403
|
+
function isEmptyGlyphRange(data) {
|
|
1404
|
+
const bytes = new Uint8Array(data);
|
|
1405
|
+
if (bytes[0] !== 0x0a)
|
|
1406
|
+
return false;
|
|
1407
|
+
const stack = readVarint(bytes, 1);
|
|
1408
|
+
if (!stack)
|
|
1409
|
+
return false;
|
|
1410
|
+
const end = stack.next + stack.value;
|
|
1411
|
+
if (end !== bytes.length)
|
|
1412
|
+
return false;
|
|
1413
|
+
let pos = stack.next;
|
|
1414
|
+
while (pos < end) {
|
|
1415
|
+
const tag = bytes[pos];
|
|
1416
|
+
if ((tag & 0x07) !== 2 || tag >> 3 === 3)
|
|
1417
|
+
return false;
|
|
1418
|
+
const length = readVarint(bytes, pos + 1);
|
|
1419
|
+
if (!length)
|
|
1420
|
+
return false;
|
|
1421
|
+
pos = length.next + length.value;
|
|
1422
|
+
}
|
|
1423
|
+
return true;
|
|
1424
|
+
}
|
|
1326
1425
|
/**
|
|
1327
1426
|
* Build the list of keys to try for a single (fontstack, range) pair.
|
|
1328
1427
|
* Order: actualStyleId variants first (most common), then downloadId,
|
|
@@ -1493,10 +1592,10 @@ function clearAllCaches() {
|
|
|
1493
1592
|
tileCache.clear();
|
|
1494
1593
|
}
|
|
1495
1594
|
/**
|
|
1496
|
-
*
|
|
1497
|
-
*
|
|
1498
|
-
*
|
|
1499
|
-
* Results are cached to avoid repeated DB queries during tile fetching
|
|
1595
|
+
* Resolve the `{id}` segment of an `idb://{id}/...` URL to its style entry.
|
|
1596
|
+
* Styles patched since URLs became style-keyed use the style key itself (one
|
|
1597
|
+
* keyed read); older stored styles use a region id, which needs a scan of all
|
|
1598
|
+
* styles. Results are cached to avoid repeated DB queries during tile fetching.
|
|
1500
1599
|
*/
|
|
1501
1600
|
async function findStyleByRegionId(db, regionId) {
|
|
1502
1601
|
// Check cache first
|
|
@@ -1505,6 +1604,11 @@ async function findStyleByRegionId(db, regionId) {
|
|
|
1505
1604
|
return cached.styleEntry;
|
|
1506
1605
|
}
|
|
1507
1606
|
try {
|
|
1607
|
+
const direct = (await db.get('styles', regionId));
|
|
1608
|
+
if (direct) {
|
|
1609
|
+
regionToStyleCache.set(regionId, { styleEntry: direct, timestamp: Date.now() });
|
|
1610
|
+
return direct;
|
|
1611
|
+
}
|
|
1508
1612
|
const allStyles = await db.getAll('styles');
|
|
1509
1613
|
const hit = findStyleByRegionIdIn(allStyles, regionId);
|
|
1510
1614
|
if (hit) {
|
|
@@ -1594,9 +1698,11 @@ async function idbFetchHandler(url, init) {
|
|
|
1594
1698
|
}
|
|
1595
1699
|
const db = await dbPromise;
|
|
1596
1700
|
const parsed = url.replace('idb://', '').split('/');
|
|
1597
|
-
|
|
1701
|
+
// A Request's URL arrives percent-encoded, so decode the style id as the Service Worker does.
|
|
1702
|
+
const [encodedDownloadId, type, ...rest] = parsed;
|
|
1703
|
+
const downloadId = safeDecodeURIComponent(encodedDownloadId ?? '');
|
|
1598
1704
|
const resourcePath = rest.join('/');
|
|
1599
|
-
const decodedResourcePath =
|
|
1705
|
+
const decodedResourcePath = safeDecodeURIComponent(resourcePath);
|
|
1600
1706
|
const key = `${downloadId}::${decodedResourcePath}`;
|
|
1601
1707
|
try {
|
|
1602
1708
|
switch (type) {
|
|
@@ -1615,7 +1721,7 @@ async function idbFetchHandler(url, init) {
|
|
|
1615
1721
|
// MapLibre can request fractional zoom levels (e.g., 12.5)
|
|
1616
1722
|
// but tiles are stored with integer zoom levels, so floor the value
|
|
1617
1723
|
const z = Math.floor(parseFloat(pathParts[pathParts.length - 3]));
|
|
1618
|
-
const sourceKey = pathParts.slice(0, pathParts.length - 3).join('/');
|
|
1724
|
+
const sourceKey = safeDecodeURIComponent(pathParts.slice(0, pathParts.length - 3).join('/'));
|
|
1619
1725
|
const yMatch = yExt.match(/^(\d+)\.(\w+)$/);
|
|
1620
1726
|
if (yMatch) {
|
|
1621
1727
|
const y = parseInt(yMatch[1]);
|
|
@@ -1653,7 +1759,7 @@ async function idbFetchHandler(url, init) {
|
|
|
1653
1759
|
if (pathParts.length === 1) {
|
|
1654
1760
|
// Old format without sourceKey - try to extract from the URL
|
|
1655
1761
|
const encodedTileUrl = pathParts[0];
|
|
1656
|
-
const tileUrl =
|
|
1762
|
+
const tileUrl = safeDecodeURIComponent(encodedTileUrl);
|
|
1657
1763
|
// Try to extract source from URL pattern
|
|
1658
1764
|
// For example: https://domain.com/service/source/vt/{z}/{x}/{y}.pbf
|
|
1659
1765
|
const urlParts = tileUrl.split('/');
|
|
@@ -1697,21 +1803,30 @@ async function idbFetchHandler(url, init) {
|
|
|
1697
1803
|
const actualStyleId = styleEntry?.key || downloadId;
|
|
1698
1804
|
const { fontstacks, rangePart } = parseGlyphPath(decodedResourcePath);
|
|
1699
1805
|
idbLogger.debug(`Trying ${fontstacks.length} fonts in fallback order: ${fontstacks.join(', ')}`);
|
|
1806
|
+
const glyphResponse = (data) => new Response(data, {
|
|
1807
|
+
status: 200,
|
|
1808
|
+
headers: { 'Content-Type': 'application/x-protobuf' },
|
|
1809
|
+
});
|
|
1810
|
+
let emptyRange;
|
|
1700
1811
|
for (const fontstack of fontstacks) {
|
|
1701
1812
|
const candidateKeys = glyphCandidateKeys(actualStyleId, downloadId, fontstack, rangePart);
|
|
1702
1813
|
for (const candidateKey of candidateKeys) {
|
|
1703
1814
|
const resource = await db.get('glyphs', candidateKey);
|
|
1704
1815
|
if (resource?.data) {
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1816
|
+
if (!isEmptyGlyphRange(resource.data)) {
|
|
1817
|
+
idbLogger.debug(`Found glyph using key: ${candidateKey} (font: ${fontstack})`);
|
|
1818
|
+
return glyphResponse(resource.data);
|
|
1819
|
+
}
|
|
1820
|
+
emptyRange ??= resource.data;
|
|
1710
1821
|
}
|
|
1711
1822
|
}
|
|
1712
1823
|
}
|
|
1713
|
-
|
|
1714
|
-
|
|
1824
|
+
// Never a 404: Mapbox GL fails a tile's whole glyph request if one range
|
|
1825
|
+
// errors, so a range outside the downloaded set would blank all its labels.
|
|
1826
|
+
if (!emptyRange) {
|
|
1827
|
+
idbLogger.warn(`Glyph not found for any font in: ${fontstacks.join(', ')}`);
|
|
1828
|
+
}
|
|
1829
|
+
return glyphResponse(emptyRange ?? new ArrayBuffer(0));
|
|
1715
1830
|
}
|
|
1716
1831
|
case 'sprite': {
|
|
1717
1832
|
idbLogger.debug(`Looking for sprite with key: ${key}`);
|
|
@@ -1803,7 +1918,8 @@ async function idbFetchHandler(url, init) {
|
|
|
1803
1918
|
catch (error) {
|
|
1804
1919
|
idbLogger.error(`Error fetching resource from IDB: ${url}`, error);
|
|
1805
1920
|
}
|
|
1806
|
-
|
|
1921
|
+
// Normal once the map moves outside a saved region, so not worth a warning per tile.
|
|
1922
|
+
idbLogger.debug(`Resource not found in IDB: ${url}`);
|
|
1807
1923
|
return new Response('Not found in IDB', { status: 404 });
|
|
1808
1924
|
}
|
|
1809
1925
|
|
|
@@ -1864,57 +1980,103 @@ function resolveMapboxUrl(mapboxUrl, accessToken) {
|
|
|
1864
1980
|
if (!accessToken) {
|
|
1865
1981
|
throw new Error(`Mapbox access token is required to resolve mapbox:// URL: ${mapboxUrl}`);
|
|
1866
1982
|
}
|
|
1867
|
-
// Strip the protocol prefix
|
|
1868
|
-
|
|
1983
|
+
// Strip the protocol prefix, and split off any query so path matching and
|
|
1984
|
+
// the resolved URL's suffixes (`.json`, `/sprite`) go before it. Existing
|
|
1985
|
+
// parameters are kept; `access_token` is set exactly once.
|
|
1986
|
+
const withoutProtocol = mapboxUrl.slice(MAPBOX_API.PROTOCOL.length);
|
|
1987
|
+
const queryIndex = withoutProtocol.indexOf('?');
|
|
1988
|
+
const path = queryIndex === -1 ? withoutProtocol : withoutProtocol.slice(0, queryIndex);
|
|
1989
|
+
const params = new URLSearchParams(queryIndex === -1 ? '' : withoutProtocol.slice(queryIndex + 1));
|
|
1990
|
+
params.set('access_token', accessToken);
|
|
1991
|
+
const query = `?${params.toString()}`;
|
|
1869
1992
|
// mapbox://styles/{user}/{id}
|
|
1870
1993
|
if (path.startsWith('styles/')) {
|
|
1871
1994
|
const rest = path.slice('styles/'.length);
|
|
1872
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}
|
|
1995
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}${query}`;
|
|
1873
1996
|
}
|
|
1874
1997
|
// mapbox://sprites/{user}/{id}
|
|
1875
1998
|
if (path.startsWith('sprites/')) {
|
|
1876
1999
|
const rest = path.slice('sprites/'.length);
|
|
1877
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}/sprite
|
|
2000
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}/sprite${query}`;
|
|
1878
2001
|
}
|
|
1879
2002
|
// mapbox://fonts/{user}/{fontstack}/{range}.pbf
|
|
1880
2003
|
if (path.startsWith('fonts/')) {
|
|
1881
2004
|
const rest = path.slice('fonts/'.length);
|
|
1882
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.FONTS_PATH}/${rest}
|
|
2005
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.FONTS_PATH}/${rest}${query}`;
|
|
1883
2006
|
}
|
|
1884
2007
|
// mapbox://models/{path}
|
|
1885
2008
|
if (path.startsWith('models/')) {
|
|
1886
2009
|
const rest = path.slice('models/'.length);
|
|
1887
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.MODELS_PATH}/${rest}
|
|
2010
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.MODELS_PATH}/${rest}${query}`;
|
|
1888
2011
|
}
|
|
1889
2012
|
// mapbox://{tileset} (e.g. mapbox://mapbox.mapbox-streets-v8)
|
|
1890
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${path}.json
|
|
2013
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${path}.json${query}`;
|
|
1891
2014
|
}
|
|
1892
2015
|
/**
|
|
1893
|
-
*
|
|
2016
|
+
* Pick the TileJSON variant Mapbox GL renders from, the way its `mergeVariants`
|
|
2017
|
+
* does: `meshopt-v2+lod`, then `meshopt`, then the base tileset.
|
|
1894
2018
|
*
|
|
1895
|
-
*
|
|
1896
|
-
*
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
1899
|
-
*
|
|
2019
|
+
* These aren't just different encodings of one dataset. For
|
|
2020
|
+
* `mapbox.mapbox-3dbuildings-v1`, the base tile over central Dubai is empty while
|
|
2021
|
+
* the variants hold its 3D buildings, so downloading the base loses them offline.
|
|
2022
|
+
* Variant tiles are `mapbox://3dtiles/...` URLs, resolved here using the base
|
|
2023
|
+
* URL's query string, which carries the access token.
|
|
2024
|
+
*/
|
|
2025
|
+
function selectTileJsonVariant(tileJson) {
|
|
2026
|
+
const { variants, tiles: baseTiles } = tileJson;
|
|
2027
|
+
if (!Array.isArray(variants))
|
|
2028
|
+
return tileJson;
|
|
2029
|
+
const withCapability = (capability) => variants.find(variant => Array.isArray(variant?.capabilities) &&
|
|
2030
|
+
variant.capabilities.length === 1 &&
|
|
2031
|
+
variant.capabilities[0] === capability);
|
|
2032
|
+
const selected = withCapability('meshopt-v2+lod') ?? withCapability('meshopt');
|
|
2033
|
+
if (!selected || !Array.isArray(selected.tiles))
|
|
2034
|
+
return tileJson;
|
|
2035
|
+
const firstBaseTile = Array.isArray(baseTiles) ? String(baseTiles[0] ?? '') : '';
|
|
2036
|
+
const queryIndex = firstBaseTile.indexOf('?');
|
|
2037
|
+
const query = queryIndex === -1 ? '' : firstBaseTile.slice(queryIndex);
|
|
2038
|
+
const tiles = selected.tiles.map(tile => tile.startsWith('mapbox://3dtiles/')
|
|
2039
|
+
? `${MAPBOX_API.BASE_URL}/3dtiles/v1/${tile.slice('mapbox://3dtiles/'.length)}${query}`
|
|
2040
|
+
: tile);
|
|
2041
|
+
return { ...tileJson, ...selected, tiles };
|
|
2042
|
+
}
|
|
2043
|
+
const SKU_LIFETIME_MS = 12 * 60 * 60 * 1000;
|
|
2044
|
+
const SKU_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
2045
|
+
let skuSession;
|
|
2046
|
+
/** A Mapbox session token in the format Mapbox GL JS generates: version + SKU id + 10 random. */
|
|
2047
|
+
function getSkuToken() {
|
|
2048
|
+
if (!skuSession || Date.now() > skuSession.expiresAt) {
|
|
2049
|
+
let randomizer = '';
|
|
2050
|
+
for (let i = 0; i < 10; i++) {
|
|
2051
|
+
randomizer += SKU_CHARS[Math.floor(Math.random() * SKU_CHARS.length)];
|
|
2052
|
+
}
|
|
2053
|
+
skuSession = { token: `101${randomizer}`, expiresAt: Date.now() + SKU_LIFETIME_MS };
|
|
2054
|
+
}
|
|
2055
|
+
return skuSession.token;
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* Add the `sku` session token that Mapbox `/raster/v1/` tile URLs need.
|
|
2059
|
+
*
|
|
2060
|
+
* TileJSON returns these URLs without one, and without it they 401. Mapbox GL
|
|
2061
|
+
* adds it at request time, but this library fetches tiles itself.
|
|
2062
|
+
*
|
|
2063
|
+
* Don't switch these to the `/v4/` endpoint, which needs no token: it serves a
|
|
2064
|
+
* different tile. For `mapbox.mapbox-terrain-dem-v1`, `/v4/….png` is a 256px
|
|
2065
|
+
* palette PNG with elevations off by up to ~200 m, while the renderer expects
|
|
2066
|
+
* the 514px RGB tile (`tileSize: 514`) that `/raster/v1/` returns.
|
|
1900
2067
|
*
|
|
1901
2068
|
* Example:
|
|
1902
2069
|
* https://a.tiles.mapbox.com/raster/v1/mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png?access_token=...
|
|
1903
|
-
* -> https://
|
|
2070
|
+
* -> https://a.tiles.mapbox.com/raster/v1/mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png?access_token=...&sku=101...
|
|
1904
2071
|
*/
|
|
1905
2072
|
function rewriteMapboxCdnTileUrl(tileUrl) {
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
const match = tileUrl.match(/^https?:\/\/[a-z]\.tiles\.mapbox\.com\/raster\/v1\/([^?]+)(\?.*)?$/);
|
|
1911
|
-
if (!match) {
|
|
2073
|
+
if (!/^https?:\/\/(?:[a-z]\.tiles\.mapbox\.com|api\.mapbox\.com)\/raster\/v1\//.test(tileUrl)) {
|
|
2074
|
+
return tileUrl;
|
|
2075
|
+
}
|
|
2076
|
+
if (/[?&]sku=/.test(tileUrl)) {
|
|
1912
2077
|
return tileUrl;
|
|
1913
2078
|
}
|
|
1914
|
-
|
|
1915
|
-
const tilesetAndPath = match[1];
|
|
1916
|
-
const queryString = match[2] || '';
|
|
1917
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${tilesetAndPath}${queryString}`;
|
|
2079
|
+
return `${tileUrl}${tileUrl.includes('?') ? '&' : '?'}sku=${getSkuToken()}`;
|
|
1918
2080
|
}
|
|
1919
2081
|
/**
|
|
1920
2082
|
* Detect the style provider based on the style URL or content
|
|
@@ -2140,24 +2302,100 @@ function validateStyleForProvider(style, provider) {
|
|
|
2140
2302
|
|
|
2141
2303
|
const styleLogger = logger.scope('StyleUtils');
|
|
2142
2304
|
/**
|
|
2143
|
-
* Patches a MapboxStyle for offline use by replacing URLs with IndexedDB references
|
|
2305
|
+
* Patches a MapboxStyle for offline use by replacing URLs with IndexedDB references.
|
|
2306
|
+
*
|
|
2307
|
+
* Safe to call repeatedly on the same stored style (every `addRegion` does):
|
|
2308
|
+
* the first call stashes each source's upstream `tiles` / `maxzoom` under
|
|
2309
|
+
* `__originalTiles` / `__originalMaxzoom`, and later calls patch from those,
|
|
2310
|
+
* so the download pipeline can still reach the network and `maxzoom` can grow
|
|
2311
|
+
* again when a deeper region is added.
|
|
2312
|
+
*
|
|
2144
2313
|
* @param style - The style to patch
|
|
2145
|
-
* @param downloadId -
|
|
2146
|
-
* @param maxZoom -
|
|
2314
|
+
* @param downloadId - Fallback ID for `idb://` URLs when `styleId` is omitted
|
|
2315
|
+
* @param maxZoom - Max zoom downloaded for this style (the highest across its regions)
|
|
2147
2316
|
* @param tileExtension - Optional tile extension
|
|
2148
|
-
* @param styleId -
|
|
2149
|
-
*
|
|
2150
|
-
*
|
|
2317
|
+
* @param styleId - Style ID used in all `idb://` URLs. Tiles, glyphs, sprites and
|
|
2318
|
+
* models are stored per style, so URLs keyed by style keep working
|
|
2319
|
+
* when any one region is deleted.
|
|
2320
|
+
*/
|
|
2321
|
+
/**
|
|
2322
|
+
* Add a region's extra tile sources to a style, unless one with that id is
|
|
2323
|
+
* already there. Sources must be in the style before it's patched for offline use.
|
|
2324
|
+
*/
|
|
2325
|
+
function injectExtraSources(style, extraSources = []) {
|
|
2326
|
+
for (const extra of extraSources) {
|
|
2327
|
+
if (style.sources[extra.id])
|
|
2328
|
+
continue;
|
|
2329
|
+
style.sources[extra.id] = {
|
|
2330
|
+
type: extra.type || 'vector',
|
|
2331
|
+
tiles: extra.tiles,
|
|
2332
|
+
...(extra.minzoom !== undefined ? { minzoom: extra.minzoom } : {}),
|
|
2333
|
+
...(extra.maxzoom !== undefined ? { maxzoom: extra.maxzoom } : {}),
|
|
2334
|
+
...(extra.attribution ? { attribution: extra.attribution } : {}),
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Keep each model's upstream URL before the style is patched, the first time
|
|
2340
|
+
* only. Patching points every model at `idb://`, so without this a model that
|
|
2341
|
+
* failed to download couldn't be fetched again.
|
|
2342
|
+
*/
|
|
2343
|
+
function stashOriginalModels(entry) {
|
|
2344
|
+
const models = entry.style.models;
|
|
2345
|
+
if (!models || entry.originalModels)
|
|
2346
|
+
return;
|
|
2347
|
+
const isPatched = (value) => String(typeof value === 'string' ? value : value?.uri).startsWith('idb://');
|
|
2348
|
+
// A style patched before this stash existed has no upstream URLs left to keep.
|
|
2349
|
+
if (Object.values(models).some(isPatched))
|
|
2350
|
+
return;
|
|
2351
|
+
entry.originalModels = JSON.parse(JSON.stringify(models));
|
|
2352
|
+
}
|
|
2353
|
+
/**
|
|
2354
|
+
* Point models that aren't stored back at their upstream URLs. Run after
|
|
2355
|
+
* patching, which points every model at `idb://`.
|
|
2356
|
+
*/
|
|
2357
|
+
function restoreUnstoredModels(entry, storedNames) {
|
|
2358
|
+
const models = entry.style.models;
|
|
2359
|
+
if (!models || !entry.originalModels)
|
|
2360
|
+
return;
|
|
2361
|
+
for (const name of Object.keys(models)) {
|
|
2362
|
+
if (!storedNames.has(name) && entry.originalModels[name] !== undefined) {
|
|
2363
|
+
models[name] = JSON.parse(JSON.stringify(entry.originalModels[name]));
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
/**
|
|
2368
|
+
* Whether a style uses parts of the spec only Mapbox GL JS v3 draws: config
|
|
2369
|
+
* (`schema`), 3D `models`, `lights` or `imports`. MapLibre rejects these.
|
|
2151
2370
|
*/
|
|
2371
|
+
function needsMapboxGl(style) {
|
|
2372
|
+
const parts = style;
|
|
2373
|
+
return Boolean(parts.schema || parts.models || parts.lights || parts.imports);
|
|
2374
|
+
}
|
|
2375
|
+
/** Whether a map instance is Mapbox GL JS v3, the only renderer with style config. */
|
|
2376
|
+
function isMapboxGlMap(map) {
|
|
2377
|
+
return typeof map?.setConfigProperty === 'function';
|
|
2378
|
+
}
|
|
2152
2379
|
function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId) {
|
|
2153
2380
|
styleLogger.debug(`Patching style for offline use with downloadId: ${downloadId}, maxZoom: ${maxZoom}, tileExtension: ${tileExtension}`);
|
|
2154
2381
|
styleLogger.debug(`Original style:`, style);
|
|
2382
|
+
const urlId = styleId || downloadId;
|
|
2155
2383
|
// Patch sources
|
|
2156
2384
|
for (const sourceKey in style.sources) {
|
|
2157
2385
|
const source = style.sources[sourceKey];
|
|
2158
2386
|
styleLogger.debug(`Patching source: ${sourceKey}`, source);
|
|
2387
|
+
// Stash upstream values once. Already-patched tiles (all idb://) are never
|
|
2388
|
+
// stashed as "original"; `null` records that the source had no maxzoom.
|
|
2389
|
+
if (source.tiles &&
|
|
2390
|
+
source.__originalTiles === undefined &&
|
|
2391
|
+
!source.tiles.every(url => url.startsWith('idb://'))) {
|
|
2392
|
+
source.__originalTiles = [...source.tiles];
|
|
2393
|
+
}
|
|
2394
|
+
if (source.__originalMaxzoom === undefined) {
|
|
2395
|
+
source.__originalMaxzoom = source.maxzoom ?? null;
|
|
2396
|
+
}
|
|
2159
2397
|
if (source.tiles) {
|
|
2160
|
-
const originalTiles = [...source.tiles];
|
|
2398
|
+
const originalTiles = source.__originalTiles ?? [...source.tiles];
|
|
2161
2399
|
// Patch to idb://{downloadId}/tile/{sourceKey}/{z}/{x}/{y}.ext.
|
|
2162
2400
|
// Extension extraction goes through the shared extractTileExtensionFromUrl
|
|
2163
2401
|
// helper so the patched URL's extension matches what tileService used when
|
|
@@ -2165,9 +2403,9 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2165
2403
|
// stored key under `.pbf` but a patched URL with `.vector`, forcing
|
|
2166
2404
|
// idbFetchHandler to fall through its pbf/mvt/png/jpg/webp fallback loop
|
|
2167
2405
|
// on every tile.
|
|
2168
|
-
source.tiles =
|
|
2406
|
+
source.tiles = originalTiles.map((url) => {
|
|
2169
2407
|
const ext = tileExtension ?? extractTileExtensionFromUrl(url);
|
|
2170
|
-
return `idb://${
|
|
2408
|
+
return `idb://${urlId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`;
|
|
2171
2409
|
});
|
|
2172
2410
|
styleLogger.debug(`Patched tiles for ${sourceKey} with extension .${tileExtension || 'pbf'}:`, {
|
|
2173
2411
|
original: originalTiles,
|
|
@@ -2175,10 +2413,12 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2175
2413
|
});
|
|
2176
2414
|
}
|
|
2177
2415
|
// Cap maxzoom so the map doesn't request tiles beyond what we downloaded.
|
|
2178
|
-
// Use the lower of the
|
|
2179
|
-
// don't raise it above the tileset's actual range (e.g.
|
|
2416
|
+
// Use the lower of the downloaded maxZoom and the source's *upstream*
|
|
2417
|
+
// maxzoom so we don't raise it above the tileset's actual range (e.g.
|
|
2418
|
+
// 3dbuildings at z14) — and so an earlier, shallower region's cap doesn't
|
|
2419
|
+
// stick once a deeper region is added.
|
|
2180
2420
|
if (maxZoom !== undefined) {
|
|
2181
|
-
const originalMaxzoom = source.
|
|
2421
|
+
const originalMaxzoom = source.__originalMaxzoom ?? undefined;
|
|
2182
2422
|
source.maxzoom = originalMaxzoom !== undefined ? Math.min(maxZoom, originalMaxzoom) : maxZoom;
|
|
2183
2423
|
styleLogger.debug(`Set maxzoom for ${sourceKey}: ${originalMaxzoom} → ${source.maxzoom}`);
|
|
2184
2424
|
}
|
|
@@ -2191,7 +2431,7 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2191
2431
|
let ext = tileExtension;
|
|
2192
2432
|
if (!ext)
|
|
2193
2433
|
ext = 'pbf';
|
|
2194
|
-
source.tiles = [`idb://${
|
|
2434
|
+
source.tiles = [`idb://${urlId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`];
|
|
2195
2435
|
styleLogger.debug(`Added tiles array for TileJSON-only source ${sourceKey}:`, {
|
|
2196
2436
|
tiles: source.tiles,
|
|
2197
2437
|
});
|
|
@@ -2207,7 +2447,7 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2207
2447
|
// Patch glyphs
|
|
2208
2448
|
if (style.glyphs) {
|
|
2209
2449
|
const originalGlyphs = style.glyphs;
|
|
2210
|
-
style.glyphs = `idb://${
|
|
2450
|
+
style.glyphs = `idb://${urlId}/glyph/{fontstack}/{range}.pbf`;
|
|
2211
2451
|
styleLogger.debug(`Patched glyphs:`, { original: originalGlyphs, patched: style.glyphs });
|
|
2212
2452
|
}
|
|
2213
2453
|
// Patch sprite
|
|
@@ -2266,13 +2506,31 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2266
2506
|
styleLogger.debug(`Final patched style:`, style);
|
|
2267
2507
|
return style;
|
|
2268
2508
|
}
|
|
2509
|
+
/** Resolve one entry of a font stack: a name, `["config", key]` or a `["concat", ...]` of those. */
|
|
2510
|
+
function resolveFontName(value, schema) {
|
|
2511
|
+
if (typeof value === 'string')
|
|
2512
|
+
return value;
|
|
2513
|
+
if (!Array.isArray(value))
|
|
2514
|
+
return undefined;
|
|
2515
|
+
if (value[0] === 'config' && typeof value[1] === 'string') {
|
|
2516
|
+
const configured = schema?.[value[1]]?.default;
|
|
2517
|
+
return typeof configured === 'string' ? configured : undefined;
|
|
2518
|
+
}
|
|
2519
|
+
if (value[0] === 'concat') {
|
|
2520
|
+
const parts = value.slice(1).map(part => resolveFontName(part, schema));
|
|
2521
|
+
return parts.every(part => part !== undefined) ? parts.join('') : undefined;
|
|
2522
|
+
}
|
|
2523
|
+
return undefined;
|
|
2524
|
+
}
|
|
2269
2525
|
/**
|
|
2270
2526
|
* Extract individual font names from a `text-font` layout property value.
|
|
2271
2527
|
*
|
|
2272
|
-
* Handles
|
|
2273
|
-
*
|
|
2528
|
+
* Handles simple arrays (`["Font A", "Font B"]`), Mapbox GL expression syntax
|
|
2529
|
+
* (e.g. `["step", ["zoom"], ["literal", ["Font A"]], 10, ["literal", ["Font B"]]]`),
|
|
2530
|
+
* and Mapbox Standard's config-built stacks
|
|
2531
|
+
* (`[["concat", ["config", "font"], " Medium"], "Arial Unicode MS Bold"]`).
|
|
2274
2532
|
*/
|
|
2275
|
-
function extractFontNamesFromTextField(textFont) {
|
|
2533
|
+
function extractFontNamesFromTextField(textFont, schema) {
|
|
2276
2534
|
const fonts = new Set();
|
|
2277
2535
|
// Known expression operators — anything starting with one of these is NOT a font name list
|
|
2278
2536
|
const expressionOps = new Set([
|
|
@@ -2312,20 +2570,40 @@ function extractFontNamesFromTextField(textFont) {
|
|
|
2312
2570
|
}
|
|
2313
2571
|
return;
|
|
2314
2572
|
}
|
|
2315
|
-
//
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2573
|
+
// A font stack: only font names, and the first isn't an expression operator
|
|
2574
|
+
const isFontEntry = (item) => typeof item === 'string' ||
|
|
2575
|
+
(Array.isArray(item) && (item[0] === 'config' || item[0] === 'concat'));
|
|
2576
|
+
if (value.every(isFontEntry) && !expressionOps.has(value[0])) {
|
|
2577
|
+
for (const item of value) {
|
|
2578
|
+
const name = resolveFontName(item, schema);
|
|
2579
|
+
if (name !== undefined)
|
|
2580
|
+
fonts.add(name);
|
|
2320
2581
|
}
|
|
2321
2582
|
return;
|
|
2322
2583
|
}
|
|
2323
|
-
//
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2584
|
+
// Only the branches that output a font. Conditions and inputs hold strings
|
|
2585
|
+
// like `["zoom"]` or match labels that aren't fonts.
|
|
2586
|
+
const last = value.length - 1;
|
|
2587
|
+
const isOutput = (i) => {
|
|
2588
|
+
switch (value[0]) {
|
|
2589
|
+
case 'step': // ["step", input, out0, stop, out, ...]
|
|
2590
|
+
return i >= 2 && i % 2 === 0;
|
|
2591
|
+
case 'match': // ["match", input, label, out, label, out, ..., fallback]
|
|
2592
|
+
return i === last || (i >= 3 && i % 2 === 1);
|
|
2593
|
+
case 'case': // ["case", cond, out, cond, out, ..., fallback]
|
|
2594
|
+
return i === last || (i >= 2 && i % 2 === 0);
|
|
2595
|
+
case 'coalesce':
|
|
2596
|
+
return i >= 1;
|
|
2597
|
+
case 'let': // ["let", name, value, ..., body]
|
|
2598
|
+
return i === last;
|
|
2599
|
+
default:
|
|
2600
|
+
return false;
|
|
2327
2601
|
}
|
|
2328
|
-
}
|
|
2602
|
+
};
|
|
2603
|
+
value.forEach((item, i) => {
|
|
2604
|
+
if (isOutput(i))
|
|
2605
|
+
walk(item);
|
|
2606
|
+
});
|
|
2329
2607
|
}
|
|
2330
2608
|
walk(textFont);
|
|
2331
2609
|
return Array.from(fonts);
|
|
@@ -2353,7 +2631,7 @@ function extractAllFontNames(style) {
|
|
|
2353
2631
|
if (style && Array.isArray(style.layers)) {
|
|
2354
2632
|
for (const layer of style.layers) {
|
|
2355
2633
|
if (layer.layout && layer.layout['text-font']) {
|
|
2356
|
-
for (const name of extractFontNamesFromTextField(layer.layout['text-font'])) {
|
|
2634
|
+
for (const name of extractFontNamesFromTextField(layer.layout['text-font'], style.schema)) {
|
|
2357
2635
|
fonts.add(name);
|
|
2358
2636
|
}
|
|
2359
2637
|
}
|
|
@@ -2422,8 +2700,13 @@ var styleUtils = /*#__PURE__*/Object.freeze({
|
|
|
2422
2700
|
extractAllFontNames: extractAllFontNames,
|
|
2423
2701
|
extractFontNamesFromTextField: extractFontNamesFromTextField,
|
|
2424
2702
|
generateGlyphUrlsFromStyle: generateGlyphUrlsFromStyle,
|
|
2703
|
+
injectExtraSources: injectExtraSources,
|
|
2704
|
+
isMapboxGlMap: isMapboxGlMap,
|
|
2705
|
+
needsMapboxGl: needsMapboxGl,
|
|
2425
2706
|
normalizeSpriteProperty: normalizeSpriteProperty,
|
|
2426
|
-
patchStyleForOffline: patchStyleForOffline
|
|
2707
|
+
patchStyleForOffline: patchStyleForOffline,
|
|
2708
|
+
restoreUnstoredModels: restoreUnstoredModels,
|
|
2709
|
+
stashOriginalModels: stashOriginalModels
|
|
2427
2710
|
});
|
|
2428
2711
|
|
|
2429
2712
|
const cleanupLogger$1 = logger.scope('CleanupCompressedTiles');
|
|
@@ -2980,7 +3263,7 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
|
|
|
2980
3263
|
// Collect all flattened sources, layers from imports
|
|
2981
3264
|
const flattenedSources = {};
|
|
2982
3265
|
const flattenedLayers = [];
|
|
2983
|
-
|
|
3266
|
+
const importSprites = [];
|
|
2984
3267
|
let importedGlyphs;
|
|
2985
3268
|
let importedModels;
|
|
2986
3269
|
for (const importEntry of imports) {
|
|
@@ -3032,6 +3315,7 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
|
|
|
3032
3315
|
// Build config lookup: schema defaults + import config overrides
|
|
3033
3316
|
const configValues = buildConfigLookup(importedStyle, importEntry.config);
|
|
3034
3317
|
// Flatten layers: prefix layer IDs, update source references, resolve config expressions
|
|
3318
|
+
const importLayers = [];
|
|
3035
3319
|
if (importedStyle.layers && Array.isArray(importedStyle.layers)) {
|
|
3036
3320
|
for (const layer of importedStyle.layers) {
|
|
3037
3321
|
const layerObj = layer;
|
|
@@ -3047,11 +3331,13 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
|
|
|
3047
3331
|
// Resolve ["config", "key"] expressions using schema defaults and import overrides.
|
|
3048
3332
|
resolveConfigExpressions(prefixedLayer, configValues);
|
|
3049
3333
|
flattenedLayers.push(prefixedLayer);
|
|
3334
|
+
importLayers.push(prefixedLayer);
|
|
3050
3335
|
}
|
|
3051
3336
|
}
|
|
3052
|
-
// Sprite: collect
|
|
3053
|
-
if
|
|
3054
|
-
|
|
3337
|
+
// Sprite: collect every import's sprite with its layers, so each can be
|
|
3338
|
+
// scoped if the flattened style ends up with more than one sprite.
|
|
3339
|
+
if (importedStyle.sprite) {
|
|
3340
|
+
importSprites.push({ importId, sprite: importedStyle.sprite, layers: importLayers });
|
|
3055
3341
|
}
|
|
3056
3342
|
// Glyphs: collect imported glyphs
|
|
3057
3343
|
if (importedStyle.glyphs && !importedGlyphs) {
|
|
@@ -3068,12 +3354,10 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
|
|
|
3068
3354
|
// Layers: imported layers go first (bottom), outer layers on top
|
|
3069
3355
|
const outerLayers = style.layers || [];
|
|
3070
3356
|
style.layers = [...flattenedLayers, ...outerLayers];
|
|
3071
|
-
// Sprite:
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
else if (!style.sprite && importedSprite) {
|
|
3076
|
-
style.sprite = importedSprite;
|
|
3357
|
+
// Sprite: a single sprite stays as-is; several become an array sprite with
|
|
3358
|
+
// each import's image references scoped to its sprite id.
|
|
3359
|
+
if (importSprites.length > 0) {
|
|
3360
|
+
style.sprite = combineSprites(style.sprite, importSprites);
|
|
3077
3361
|
}
|
|
3078
3362
|
// Glyphs: outer wins; if absent, use imported
|
|
3079
3363
|
if (!style.glyphs && importedGlyphs) {
|
|
@@ -3260,23 +3544,37 @@ function isExpression(value) {
|
|
|
3260
3544
|
return Array.isArray(value) && value.length > 0 && typeof value[0] === 'string';
|
|
3261
3545
|
}
|
|
3262
3546
|
/**
|
|
3263
|
-
*
|
|
3264
|
-
*
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3547
|
+
* Combine the outer style's sprite with its imports' sprites.
|
|
3548
|
+
*
|
|
3549
|
+
* With one sprite in total it is returned unchanged (a plain string keeps
|
|
3550
|
+
* working in Mapbox GL, which only accepts a string `sprite`). With several,
|
|
3551
|
+
* the result is a MapLibre array sprite: imports first, the outer sprite last
|
|
3552
|
+
* as `default`. Array-sprite images from any entry other than `default` are
|
|
3553
|
+
* only reachable as `{id}:{name}`, so each string-sprite import gets its
|
|
3554
|
+
* import id as sprite id and its layers' image references are prefixed with
|
|
3555
|
+
* `{importId}:`. Imports that already use an array sprite keep their entries
|
|
3556
|
+
* and references unchanged.
|
|
3557
|
+
*/
|
|
3558
|
+
function combineSprites(outer, imports) {
|
|
3559
|
+
if (!outer && imports.length === 1) {
|
|
3560
|
+
return imports[0].sprite;
|
|
3271
3561
|
}
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3562
|
+
const result = [];
|
|
3563
|
+
for (const { importId, sprite, layers } of imports) {
|
|
3564
|
+
if (typeof sprite === 'string') {
|
|
3565
|
+
result.push({ id: importId, url: sprite });
|
|
3566
|
+
for (const layer of layers) {
|
|
3567
|
+
prefixLayerImages(layer, `${importId}:`);
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
else if (Array.isArray(sprite)) {
|
|
3571
|
+
for (const entry of sprite) {
|
|
3572
|
+
if (entry && typeof entry.url === 'string') {
|
|
3573
|
+
result.push(entry);
|
|
3574
|
+
}
|
|
3276
3575
|
}
|
|
3277
3576
|
}
|
|
3278
3577
|
}
|
|
3279
|
-
// Add outer sprites on top
|
|
3280
3578
|
if (typeof outer === 'string') {
|
|
3281
3579
|
result.push({ id: 'default', url: outer });
|
|
3282
3580
|
}
|
|
@@ -3289,8 +3587,74 @@ function mergeSprites(outer, imported) {
|
|
|
3289
3587
|
}
|
|
3290
3588
|
return result;
|
|
3291
3589
|
}
|
|
3590
|
+
/** Layer properties whose values name sprite images. */
|
|
3591
|
+
const IMAGE_PROPERTIES = [
|
|
3592
|
+
['layout', 'icon-image'],
|
|
3593
|
+
['paint', 'fill-pattern'],
|
|
3594
|
+
['paint', 'line-pattern'],
|
|
3595
|
+
['paint', 'fill-extrusion-pattern'],
|
|
3596
|
+
['paint', 'background-pattern'],
|
|
3597
|
+
];
|
|
3598
|
+
function prefixLayerImages(layer, prefix) {
|
|
3599
|
+
for (const [group, property] of IMAGE_PROPERTIES) {
|
|
3600
|
+
const props = layer[group];
|
|
3601
|
+
if (props && property in props) {
|
|
3602
|
+
props[property] = prefixImageValue(props[property], prefix);
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
/**
|
|
3607
|
+
* Prefix the image name(s) a property value resolves to. Handles plain names
|
|
3608
|
+
* (including `{token}` templates), legacy `stops` functions, and expressions:
|
|
3609
|
+
* `image` / `literal` directly, the output branches of `coalesce` / `case` /
|
|
3610
|
+
* `match` / `step`, and anything else string-valued via `concat`.
|
|
3611
|
+
*/
|
|
3612
|
+
function prefixImageValue(value, prefix) {
|
|
3613
|
+
if (typeof value === 'string') {
|
|
3614
|
+
return value === '' ? value : `${prefix}${value}`;
|
|
3615
|
+
}
|
|
3616
|
+
if (Array.isArray(value)) {
|
|
3617
|
+
if (value.length === 0 || typeof value[0] !== 'string')
|
|
3618
|
+
return value;
|
|
3619
|
+
const [op, ...args] = value;
|
|
3620
|
+
const mapAt = (isOutput) => value.map((arg, i) => (i > 0 && isOutput(i) ? prefixImageValue(arg, prefix) : arg));
|
|
3621
|
+
const lastIndex = value.length - 1;
|
|
3622
|
+
switch (op) {
|
|
3623
|
+
case 'image':
|
|
3624
|
+
return ['image', prefixImageValue(args[0], prefix), ...args.slice(1)];
|
|
3625
|
+
case 'literal':
|
|
3626
|
+
return typeof args[0] === 'string' ? ['literal', `${prefix}${args[0]}`] : value;
|
|
3627
|
+
case 'coalesce':
|
|
3628
|
+
return mapAt(() => true);
|
|
3629
|
+
case 'case':
|
|
3630
|
+
// ["case", cond, out, cond, out, ..., fallback]
|
|
3631
|
+
return mapAt(i => i % 2 === 0 || i === lastIndex);
|
|
3632
|
+
case 'match':
|
|
3633
|
+
// ["match", input, label, out, label, out, ..., fallback]
|
|
3634
|
+
return mapAt(i => (i >= 3 && i % 2 === 1) || i === lastIndex);
|
|
3635
|
+
case 'step':
|
|
3636
|
+
// ["step", input, out0, stop, out, ...]
|
|
3637
|
+
return mapAt(i => i >= 2 && i % 2 === 0);
|
|
3638
|
+
default:
|
|
3639
|
+
return ['concat', prefix, value];
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
if (value && typeof value === 'object') {
|
|
3643
|
+
const fn = value;
|
|
3644
|
+
if (Array.isArray(fn.stops)) {
|
|
3645
|
+
return {
|
|
3646
|
+
...fn,
|
|
3647
|
+
stops: fn.stops.map(stop => Array.isArray(stop) ? [stop[0], prefixImageValue(stop[1], prefix)] : stop),
|
|
3648
|
+
...('default' in fn
|
|
3649
|
+
? { default: prefixImageValue(fn.default, prefix) }
|
|
3650
|
+
: {}),
|
|
3651
|
+
};
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
return value;
|
|
3655
|
+
}
|
|
3292
3656
|
|
|
3293
|
-
const DEFAULT_WASM_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.
|
|
3657
|
+
const DEFAULT_WASM_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.15.2/dist/';
|
|
3294
3658
|
let currentConfig = {};
|
|
3295
3659
|
let sqlJsPromise = null;
|
|
3296
3660
|
/**
|
|
@@ -3302,15 +3666,42 @@ function configureSqlJs(config) {
|
|
|
3302
3666
|
sqlJsPromise = null;
|
|
3303
3667
|
}
|
|
3304
3668
|
/**
|
|
3305
|
-
*
|
|
3306
|
-
*
|
|
3669
|
+
* Resolve the `initSqlJs` factory.
|
|
3670
|
+
*
|
|
3671
|
+
* Bundler builds resolve the npm package via dynamic `import()`, so `sql.js`
|
|
3672
|
+
* only ships with bundles that actually call MBTiles code. The UMD/CDN build
|
|
3673
|
+
* has no module resolver, so the import throws there — fall back to the
|
|
3674
|
+
* `initSqlJs` global that sql.js's own `<script>` build defines.
|
|
3675
|
+
*/
|
|
3676
|
+
async function resolveInitSqlJs() {
|
|
3677
|
+
let importError;
|
|
3678
|
+
try {
|
|
3679
|
+
const mod = (await import('sql.js'));
|
|
3680
|
+
if (typeof mod?.default === 'function')
|
|
3681
|
+
return mod.default;
|
|
3682
|
+
}
|
|
3683
|
+
catch (error) {
|
|
3684
|
+
// Bare specifier not resolvable (UMD/CDN), or the module threw while
|
|
3685
|
+
// initialising. Either way, try the global before giving up.
|
|
3686
|
+
importError = error;
|
|
3687
|
+
}
|
|
3688
|
+
const globalInit = globalThis.initSqlJs;
|
|
3689
|
+
if (typeof globalInit === 'function')
|
|
3690
|
+
return globalInit;
|
|
3691
|
+
// Preserve the original failure — a module that resolved but threw is a
|
|
3692
|
+
// very different problem from one that couldn't be resolved at all.
|
|
3693
|
+
throw new Error('sql.js could not be loaded. Install `sql.js` when bundling, or load ' +
|
|
3694
|
+
'https://cdn.jsdelivr.net/npm/sql.js/dist/sql-wasm.js before map-gl-offline ' +
|
|
3695
|
+
'when using the UMD build.', importError !== undefined ? { cause: importError } : undefined);
|
|
3696
|
+
}
|
|
3697
|
+
/**
|
|
3698
|
+
* Lazily initialise `sql.js`.
|
|
3307
3699
|
*/
|
|
3308
3700
|
async function getSqlJs() {
|
|
3309
3701
|
if (sqlJsPromise)
|
|
3310
3702
|
return sqlJsPromise;
|
|
3311
3703
|
sqlJsPromise = (async () => {
|
|
3312
|
-
const
|
|
3313
|
-
const initSqlJs = mod.default;
|
|
3704
|
+
const initSqlJs = await resolveInitSqlJs();
|
|
3314
3705
|
const options = {};
|
|
3315
3706
|
if (currentConfig.wasmBinary) {
|
|
3316
3707
|
options.wasmBinary = currentConfig.wasmBinary;
|
|
@@ -3324,91 +3715,575 @@ async function getSqlJs() {
|
|
|
3324
3715
|
return sqlJsPromise;
|
|
3325
3716
|
}
|
|
3326
3717
|
|
|
3327
|
-
const
|
|
3328
|
-
|
|
3718
|
+
const modelLogger = logger.scope('ModelService');
|
|
3719
|
+
/**
|
|
3720
|
+
* Build the storage key for a model. Kept consistent with sprite/glyph
|
|
3721
|
+
* conventions: `{styleId}::model::{modelName}`.
|
|
3722
|
+
*/
|
|
3723
|
+
function modelKey(styleId, modelName) {
|
|
3724
|
+
return `${styleId}::model::${modelName}`;
|
|
3725
|
+
}
|
|
3726
|
+
/** Names of the models stored for a style. */
|
|
3727
|
+
async function storedModelNames(styleId) {
|
|
3728
|
+
const db = await dbPromise;
|
|
3729
|
+
const prefix = modelKey(styleId, '');
|
|
3730
|
+
const keys = await db.getAllKeys('models', IDBKeyRange.bound(prefix, `${prefix}`));
|
|
3731
|
+
return new Set(keys.map(key => String(key).slice(prefix.length)));
|
|
3732
|
+
}
|
|
3733
|
+
/** True when the given key belongs to the given styleId's model store prefix. */
|
|
3734
|
+
function modelKeyBelongsToStyle(key, styleId) {
|
|
3735
|
+
return key.startsWith(`${styleId}::model::`);
|
|
3736
|
+
}
|
|
3737
|
+
/**
|
|
3738
|
+
* Service for downloading, storing, and serving Mapbox 3D model (.glb) files.
|
|
3739
|
+
*
|
|
3740
|
+
* Mapbox Standard declares 32 models at the top of `style.models`:
|
|
3741
|
+
*
|
|
3742
|
+
* ```json
|
|
3743
|
+
* {
|
|
3744
|
+
* "maple1-lod1": "mapbox://models/mapbox/maple1-v4-lod1.glb",
|
|
3745
|
+
* ...
|
|
3746
|
+
* }
|
|
3747
|
+
* ```
|
|
3748
|
+
*
|
|
3749
|
+
* `model` layers (e.g. `trees`, `wind-turbine-towers`) pick one by name at
|
|
3750
|
+
* render time. For offline use each referenced URL is fetched and stored
|
|
3751
|
+
* here, and `patchStyleForOffline` rewrites the dictionary entries to
|
|
3752
|
+
* `idb://{styleId}/model/{name}` URLs.
|
|
3753
|
+
*/
|
|
3754
|
+
class ModelService {
|
|
3329
3755
|
db = dbPromise;
|
|
3330
|
-
|
|
3756
|
+
/**
|
|
3757
|
+
* Download the set of models referenced by `style.models` for one style.
|
|
3758
|
+
*
|
|
3759
|
+
* @param models `{ modelName: resolvedHttpUrl }` — URLs must already be
|
|
3760
|
+
* resolved (mapbox:// URLs should be resolved by the caller).
|
|
3761
|
+
* @param styleId The owning style's key.
|
|
3762
|
+
*/
|
|
3763
|
+
async downloadModels(models, styleId, options = {}) {
|
|
3331
3764
|
const db = await this.db;
|
|
3332
|
-
const { batchSize =
|
|
3333
|
-
const
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3765
|
+
const { onProgress, batchSize = 4, maxRetries = 3, skipExisting = true, timeoutMs = 30000, } = options;
|
|
3766
|
+
const entries = Object.entries(models);
|
|
3767
|
+
const progressTracker = createProgressTracker(entries.length);
|
|
3768
|
+
const result = {
|
|
3769
|
+
totalModels: entries.length,
|
|
3770
|
+
downloadedModels: 0,
|
|
3771
|
+
skippedModels: 0,
|
|
3772
|
+
failedModels: 0,
|
|
3773
|
+
totalSize: 0,
|
|
3774
|
+
errors: [],
|
|
3775
|
+
};
|
|
3776
|
+
const emit = () => onProgress?.(progressTracker.getProgress());
|
|
3777
|
+
emit();
|
|
3778
|
+
if (entries.length === 0)
|
|
3779
|
+
return result;
|
|
3780
|
+
// Pre-compute existing keys for skipExisting
|
|
3781
|
+
const existingKeys = new Set();
|
|
3344
3782
|
if (skipExisting) {
|
|
3345
|
-
const
|
|
3346
|
-
const
|
|
3347
|
-
|
|
3348
|
-
while (cursor) {
|
|
3349
|
-
existingFonts.add(cursor.value.key);
|
|
3350
|
-
cursor = await cursor.continue();
|
|
3783
|
+
const tx = db.transaction('models', 'readonly');
|
|
3784
|
+
for await (const cursor of tx.store) {
|
|
3785
|
+
existingKeys.add(cursor.value.key);
|
|
3351
3786
|
}
|
|
3352
|
-
urlsToDownload = fontUrls.filter(url => {
|
|
3353
|
-
const key = this.createFontKey(url, styleName);
|
|
3354
|
-
return !existingFonts.has(key);
|
|
3355
|
-
});
|
|
3356
|
-
skippedFonts = fontUrls.length - urlsToDownload.length;
|
|
3357
3787
|
}
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
const
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3788
|
+
await processBatch(entries, async ([modelName, url]) => {
|
|
3789
|
+
const key = modelKey(styleId, modelName);
|
|
3790
|
+
const label = `${styleId}::${modelName}`;
|
|
3791
|
+
if (skipExisting && existingKeys.has(key)) {
|
|
3792
|
+
result.skippedModels++;
|
|
3793
|
+
progressTracker.update(1, label);
|
|
3794
|
+
emit();
|
|
3795
|
+
return;
|
|
3366
3796
|
}
|
|
3367
|
-
}
|
|
3368
|
-
// Process fonts in batches with concurrency control
|
|
3369
|
-
await processBatch(urlsToDownload, async (fontUrl) => {
|
|
3370
3797
|
try {
|
|
3371
|
-
const
|
|
3372
|
-
const response = await fetchResourceWithRetry(fontUrl, {
|
|
3798
|
+
const response = await fetchResourceWithRetry(url, {
|
|
3373
3799
|
retries: maxRetries,
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
proxyType: 'fonts',
|
|
3800
|
+
timeout: timeoutMs,
|
|
3801
|
+
proxyType: 'tiles',
|
|
3377
3802
|
});
|
|
3378
3803
|
if (response.type === 'json') {
|
|
3379
|
-
throw new Error(
|
|
3380
|
-
}
|
|
3381
|
-
const fontData = response.data;
|
|
3382
|
-
const contentType = response.contentType ||
|
|
3383
|
-
(response.type === 'other' ? 'font/opentype' : `font/${response.type}`);
|
|
3384
|
-
// Validate font if enabled
|
|
3385
|
-
if (validateFonts) {
|
|
3386
|
-
await this.validateFont(fontData, contentType);
|
|
3804
|
+
throw new Error('Unexpected JSON response for model');
|
|
3387
3805
|
}
|
|
3388
|
-
|
|
3389
|
-
const
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
data: fontData,
|
|
3806
|
+
const data = response.data;
|
|
3807
|
+
const contentType = ('contentType' in response && response.contentType) || 'model/gltf-binary';
|
|
3808
|
+
const entry = {
|
|
3809
|
+
key,
|
|
3810
|
+
data,
|
|
3394
3811
|
contentType,
|
|
3395
|
-
|
|
3396
|
-
|
|
3812
|
+
size: data.byteLength,
|
|
3813
|
+
url,
|
|
3814
|
+
styleId,
|
|
3815
|
+
modelName,
|
|
3397
3816
|
lastModified: Date.now(),
|
|
3398
3817
|
downloadedAt: new Date().toISOString(),
|
|
3399
|
-
downloadId: styleName,
|
|
3400
|
-
metadata: {
|
|
3401
|
-
userAgent: navigator.userAgent,
|
|
3402
|
-
downloadTimestamp: Date.now(),
|
|
3403
|
-
},
|
|
3404
3818
|
expires: response.expires,
|
|
3405
3819
|
};
|
|
3406
|
-
await db.put('
|
|
3407
|
-
|
|
3408
|
-
totalSize +=
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3820
|
+
await db.put('models', entry);
|
|
3821
|
+
result.downloadedModels++;
|
|
3822
|
+
result.totalSize += data.byteLength;
|
|
3823
|
+
progressTracker.update(1, label);
|
|
3824
|
+
}
|
|
3825
|
+
catch (err) {
|
|
3826
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3827
|
+
result.failedModels++;
|
|
3828
|
+
result.errors.push({ url, error: message });
|
|
3829
|
+
modelLogger.warn(`Failed to download model "${modelName}" from ${url}:`, err);
|
|
3830
|
+
progressTracker.update(1, label, message);
|
|
3831
|
+
}
|
|
3832
|
+
emit();
|
|
3833
|
+
}, { batchSize });
|
|
3834
|
+
modelLogger.info(`Models downloaded for style ${styleId}: ${result.downloadedModels} new, ${result.skippedModels} skipped, ${result.failedModels} failed`);
|
|
3835
|
+
return result;
|
|
3836
|
+
}
|
|
3837
|
+
/** Retrieve a single model by `{styleId, modelName}`. */
|
|
3838
|
+
async getModel(styleId, modelName) {
|
|
3839
|
+
const db = await this.db;
|
|
3840
|
+
return db.get('models', modelKey(styleId, modelName));
|
|
3841
|
+
}
|
|
3842
|
+
/** Aggregate stats across all stored models. */
|
|
3843
|
+
async getModelStats() {
|
|
3844
|
+
const db = await this.db;
|
|
3845
|
+
const stats = {
|
|
3846
|
+
count: 0,
|
|
3847
|
+
totalSize: 0,
|
|
3848
|
+
averageSize: 0,
|
|
3849
|
+
models: [],
|
|
3850
|
+
modelsByStyle: {},
|
|
3851
|
+
};
|
|
3852
|
+
const tx = db.transaction('models', 'readonly');
|
|
3853
|
+
for await (const cursor of tx.store) {
|
|
3854
|
+
const m = cursor.value;
|
|
3855
|
+
stats.count++;
|
|
3856
|
+
stats.totalSize += m.size;
|
|
3857
|
+
stats.models.push({ name: m.modelName, size: m.size, lastModified: m.lastModified });
|
|
3858
|
+
stats.modelsByStyle[m.styleId] = (stats.modelsByStyle[m.styleId] ?? 0) + 1;
|
|
3859
|
+
}
|
|
3860
|
+
stats.averageSize = stats.count > 0 ? stats.totalSize / stats.count : 0;
|
|
3861
|
+
return stats;
|
|
3862
|
+
}
|
|
3863
|
+
/**
|
|
3864
|
+
* Delete models older than `maxAge` days. Defaults to 30.
|
|
3865
|
+
*/
|
|
3866
|
+
async cleanupOldModels(maxAge = 30) {
|
|
3867
|
+
const db = await this.db;
|
|
3868
|
+
const cutoff = Date.now() - maxAge * 24 * 60 * 60 * 1000;
|
|
3869
|
+
let deleted = 0;
|
|
3870
|
+
const tx = db.transaction('models', 'readwrite');
|
|
3871
|
+
for await (const cursor of tx.store) {
|
|
3872
|
+
if (cursor.value.lastModified < cutoff) {
|
|
3873
|
+
await cursor.delete();
|
|
3874
|
+
deleted++;
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
return deleted;
|
|
3878
|
+
}
|
|
3879
|
+
/**
|
|
3880
|
+
* Basic integrity check: remove entries with empty/missing data.
|
|
3881
|
+
*/
|
|
3882
|
+
async verifyAndRepairModels() {
|
|
3883
|
+
const db = await this.db;
|
|
3884
|
+
let verified = 0;
|
|
3885
|
+
let removed = 0;
|
|
3886
|
+
const tx = db.transaction('models', 'readwrite');
|
|
3887
|
+
for await (const cursor of tx.store) {
|
|
3888
|
+
const m = cursor.value;
|
|
3889
|
+
if (!m.data || m.data.byteLength === 0) {
|
|
3890
|
+
await cursor.delete();
|
|
3891
|
+
removed++;
|
|
3892
|
+
}
|
|
3893
|
+
else {
|
|
3894
|
+
verified++;
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
return { verified, repaired: 0, removed };
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
// Singleton + convenience exports, matching other service modules.
|
|
3901
|
+
const modelService = new ModelService();
|
|
3902
|
+
const downloadModels = (models, styleId, options) => modelService.downloadModels(models, styleId, options);
|
|
3903
|
+
const getModel = (styleId, modelName) => modelService.getModel(styleId, modelName);
|
|
3904
|
+
const getModelStats = () => modelService.getModelStats();
|
|
3905
|
+
const cleanupOldModels = (maxAge) => modelService.cleanupOldModels(maxAge);
|
|
3906
|
+
const verifyAndRepairModels = () => modelService.verifyAndRepairModels();
|
|
3907
|
+
|
|
3908
|
+
var modelService$1 = /*#__PURE__*/Object.freeze({
|
|
3909
|
+
__proto__: null,
|
|
3910
|
+
ModelService: ModelService,
|
|
3911
|
+
cleanupOldModels: cleanupOldModels,
|
|
3912
|
+
downloadModels: downloadModels,
|
|
3913
|
+
getModel: getModel,
|
|
3914
|
+
getModelStats: getModelStats,
|
|
3915
|
+
modelKeyBelongsToStyle: modelKeyBelongsToStyle,
|
|
3916
|
+
modelService: modelService,
|
|
3917
|
+
storedModelNames: storedModelNames,
|
|
3918
|
+
verifyAndRepairModels: verifyAndRepairModels
|
|
3919
|
+
});
|
|
3920
|
+
|
|
3921
|
+
/**
|
|
3922
|
+
* Mapbox GL v3 style-config helpers.
|
|
3923
|
+
*
|
|
3924
|
+
* Mapbox Standard is one style with switchable features: most of its layers set
|
|
3925
|
+
* `layout.visibility` from `["config", key]` expressions backed by `style.schema`
|
|
3926
|
+
* defaults. These helpers work out which sources the style actually needs for
|
|
3927
|
+
* the current config, so features that are switched off aren't downloaded and
|
|
3928
|
+
* features that are switched on aren't skipped.
|
|
3929
|
+
*/
|
|
3930
|
+
const configLogger = logger.scope('StyleConfig');
|
|
3931
|
+
/** Sentinel for an expression that can't be decided from config alone. */
|
|
3932
|
+
const UNKNOWN = Symbol('unknown');
|
|
3933
|
+
/**
|
|
3934
|
+
* True for styles whose feature set is config-driven: they declare a `schema`,
|
|
3935
|
+
* or were flattened from `imports` (whose config is already resolved into the
|
|
3936
|
+
* layers). Other styles are never filtered by config.
|
|
3937
|
+
*/
|
|
3938
|
+
function isConfigDrivenStyle(style) {
|
|
3939
|
+
const s = style;
|
|
3940
|
+
if (!s)
|
|
3941
|
+
return false;
|
|
3942
|
+
return ((s.schema !== undefined && Object.keys(s.schema).length > 0) ||
|
|
3943
|
+
(Array.isArray(s.imports) && s.imports.length > 0));
|
|
3944
|
+
}
|
|
3945
|
+
function evaluate(expr, config) {
|
|
3946
|
+
if (!Array.isArray(expr))
|
|
3947
|
+
return expr;
|
|
3948
|
+
const [op, ...args] = expr;
|
|
3949
|
+
switch (op) {
|
|
3950
|
+
case 'literal':
|
|
3951
|
+
return args[0];
|
|
3952
|
+
case 'config': {
|
|
3953
|
+
const key = args[0];
|
|
3954
|
+
return typeof key === 'string' && key in config ? config[key] : UNKNOWN;
|
|
3955
|
+
}
|
|
3956
|
+
case '!': {
|
|
3957
|
+
const value = evaluate(args[0], config);
|
|
3958
|
+
return typeof value === 'boolean' ? !value : UNKNOWN;
|
|
3959
|
+
}
|
|
3960
|
+
case 'all':
|
|
3961
|
+
case 'any': {
|
|
3962
|
+
const decisive = op === 'any';
|
|
3963
|
+
let sawUnknown = false;
|
|
3964
|
+
for (const arg of args) {
|
|
3965
|
+
const value = evaluate(arg, config);
|
|
3966
|
+
if (value === decisive)
|
|
3967
|
+
return decisive;
|
|
3968
|
+
if (typeof value !== 'boolean')
|
|
3969
|
+
sawUnknown = true;
|
|
3970
|
+
}
|
|
3971
|
+
return sawUnknown ? UNKNOWN : !decisive;
|
|
3972
|
+
}
|
|
3973
|
+
case '==':
|
|
3974
|
+
case '!=': {
|
|
3975
|
+
const a = evaluate(args[0], config);
|
|
3976
|
+
const b = evaluate(args[1], config);
|
|
3977
|
+
if (a === UNKNOWN || b === UNKNOWN)
|
|
3978
|
+
return UNKNOWN;
|
|
3979
|
+
return op === '==' ? a === b : a !== b;
|
|
3980
|
+
}
|
|
3981
|
+
case 'case': {
|
|
3982
|
+
// ["case", cond, out, cond, out, ..., fallback]
|
|
3983
|
+
for (let i = 0; i < args.length - 1; i += 2) {
|
|
3984
|
+
const condition = evaluate(args[i], config);
|
|
3985
|
+
if (typeof condition !== 'boolean')
|
|
3986
|
+
return UNKNOWN;
|
|
3987
|
+
if (condition)
|
|
3988
|
+
return evaluate(args[i + 1], config);
|
|
3989
|
+
}
|
|
3990
|
+
return evaluate(args[args.length - 1], config);
|
|
3991
|
+
}
|
|
3992
|
+
case 'match': {
|
|
3993
|
+
// ["match", input, label, out, label, out, ..., fallback]
|
|
3994
|
+
const input = evaluate(args[0], config);
|
|
3995
|
+
if (input === UNKNOWN)
|
|
3996
|
+
return UNKNOWN;
|
|
3997
|
+
for (let i = 1; i < args.length - 1; i += 2) {
|
|
3998
|
+
const label = args[i];
|
|
3999
|
+
if (Array.isArray(label) ? label.includes(input) : label === input) {
|
|
4000
|
+
return evaluate(args[i + 1], config);
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
return evaluate(args[args.length - 1], config);
|
|
4004
|
+
}
|
|
4005
|
+
default:
|
|
4006
|
+
return UNKNOWN;
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
function collectIconsetIds(value, ids) {
|
|
4010
|
+
if (Array.isArray(value)) {
|
|
4011
|
+
for (const item of value)
|
|
4012
|
+
collectIconsetIds(item, ids);
|
|
4013
|
+
}
|
|
4014
|
+
else if (value && typeof value === 'object') {
|
|
4015
|
+
const record = value;
|
|
4016
|
+
const iconset = record.iconset;
|
|
4017
|
+
if (iconset && typeof iconset.id === 'string')
|
|
4018
|
+
ids.add(iconset.id);
|
|
4019
|
+
for (const item of Object.values(record))
|
|
4020
|
+
collectIconsetIds(item, ids);
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
4023
|
+
/**
|
|
4024
|
+
* Sources a config-driven style uses under its current config (`active`), and
|
|
4025
|
+
* those it references but only from hidden layers or unused iconsets (`inactive`).
|
|
4026
|
+
*
|
|
4027
|
+
* Deliberately errs towards keeping sources: a visibility expression that can't
|
|
4028
|
+
* be decided counts as visible. Sources nothing in the style references
|
|
4029
|
+
* (`extraSources`, ones a caller adds at runtime) are in neither set.
|
|
4030
|
+
*/
|
|
4031
|
+
function classifySources(style) {
|
|
4032
|
+
const inactive = new Set();
|
|
4033
|
+
const active = new Set();
|
|
4034
|
+
if (!isConfigDrivenStyle(style))
|
|
4035
|
+
return { active, inactive };
|
|
4036
|
+
const s = style;
|
|
4037
|
+
const config = {};
|
|
4038
|
+
for (const [key, entry] of Object.entries(s.schema ?? {})) {
|
|
4039
|
+
if (entry && typeof entry === 'object' && 'default' in entry)
|
|
4040
|
+
config[key] = entry.default;
|
|
4041
|
+
}
|
|
4042
|
+
const activeIconsets = new Set();
|
|
4043
|
+
for (const layer of (s.layers ?? [])) {
|
|
4044
|
+
const visible = evaluate(layer?.layout?.visibility ?? 'visible', config) !== 'none';
|
|
4045
|
+
if (typeof layer?.source === 'string') {
|
|
4046
|
+
inactive.add(layer.source);
|
|
4047
|
+
if (visible)
|
|
4048
|
+
active.add(layer.source);
|
|
4049
|
+
}
|
|
4050
|
+
if (visible) {
|
|
4051
|
+
// The whole layer, since `appearances` can also name an iconset.
|
|
4052
|
+
collectIconsetIds(layer, activeIconsets);
|
|
4053
|
+
}
|
|
4054
|
+
}
|
|
4055
|
+
for (const [id, iconset] of Object.entries(s.iconsets ?? {})) {
|
|
4056
|
+
if (typeof iconset?.source !== 'string')
|
|
4057
|
+
continue;
|
|
4058
|
+
inactive.add(iconset.source);
|
|
4059
|
+
if (activeIconsets.has(id))
|
|
4060
|
+
active.add(iconset.source);
|
|
4061
|
+
}
|
|
4062
|
+
if (typeof s.terrain?.source === 'string')
|
|
4063
|
+
active.add(s.terrain.source);
|
|
4064
|
+
for (const id of active)
|
|
4065
|
+
inactive.delete(id);
|
|
4066
|
+
return { active, inactive };
|
|
4067
|
+
}
|
|
4068
|
+
/**
|
|
4069
|
+
* Write config values into a style's `schema` defaults. `["config", key]` reads
|
|
4070
|
+
* those defaults at render time, so this one edit drives both what gets
|
|
4071
|
+
* downloaded and what the stored style shows offline. Keys the schema doesn't
|
|
4072
|
+
* declare are ignored with a warning.
|
|
4073
|
+
*/
|
|
4074
|
+
function applyStyleConfig(style, config) {
|
|
4075
|
+
const schema = style.schema;
|
|
4076
|
+
for (const [key, value] of Object.entries(config)) {
|
|
4077
|
+
const entry = schema?.[key];
|
|
4078
|
+
if (entry && typeof entry === 'object') {
|
|
4079
|
+
entry.default = value;
|
|
4080
|
+
}
|
|
4081
|
+
else {
|
|
4082
|
+
configLogger.warn(`Ignoring styleConfig "${key}": the style's schema doesn't declare it`);
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
|
|
4087
|
+
/**
|
|
4088
|
+
* XYZ tile range covering `bounds` at zoom `z`. Shared by the download
|
|
4089
|
+
* pipeline (which fetches exactly this range) and region-scoped consumers
|
|
4090
|
+
* like MBTiles export, so both agree on which tiles belong to a region.
|
|
4091
|
+
*/
|
|
4092
|
+
function getTileRangeAtZoom(bounds, z) {
|
|
4093
|
+
const minTile = pointToTileClamped(bounds[0][0], bounds[0][1], z);
|
|
4094
|
+
const maxTile = pointToTileClamped(bounds[1][0], bounds[1][1], z);
|
|
4095
|
+
return {
|
|
4096
|
+
minX: Math.min(minTile[0], maxTile[0]),
|
|
4097
|
+
maxX: Math.max(minTile[0], maxTile[0]),
|
|
4098
|
+
minY: Math.min(minTile[1], maxTile[1]),
|
|
4099
|
+
maxY: Math.max(minTile[1], maxTile[1]),
|
|
4100
|
+
};
|
|
4101
|
+
}
|
|
4102
|
+
/** Web Mercator's latitude limit; tiles don't exist beyond it. */
|
|
4103
|
+
const MAX_MERCATOR_LAT = 85.0511287798066;
|
|
4104
|
+
/**
|
|
4105
|
+
* `tilebelt.pointToTile`, kept on the tile grid. tilebelt wraps longitude 180
|
|
4106
|
+
* to x = 0, so a region whose east edge is the antimeridian became the range
|
|
4107
|
+
* 0..maxX — nearly the whole world at every zoom. Latitudes beyond the
|
|
4108
|
+
* Mercator limit likewise fall off the grid.
|
|
4109
|
+
*/
|
|
4110
|
+
function pointToTileClamped(lon, lat, z) {
|
|
4111
|
+
const maxIndex = 2 ** z - 1;
|
|
4112
|
+
const clampedLon = Math.max(-180, Math.min(180, lon));
|
|
4113
|
+
const clampedLat = Math.max(-MAX_MERCATOR_LAT, Math.min(MAX_MERCATOR_LAT, lat));
|
|
4114
|
+
const [x, y] = tilebelt__namespace.pointToTile(clampedLon, clampedLat, z);
|
|
4115
|
+
return [
|
|
4116
|
+
clampedLon === 180 ? maxIndex : Math.max(0, Math.min(maxIndex, x)),
|
|
4117
|
+
Math.max(0, Math.min(maxIndex, y)),
|
|
4118
|
+
];
|
|
4119
|
+
}
|
|
4120
|
+
/**
|
|
4121
|
+
* Whether tile `z/x/y` falls inside `bounds` and `[minZoom, maxZoom]`.
|
|
4122
|
+
*/
|
|
4123
|
+
function isTileInRegion(z, x, y, region) {
|
|
4124
|
+
if (z < region.minZoom || z > region.maxZoom)
|
|
4125
|
+
return false;
|
|
4126
|
+
const { minX, maxX, minY, maxY } = getTileRangeAtZoom(region.bounds, z);
|
|
4127
|
+
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
|
4128
|
+
}
|
|
4129
|
+
/**
|
|
4130
|
+
* Zoom levels to download for one source in `region`: the overlap of the
|
|
4131
|
+
* region's and the source's zoom ranges. When they don't overlap, the source
|
|
4132
|
+
* still gets the zooms the map needs from it:
|
|
4133
|
+
* - source ends below the region (`maxzoom` < region min) → its `maxzoom`,
|
|
4134
|
+
* which the renderer overzooms for the region's zooms;
|
|
4135
|
+
* - source starts above the region (`minzoom` > region max) → `minzoom`
|
|
4136
|
+
* through `maxzoom` (or just `minzoom` if unbounded).
|
|
4137
|
+
*/
|
|
4138
|
+
function sourceZoomRange(region, sourceMinZoom, sourceMaxZoom) {
|
|
4139
|
+
const srcMin = sourceMinZoom !== undefined ? Math.ceil(sourceMinZoom) : undefined;
|
|
4140
|
+
const srcMax = sourceMaxZoom !== undefined ? Math.floor(sourceMaxZoom) : undefined;
|
|
4141
|
+
if (srcMax !== undefined && srcMax < region.minZoom) {
|
|
4142
|
+
return { min: srcMax, max: srcMax };
|
|
4143
|
+
}
|
|
4144
|
+
if (srcMin !== undefined && srcMin > region.maxZoom) {
|
|
4145
|
+
return { min: srcMin, max: srcMax ?? srcMin };
|
|
4146
|
+
}
|
|
4147
|
+
return {
|
|
4148
|
+
min: Math.max(region.minZoom, srcMin ?? region.minZoom),
|
|
4149
|
+
max: Math.min(region.maxZoom, srcMax ?? region.maxZoom),
|
|
4150
|
+
};
|
|
4151
|
+
}
|
|
4152
|
+
/**
|
|
4153
|
+
* A stored source's upstream `maxzoom`. Patched styles cap `maxzoom` for
|
|
4154
|
+
* rendering and stash the upstream value under `__originalMaxzoom`.
|
|
4155
|
+
*/
|
|
4156
|
+
function upstreamMaxzoom(source) {
|
|
4157
|
+
if (source?.__originalMaxzoom !== undefined)
|
|
4158
|
+
return source.__originalMaxzoom ?? undefined;
|
|
4159
|
+
return source?.maxzoom;
|
|
4160
|
+
}
|
|
4161
|
+
function hasValidBounds(region) {
|
|
4162
|
+
const b = region.bounds;
|
|
4163
|
+
return (Array.isArray(b) &&
|
|
4164
|
+
b.length === 2 &&
|
|
4165
|
+
b.every(corner => Array.isArray(corner) && corner.length === 2));
|
|
4166
|
+
}
|
|
4167
|
+
/**
|
|
4168
|
+
* Whether a stored tile was downloaded for `region`: inside its bounds, at a
|
|
4169
|
+
* zoom the download pipeline fetches for the tile's source (which can fall
|
|
4170
|
+
* outside the region's own range — see `sourceZoomRange`). `sources` is the
|
|
4171
|
+
* stored style's `sources`. Regions with malformed bounds match nothing.
|
|
4172
|
+
*/
|
|
4173
|
+
function tileBelongsToRegion(tile, region, sources) {
|
|
4174
|
+
if (!hasValidBounds(region))
|
|
4175
|
+
return false;
|
|
4176
|
+
const source = tile.sourceId !== undefined ? sources[tile.sourceId] : undefined;
|
|
4177
|
+
const { min, max } = sourceZoomRange(region, source?.minzoom, upstreamMaxzoom(source));
|
|
4178
|
+
return isTileInRegion(tile.z, tile.x, tile.y, {
|
|
4179
|
+
bounds: region.bounds,
|
|
4180
|
+
minZoom: min,
|
|
4181
|
+
maxZoom: max,
|
|
4182
|
+
});
|
|
4183
|
+
}
|
|
4184
|
+
|
|
4185
|
+
const fontLogger = logger.scope('FontService');
|
|
4186
|
+
class FontService {
|
|
4187
|
+
db = dbPromise;
|
|
4188
|
+
async downloadFonts(fontUrls, styleName, options = {}) {
|
|
4189
|
+
const db = await this.db;
|
|
4190
|
+
const { batchSize = 10, maxRetries = 3, skipExisting = true, retryDelay = 1000, timeout = 30000, validateFonts = true, storageQuotaCheck = true, quietMode = false, onProgress, } = options;
|
|
4191
|
+
const startTime = Date.now();
|
|
4192
|
+
let totalSize = 0;
|
|
4193
|
+
let downloadedFonts = 0;
|
|
4194
|
+
let skippedFonts = 0;
|
|
4195
|
+
let failedFonts = 0;
|
|
4196
|
+
const errors = [];
|
|
4197
|
+
const fontsByType = {};
|
|
4198
|
+
// Create progress tracker
|
|
4199
|
+
const progressTracker = createProgressTracker(fontUrls.length);
|
|
4200
|
+
const reportProgress = (message) => {
|
|
4201
|
+
if (!onProgress)
|
|
4202
|
+
return;
|
|
4203
|
+
const progress = progressTracker.getProgress();
|
|
4204
|
+
onProgress({
|
|
4205
|
+
completed: progress.completed,
|
|
4206
|
+
total: progress.total,
|
|
4207
|
+
percentage: progress.percentage,
|
|
4208
|
+
message,
|
|
4209
|
+
errors: errors.map(e => e.error),
|
|
4210
|
+
});
|
|
4211
|
+
};
|
|
4212
|
+
// Filter existing fonts if skipExisting is true
|
|
4213
|
+
let urlsToDownload = fontUrls;
|
|
4214
|
+
if (skipExisting) {
|
|
4215
|
+
const existingFonts = new Set();
|
|
4216
|
+
const tx = db.transaction(['fonts'], 'readonly');
|
|
4217
|
+
let cursor = await tx.objectStore('fonts').openCursor();
|
|
4218
|
+
while (cursor) {
|
|
4219
|
+
existingFonts.add(cursor.value.key);
|
|
4220
|
+
cursor = await cursor.continue();
|
|
4221
|
+
}
|
|
4222
|
+
urlsToDownload = fontUrls.filter(url => {
|
|
4223
|
+
const key = this.createFontKey(url, styleName);
|
|
4224
|
+
return !existingFonts.has(key);
|
|
4225
|
+
});
|
|
4226
|
+
skippedFonts = fontUrls.length - urlsToDownload.length;
|
|
4227
|
+
// Skipped fonts count toward the total, so mark them done up front.
|
|
4228
|
+
if (skippedFonts > 0) {
|
|
4229
|
+
progressTracker.update(skippedFonts);
|
|
4230
|
+
}
|
|
4231
|
+
}
|
|
4232
|
+
// Check storage quota if enabled
|
|
4233
|
+
if (storageQuotaCheck && 'storage' in navigator && 'estimate' in navigator.storage) {
|
|
4234
|
+
const estimate = await navigator.storage.estimate();
|
|
4235
|
+
const usedSpace = estimate.usage || 0;
|
|
4236
|
+
const availableSpace = (estimate.quota || 0) - usedSpace;
|
|
4237
|
+
if (availableSpace < 50 * 1024 * 1024) {
|
|
4238
|
+
// Less than 50MB
|
|
4239
|
+
throw new Error('Insufficient storage space for font download');
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
// Process fonts in batches with concurrency control
|
|
4243
|
+
await processBatch(urlsToDownload, async (fontUrl) => {
|
|
4244
|
+
try {
|
|
4245
|
+
const fontKey = this.createFontKey(fontUrl, styleName);
|
|
4246
|
+
const response = await fetchResourceWithRetry(fontUrl, {
|
|
4247
|
+
retries: maxRetries,
|
|
4248
|
+
retryDelay,
|
|
4249
|
+
timeout,
|
|
4250
|
+
proxyType: 'fonts',
|
|
4251
|
+
});
|
|
4252
|
+
if (response.type === 'json') {
|
|
4253
|
+
throw new Error(`Unexpected JSON response when fetching font: ${fontUrl}`);
|
|
4254
|
+
}
|
|
4255
|
+
const fontData = response.data;
|
|
4256
|
+
const contentType = response.contentType ||
|
|
4257
|
+
(response.type === 'other' ? 'font/opentype' : `font/${response.type}`);
|
|
4258
|
+
// Validate font if enabled
|
|
4259
|
+
if (validateFonts) {
|
|
4260
|
+
await this.validateFont(fontData, contentType);
|
|
4261
|
+
}
|
|
4262
|
+
// Store font in database
|
|
4263
|
+
const fontEntry = {
|
|
4264
|
+
key: fontKey,
|
|
4265
|
+
url: fontUrl,
|
|
4266
|
+
originalUrl: fontUrl,
|
|
4267
|
+
data: fontData,
|
|
4268
|
+
contentType,
|
|
4269
|
+
type: this.detectFontType(contentType, fontUrl),
|
|
4270
|
+
size: fontData.byteLength,
|
|
4271
|
+
lastModified: Date.now(),
|
|
4272
|
+
downloadedAt: new Date().toISOString(),
|
|
4273
|
+
downloadId: styleName,
|
|
4274
|
+
metadata: {
|
|
4275
|
+
userAgent: navigator.userAgent,
|
|
4276
|
+
downloadTimestamp: Date.now(),
|
|
4277
|
+
},
|
|
4278
|
+
expires: response.expires,
|
|
4279
|
+
};
|
|
4280
|
+
await db.put('fonts', fontEntry);
|
|
4281
|
+
progressTracker.update(1, fontKey);
|
|
4282
|
+
reportProgress(`Downloaded ${fontKey}`);
|
|
4283
|
+
totalSize += fontData.byteLength;
|
|
4284
|
+
downloadedFonts++;
|
|
4285
|
+
// Track font types
|
|
4286
|
+
const fontType = this.detectFontType(contentType, fontUrl);
|
|
3412
4287
|
fontsByType[fontType] = (fontsByType[fontType] || 0) + 1;
|
|
3413
4288
|
}
|
|
3414
4289
|
catch (error) {
|
|
@@ -3418,6 +4293,9 @@ class FontService {
|
|
|
3418
4293
|
url: fontUrl,
|
|
3419
4294
|
error: errorMessage,
|
|
3420
4295
|
});
|
|
4296
|
+
// A failed font is finished too; don't let the bar stall on it.
|
|
4297
|
+
progressTracker.update(1, fontUrl);
|
|
4298
|
+
reportProgress(`Failed: ${fontUrl}`);
|
|
3421
4299
|
// Only log detailed errors if not in quiet mode
|
|
3422
4300
|
if (!quietMode) {
|
|
3423
4301
|
fontLogger.error(`Failed to download font ${fontUrl}:`, errorMessage);
|
|
@@ -3430,6 +4308,7 @@ class FontService {
|
|
|
3430
4308
|
}, { batchSize });
|
|
3431
4309
|
const downloadTime = Date.now() - startTime;
|
|
3432
4310
|
const averageSpeed = downloadTime > 0 ? (totalSize / downloadTime) * 1000 : 0;
|
|
4311
|
+
reportProgress(`Font download complete: ${downloadedFonts} downloaded, ${skippedFonts} skipped, ${failedFonts} failed`);
|
|
3433
4312
|
return {
|
|
3434
4313
|
totalFonts: fontUrls.length,
|
|
3435
4314
|
downloadedFonts,
|
|
@@ -3756,6 +4635,10 @@ class SpriteService {
|
|
|
3756
4635
|
return !existingSprites.has(key);
|
|
3757
4636
|
});
|
|
3758
4637
|
skippedSprites = spriteUrls.length - urlsToDownload.length;
|
|
4638
|
+
// Skipped sprites count toward the total, so mark them done up front.
|
|
4639
|
+
if (skippedSprites > 0) {
|
|
4640
|
+
progressTracker.update(skippedSprites);
|
|
4641
|
+
}
|
|
3759
4642
|
}
|
|
3760
4643
|
// Check storage quota if enabled
|
|
3761
4644
|
if (storageQuotaCheck && 'storage' in navigator && 'estimate' in navigator.storage) {
|
|
@@ -3871,7 +4754,9 @@ class SpriteService {
|
|
|
3871
4754
|
error: errorMessage,
|
|
3872
4755
|
});
|
|
3873
4756
|
spriteLogger.error(`Failed to download sprite ${spriteUrl}:`, error);
|
|
3874
|
-
//
|
|
4757
|
+
// Advance progress on failure too — a failed item is finished, and
|
|
4758
|
+
// otherwise the bar stalls, then jumps to 100% at the end.
|
|
4759
|
+
progressTracker.update(1, this.extractSpriteName(spriteUrl));
|
|
3875
4760
|
if (onProgress) {
|
|
3876
4761
|
const progress = progressTracker.getProgress();
|
|
3877
4762
|
onProgress({
|
|
@@ -4185,11 +5070,78 @@ var spriteService$1 = /*#__PURE__*/Object.freeze({
|
|
|
4185
5070
|
verifyAndRepairSprites: verifyAndRepairSprites
|
|
4186
5071
|
});
|
|
4187
5072
|
|
|
4188
|
-
|
|
4189
|
-
function
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
5073
|
+
/** A style URL's identity, ignoring the access token (same style, different token). */
|
|
5074
|
+
function styleUrlIdentity(url) {
|
|
5075
|
+
try {
|
|
5076
|
+
const parsed = new URL(url);
|
|
5077
|
+
parsed.searchParams.delete('access_token');
|
|
5078
|
+
return parsed.toString();
|
|
5079
|
+
}
|
|
5080
|
+
catch {
|
|
5081
|
+
return url;
|
|
5082
|
+
}
|
|
5083
|
+
}
|
|
5084
|
+
/**
|
|
5085
|
+
* Pick the storage key for a style downloaded from `styleUrl`. `baseId` comes
|
|
5086
|
+
* from the style's `id`/`name`, which unrelated styles can share ("Streets"),
|
|
5087
|
+
* so a key already held by a style from a different URL is never reused:
|
|
5088
|
+
* fall back to the key this URL was stored under before, else a free
|
|
5089
|
+
* `${baseId}-N`. Entries without `originalUrl` (saved with
|
|
5090
|
+
* `includeMetadata: false`) are assumed to be the same style, so re-downloads
|
|
5091
|
+
* don't pile up duplicates; `carryOverRegions` keeps their regions either way.
|
|
5092
|
+
*/
|
|
5093
|
+
async function resolveStyleKey(db, baseId, styleUrl) {
|
|
5094
|
+
const target = styleUrlIdentity(styleUrl);
|
|
5095
|
+
const isSameStyle = (entry) => !!entry.originalUrl && styleUrlIdentity(entry.originalUrl) === target;
|
|
5096
|
+
const existing = await db.get('styles', baseId);
|
|
5097
|
+
if (!existing || !existing.originalUrl || isSameStyle(existing))
|
|
5098
|
+
return baseId;
|
|
5099
|
+
const all = await db.getAll('styles');
|
|
5100
|
+
const previous = all.find(isSameStyle);
|
|
5101
|
+
if (previous)
|
|
5102
|
+
return previous.key;
|
|
5103
|
+
const taken = new Set(all.map(entry => entry.key));
|
|
5104
|
+
let n = 2;
|
|
5105
|
+
while (taken.has(`${baseId}-${n}`))
|
|
5106
|
+
n++;
|
|
5107
|
+
logger.warn(`Style key "${baseId}" is used by a style from another URL; storing ${styleUrl} as "${baseId}-${n}"`);
|
|
5108
|
+
return `${baseId}-${n}`;
|
|
5109
|
+
}
|
|
5110
|
+
/**
|
|
5111
|
+
* Re-downloading a style replaces its stored entry. Carry over the regions
|
|
5112
|
+
* already attached to it and re-patch the fresh style for them — otherwise
|
|
5113
|
+
* every existing region is dropped and their tiles are orphaned. Patches a
|
|
5114
|
+
* copy, so callers can keep using the unpatched style for resource downloads.
|
|
5115
|
+
*/
|
|
5116
|
+
async function carryOverRegions(db, entry) {
|
|
5117
|
+
const existing = await db.get('styles', entry.key);
|
|
5118
|
+
const regions = existing?.regions;
|
|
5119
|
+
// Config set through `styleConfig` decides what renders and what's planned,
|
|
5120
|
+
// so it has to survive a fresh copy of the style.
|
|
5121
|
+
if (existing?.styleConfig) {
|
|
5122
|
+
entry.style = JSON.parse(JSON.stringify(entry.style));
|
|
5123
|
+
applyStyleConfig(entry.style, existing.styleConfig);
|
|
5124
|
+
entry.styleConfig = existing.styleConfig;
|
|
5125
|
+
}
|
|
5126
|
+
if (!Array.isArray(regions) || regions.length === 0)
|
|
5127
|
+
return;
|
|
5128
|
+
entry.regions = regions;
|
|
5129
|
+
entry.style = JSON.parse(JSON.stringify(entry.style));
|
|
5130
|
+
// Regions' extra sources were added to the old copy, so add them to this one.
|
|
5131
|
+
for (const region of regions) {
|
|
5132
|
+
injectExtraSources(entry.style, region.extraSources);
|
|
5133
|
+
}
|
|
5134
|
+
stashOriginalModels(entry);
|
|
5135
|
+
patchStyleForOffline(entry.style, entry.key, Math.max(...regions.map(region => region.maxZoom)), undefined, entry.key);
|
|
5136
|
+
const { storedModelNames } = await Promise.resolve().then(function () { return modelService$1; });
|
|
5137
|
+
restoreUnstoredModels(entry, await storedModelNames(entry.key));
|
|
5138
|
+
logger.debug(`Carried ${regions.length} region(s) over to re-downloaded style ${entry.key}`);
|
|
5139
|
+
}
|
|
5140
|
+
// Helper functions to work with StyleEntry structure
|
|
5141
|
+
function createStyleEntry(key, style, provider = 'auto', metadata = {}) {
|
|
5142
|
+
return {
|
|
5143
|
+
key,
|
|
5144
|
+
style,
|
|
4193
5145
|
provider,
|
|
4194
5146
|
regions: [],
|
|
4195
5147
|
fonts: [],
|
|
@@ -4287,8 +5239,11 @@ async function downloadStyles(stylesUrl, options = {}) {
|
|
|
4287
5239
|
layerCount: style.layers ? style.layers.length : 0,
|
|
4288
5240
|
});
|
|
4289
5241
|
}
|
|
5242
|
+
// Mapbox Standard now arrives flat, so resolveImports may not have run.
|
|
5243
|
+
sanitizeIndoorExpressions(style);
|
|
4290
5244
|
// Check if style already exists in the database
|
|
4291
5245
|
const db = await dbPromise;
|
|
5246
|
+
style.id = await resolveStyleKey(db, style.id, stylesUrl);
|
|
4292
5247
|
if (skipExisting) {
|
|
4293
5248
|
const existingStyle = await db.get('styles', style.id);
|
|
4294
5249
|
if (existingStyle && typeof existingStyle === 'object') {
|
|
@@ -4352,7 +5307,7 @@ async function downloadStyles(stylesUrl, options = {}) {
|
|
|
4352
5307
|
timeout: timeoutMs,
|
|
4353
5308
|
retryDelay: 1000,
|
|
4354
5309
|
});
|
|
4355
|
-
const sourceData = await sourceResponse.json();
|
|
5310
|
+
const sourceData = selectTileJsonVariant(await sourceResponse.json());
|
|
4356
5311
|
style.sources[sourceKey] = { ...source, ...sourceData }; // Embed source data
|
|
4357
5312
|
sourcesEmbedded++;
|
|
4358
5313
|
logger.debug(`Embedded source data for ${sourceKey}`);
|
|
@@ -4419,6 +5374,7 @@ async function downloadStyles(stylesUrl, options = {}) {
|
|
|
4419
5374
|
}
|
|
4420
5375
|
: {});
|
|
4421
5376
|
// Save the style
|
|
5377
|
+
await carryOverRegions(db, styleStorageItem);
|
|
4422
5378
|
await db.put('styles', styleStorageItem);
|
|
4423
5379
|
logger.debug('Style with sources saved successfully');
|
|
4424
5380
|
onProgress?.({
|
|
@@ -4692,18 +5648,26 @@ async function loadStyleById(styleId) {
|
|
|
4692
5648
|
return null;
|
|
4693
5649
|
}
|
|
4694
5650
|
}
|
|
5651
|
+
/** Delete every style and everything stored for them. */
|
|
4695
5652
|
async function deleteStyles() {
|
|
4696
5653
|
try {
|
|
4697
5654
|
const db = await dbPromise;
|
|
4698
|
-
await
|
|
5655
|
+
await Promise.all(['tiles', 'fonts', 'glyphs', 'sprites', 'models', 'styles'].map(store => db.clear(store)));
|
|
5656
|
+
clearAllCaches();
|
|
4699
5657
|
logger.debug('All styles deleted successfully');
|
|
4700
5658
|
}
|
|
4701
5659
|
catch (error) {
|
|
4702
5660
|
logger.error('Error deleting styles:', error);
|
|
4703
5661
|
}
|
|
4704
5662
|
}
|
|
5663
|
+
/**
|
|
5664
|
+
* Delete a style, its regions and everything stored for it. The entry goes last,
|
|
5665
|
+
* so a failure part-way leaves the style listed and able to be deleted again.
|
|
5666
|
+
*/
|
|
4705
5667
|
async function deleteStyleById(styleId) {
|
|
4706
5668
|
try {
|
|
5669
|
+
const { RegionService } = await Promise.resolve().then(function () { return regionService; });
|
|
5670
|
+
await new RegionService().deleteStyleData(styleId);
|
|
4707
5671
|
const db = await dbPromise;
|
|
4708
5672
|
await db.delete('styles', styleId);
|
|
4709
5673
|
logger.debug(`Style with ID ${styleId} deleted successfully`);
|
|
@@ -4712,6 +5676,24 @@ async function deleteStyleById(styleId) {
|
|
|
4712
5676
|
logger.error(`Error deleting style with ID ${styleId}:`, error);
|
|
4713
5677
|
}
|
|
4714
5678
|
}
|
|
5679
|
+
/**
|
|
5680
|
+
* Bytes stored for each style, by style id: its tiles, and its other resources
|
|
5681
|
+
* (glyphs, sprites, models and fonts). Tiles shared by regions count once.
|
|
5682
|
+
*/
|
|
5683
|
+
async function getStyleStorageSizes() {
|
|
5684
|
+
const db = await dbPromise;
|
|
5685
|
+
const sizes = {};
|
|
5686
|
+
for (const storeName of ['tiles', 'glyphs', 'sprites', 'models', 'fonts']) {
|
|
5687
|
+
for await (const cursor of db.transaction(storeName).store) {
|
|
5688
|
+
const { styleId, size } = cursor.value;
|
|
5689
|
+
if (!styleId)
|
|
5690
|
+
continue;
|
|
5691
|
+
sizes[styleId] ??= { tiles: 0, resources: 0 };
|
|
5692
|
+
sizes[styleId][storeName === 'tiles' ? 'tiles' : 'resources'] += size ?? 0;
|
|
5693
|
+
}
|
|
5694
|
+
}
|
|
5695
|
+
return sizes;
|
|
5696
|
+
}
|
|
4715
5697
|
/**
|
|
4716
5698
|
* Get enhanced style statistics
|
|
4717
5699
|
*/
|
|
@@ -4937,9 +5919,10 @@ async function isStyleDownloaded(styleId, styleUrl) {
|
|
|
4937
5919
|
* Enhanced style download that supports both Mapbox GL and MapLibre GL
|
|
4938
5920
|
*/
|
|
4939
5921
|
async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
4940
|
-
const { provider: explicitProvider, accessToken: explicitAccessToken, forceProvider = false, skipExisting = false, enableSourceEmbedding = true, maxRetries = 3, timeoutMs = 30000, } = options;
|
|
5922
|
+
const { provider: explicitProvider, accessToken: explicitAccessToken, forceProvider = false, skipExisting = false, enableSourceEmbedding = true, maxRetries = 3, timeoutMs = 30000, onProgress, } = options;
|
|
4941
5923
|
const startTime = Date.now();
|
|
4942
5924
|
let sourcesEmbedded = 0;
|
|
5925
|
+
const emit = (percentage, message) => onProgress?.({ completed: percentage, total: 100, percentage, message, errors: [] });
|
|
4943
5926
|
logger.debug(`Downloading style from: ${styleUrl}`);
|
|
4944
5927
|
try {
|
|
4945
5928
|
// Auto-detect provider if not explicitly set
|
|
@@ -4960,6 +5943,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
4960
5943
|
fetchUrl = normalizeStyleUrl(styleUrl, extractedToken);
|
|
4961
5944
|
}
|
|
4962
5945
|
// Fetch the style
|
|
5946
|
+
emit(0, 'Fetching style');
|
|
4963
5947
|
const response = await fetchWithRetry(fetchUrl, {
|
|
4964
5948
|
timeout: timeoutMs,
|
|
4965
5949
|
retries: maxRetries,
|
|
@@ -4968,6 +5952,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
4968
5952
|
throw new Error(`Failed to fetch style: ${response.status} ${response.statusText}`);
|
|
4969
5953
|
}
|
|
4970
5954
|
const style = (await response.json());
|
|
5955
|
+
emit(20, 'Style fetched');
|
|
4971
5956
|
// Resolve imports (Mapbox Standard / compositional styles)
|
|
4972
5957
|
if (hasImports(style)) {
|
|
4973
5958
|
const importToken = extractedToken || '';
|
|
@@ -4981,13 +5966,19 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
4981
5966
|
layerCount: style.layers ? style.layers.length : 0,
|
|
4982
5967
|
});
|
|
4983
5968
|
}
|
|
5969
|
+
// Mapbox Standard now arrives flat, so resolveImports may not have run.
|
|
5970
|
+
sanitizeIndoorExpressions(style);
|
|
5971
|
+
emit(40, 'Processing style sources');
|
|
4984
5972
|
// Process style for the detected provider
|
|
4985
5973
|
const processedStyle = processStyleSources(style, detectedProvider, extractedToken);
|
|
4986
5974
|
// Embed TileJSON data into sources for reliable offline tile downloads
|
|
4987
5975
|
// This fetches each source's TileJSON URL and merges the result (including tiles array,
|
|
4988
5976
|
// minzoom, maxzoom) into the source config, so tile downloads don't need extra fetches
|
|
4989
5977
|
if (enableSourceEmbedding && processedStyle.sources) {
|
|
4990
|
-
|
|
5978
|
+
const sourceEntries = Object.entries(processedStyle.sources);
|
|
5979
|
+
let sourcesVisited = 0;
|
|
5980
|
+
for (const [sourceKey, sourceConfig] of sourceEntries) {
|
|
5981
|
+
emit(40 + Math.round((sourcesVisited++ / Math.max(1, sourceEntries.length)) * 40), `Embedding source ${sourceKey}`);
|
|
4991
5982
|
const source = sourceConfig;
|
|
4992
5983
|
if (source.url && typeof source.url === 'string' && !source.tiles) {
|
|
4993
5984
|
const sourceUrl = source.url;
|
|
@@ -5000,7 +5991,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5000
5991
|
timeout: timeoutMs,
|
|
5001
5992
|
retryDelay: 1000,
|
|
5002
5993
|
});
|
|
5003
|
-
const sourceData = await sourceResponse.json();
|
|
5994
|
+
const sourceData = selectTileJsonVariant(await sourceResponse.json());
|
|
5004
5995
|
// Only copy valid MapLibre/Mapbox source properties from TileJSON
|
|
5005
5996
|
// Copying unknown properties (like "name", "tilejson") causes
|
|
5006
5997
|
// MapLibre to throw "unknown property" errors on setStyle()
|
|
@@ -5034,6 +6025,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5034
6025
|
}
|
|
5035
6026
|
}
|
|
5036
6027
|
}
|
|
6028
|
+
emit(80, 'Saving style');
|
|
5037
6029
|
// Set default glyphs URL if not present
|
|
5038
6030
|
if (!processedStyle.glyphs) {
|
|
5039
6031
|
processedStyle.glyphs = GLYPH_CONFIG.DEFAULT_URL;
|
|
@@ -5045,13 +6037,17 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5045
6037
|
throw new Error(`Style validation failed: ${validation.errors.join(', ')}`);
|
|
5046
6038
|
}
|
|
5047
6039
|
// Generate style ID
|
|
5048
|
-
const styleId = processedStyle.name?.toLowerCase().replace(/\s+/g, '-') || `style-${Date.now()}`;
|
|
5049
|
-
// Check if style already exists
|
|
5050
6040
|
const db = await dbPromise;
|
|
6041
|
+
const styleId = await resolveStyleKey(db,
|
|
6042
|
+
// The key goes into resource keys ("{key}:...") and idb:// URLs, so ":" or "/"
|
|
6043
|
+
// in a style's name would make its data look like another style's or break its URLs.
|
|
6044
|
+
processedStyle.name?.toLowerCase().replace(/[\s:/\\?#%]+/g, '-') || `style-${Date.now()}`, styleUrl);
|
|
6045
|
+
// Check if style already exists
|
|
5051
6046
|
if (skipExisting) {
|
|
5052
6047
|
const existingStyle = await db.get('styles', styleId);
|
|
5053
6048
|
if (existingStyle) {
|
|
5054
6049
|
logger.debug(`Style ${styleId} already exists, skipping`);
|
|
6050
|
+
emit(100, 'Style already exists');
|
|
5055
6051
|
return {
|
|
5056
6052
|
styleId,
|
|
5057
6053
|
success: true,
|
|
@@ -5082,7 +6078,9 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5082
6078
|
accessToken: extractedToken,
|
|
5083
6079
|
});
|
|
5084
6080
|
// Save style
|
|
6081
|
+
await carryOverRegions(db, styleEntry);
|
|
5085
6082
|
await db.put('styles', styleEntry);
|
|
6083
|
+
emit(100, 'Style saved');
|
|
5086
6084
|
const downloadTime = Date.now() - startTime;
|
|
5087
6085
|
logger.info(`Style ${styleId} downloaded in ${downloadTime}ms`);
|
|
5088
6086
|
return {
|
|
@@ -5131,12 +6129,38 @@ var styleService = /*#__PURE__*/Object.freeze({
|
|
|
5131
6129
|
downloadStyleWithProvider: downloadStyleWithProvider,
|
|
5132
6130
|
downloadStyles: downloadStyles,
|
|
5133
6131
|
getStyleStats: getStyleStats,
|
|
6132
|
+
getStyleStorageSizes: getStyleStorageSizes,
|
|
5134
6133
|
isStyleDownloaded: isStyleDownloaded,
|
|
5135
6134
|
loadStyleById: loadStyleById,
|
|
5136
6135
|
loadStyles: loadStyles
|
|
5137
6136
|
});
|
|
5138
6137
|
|
|
5139
6138
|
const regionLogger$1 = logger.scope('RegionService');
|
|
6139
|
+
/**
|
|
6140
|
+
* Where each phase sits in a download's overall progress, as [start, end] in
|
|
6141
|
+
* percent. Tiles get the most because they're usually most of the bytes.
|
|
6142
|
+
*/
|
|
6143
|
+
const PHASE_PROGRESS_RANGES = {
|
|
6144
|
+
style: [0, 5],
|
|
6145
|
+
sprites: [5, 10],
|
|
6146
|
+
glyphs: [10, 25],
|
|
6147
|
+
models: [25, 30],
|
|
6148
|
+
tiles: [30, 99],
|
|
6149
|
+
metadata: [99, 100],
|
|
6150
|
+
};
|
|
6151
|
+
/** True for a Mapbox-hosted style sprite, which has an `iconset.pbf` beside it. */
|
|
6152
|
+
function hasMapboxIconset(spriteUrl) {
|
|
6153
|
+
try {
|
|
6154
|
+
const { hostname, pathname } = new URL(spriteUrl);
|
|
6155
|
+
return (hostname === 'api.mapbox.com' &&
|
|
6156
|
+
pathname.startsWith('/styles/v1/') &&
|
|
6157
|
+
pathname.endsWith('/sprite'));
|
|
6158
|
+
}
|
|
6159
|
+
catch {
|
|
6160
|
+
// Not an absolute URL, so not a Mapbox-hosted sprite.
|
|
6161
|
+
return false;
|
|
6162
|
+
}
|
|
6163
|
+
}
|
|
5140
6164
|
/**
|
|
5141
6165
|
* True when `key` belongs to the given styleId.
|
|
5142
6166
|
*
|
|
@@ -5153,44 +6177,10 @@ function resourceKeyBelongsToStyle(key, styleId) {
|
|
|
5153
6177
|
}
|
|
5154
6178
|
class RegionService {
|
|
5155
6179
|
/**
|
|
5156
|
-
*
|
|
5157
|
-
|
|
5158
|
-
tileOverlapsWithRegion(tileX, tileY, tileZ, region) {
|
|
5159
|
-
// Validate region bounds exist and have correct structure
|
|
5160
|
-
if (!region.bounds ||
|
|
5161
|
-
!Array.isArray(region.bounds) ||
|
|
5162
|
-
region.bounds.length !== 2 ||
|
|
5163
|
-
!Array.isArray(region.bounds[0]) ||
|
|
5164
|
-
!Array.isArray(region.bounds[1]) ||
|
|
5165
|
-
region.bounds[0].length !== 2 ||
|
|
5166
|
-
region.bounds[1].length !== 2) {
|
|
5167
|
-
regionLogger$1.warn('Invalid region bounds structure:', region.bounds);
|
|
5168
|
-
return false;
|
|
5169
|
-
}
|
|
5170
|
-
// Get tile bounds using tilebelt
|
|
5171
|
-
const tileBounds = tilebelt__namespace.tileToBBOX([tileX, tileY, tileZ]);
|
|
5172
|
-
const [tileWest, tileSouth, tileEast, tileNorth] = tileBounds;
|
|
5173
|
-
// Region bounds format: [[west, south], [east, north]]
|
|
5174
|
-
const regionWest = region.bounds[0][0];
|
|
5175
|
-
const regionSouth = region.bounds[0][1];
|
|
5176
|
-
const regionEast = region.bounds[1][0];
|
|
5177
|
-
const regionNorth = region.bounds[1][1];
|
|
5178
|
-
// Check if bounding boxes overlap
|
|
5179
|
-
return !(tileEast < regionWest ||
|
|
5180
|
-
tileWest > regionEast ||
|
|
5181
|
-
tileNorth < regionSouth ||
|
|
5182
|
-
tileSouth > regionNorth);
|
|
5183
|
-
}
|
|
5184
|
-
/**
|
|
5185
|
-
* Check if a tile overlaps with any of the given regions
|
|
5186
|
-
*/
|
|
5187
|
-
tileOverlapsWithAnyRegion(tileX, tileY, tileZ, regions) {
|
|
5188
|
-
return regions.some(region => this.tileOverlapsWithRegion(tileX, tileY, tileZ, region));
|
|
5189
|
-
}
|
|
5190
|
-
/**
|
|
5191
|
-
* Delete tiles that don't overlap with any remaining regions
|
|
6180
|
+
* Delete tiles no remaining region needs — outside every remaining region's
|
|
6181
|
+
* bounds, or at a zoom none of them downloads for that tile's source.
|
|
5192
6182
|
*/
|
|
5193
|
-
async deleteNonOverlappingTiles(styleId, remainingRegions) {
|
|
6183
|
+
async deleteNonOverlappingTiles(styleId, remainingRegions, sources) {
|
|
5194
6184
|
const db = await dbPromise;
|
|
5195
6185
|
let deletedCount = 0;
|
|
5196
6186
|
regionLogger$1.debug(`Checking tiles for style ${styleId} against ${remainingRegions.length} remaining regions`);
|
|
@@ -5207,9 +6197,13 @@ class RegionService {
|
|
|
5207
6197
|
regionLogger$1.warn(`Invalid tile key format: ${tileEntry.key}`);
|
|
5208
6198
|
continue;
|
|
5209
6199
|
}
|
|
5210
|
-
const
|
|
5211
|
-
|
|
5212
|
-
|
|
6200
|
+
const tile = {
|
|
6201
|
+
sourceId: tileEntry.sourceId ?? parsed.sourceId,
|
|
6202
|
+
z: parsed.z,
|
|
6203
|
+
x: parsed.x,
|
|
6204
|
+
y: parsed.y,
|
|
6205
|
+
};
|
|
6206
|
+
if (!remainingRegions.some(region => tileBelongsToRegion(tile, region, sources))) {
|
|
5213
6207
|
regionLogger$1.debug(`Deleting non-overlapping tile: ${tileEntry.key}`);
|
|
5214
6208
|
await cursor.delete();
|
|
5215
6209
|
deletedCount++;
|
|
@@ -5263,6 +6257,46 @@ class RegionService {
|
|
|
5263
6257
|
/**
|
|
5264
6258
|
* Delete all tiles for a style
|
|
5265
6259
|
*/
|
|
6260
|
+
/**
|
|
6261
|
+
* Delete everything stored for a style except its own entry: tiles, fonts,
|
|
6262
|
+
* glyphs, sprites and models.
|
|
6263
|
+
*/
|
|
6264
|
+
/**
|
|
6265
|
+
* Undo what a failed or cancelled download left behind: its partly stored
|
|
6266
|
+
* tiles and its config change. A style this download created, which no other
|
|
6267
|
+
* region uses, goes entirely, and so does a style deleted during the download.
|
|
6268
|
+
*/
|
|
6269
|
+
async cleanUpFailedDownload(styleId, createdStyle, configBefore) {
|
|
6270
|
+
try {
|
|
6271
|
+
const db = await dbPromise;
|
|
6272
|
+
const entry = await db.get('styles', styleId);
|
|
6273
|
+
if (!entry) {
|
|
6274
|
+
await this.deleteStyleData(styleId);
|
|
6275
|
+
return;
|
|
6276
|
+
}
|
|
6277
|
+
const regions = Array.isArray(entry.regions) ? entry.regions : [];
|
|
6278
|
+
if (createdStyle && regions.length === 0) {
|
|
6279
|
+
await this.deleteStyleData(styleId);
|
|
6280
|
+
await db.delete('styles', styleId);
|
|
6281
|
+
return;
|
|
6282
|
+
}
|
|
6283
|
+
if (configBefore) {
|
|
6284
|
+
entry.style.schema = configBefore.schema;
|
|
6285
|
+
entry.styleConfig = configBefore.values;
|
|
6286
|
+
await db.put('styles', entry);
|
|
6287
|
+
}
|
|
6288
|
+
await this.deleteNonOverlappingTiles(styleId, regions, (entry.style?.sources ?? {}));
|
|
6289
|
+
clearAllCaches();
|
|
6290
|
+
}
|
|
6291
|
+
catch (error) {
|
|
6292
|
+
regionLogger$1.warn(`Could not clean up after the failed download on ${styleId}:`, error);
|
|
6293
|
+
}
|
|
6294
|
+
}
|
|
6295
|
+
async deleteStyleData(styleId) {
|
|
6296
|
+
await this.deleteAllStyleTiles(styleId);
|
|
6297
|
+
await this.deleteStyleResources(styleId);
|
|
6298
|
+
clearAllCaches();
|
|
6299
|
+
}
|
|
5266
6300
|
async deleteAllStyleTiles(styleId) {
|
|
5267
6301
|
const db = await dbPromise;
|
|
5268
6302
|
let deletedCount = 0;
|
|
@@ -5309,23 +6343,15 @@ class RegionService {
|
|
|
5309
6343
|
styleEntry.regions = [];
|
|
5310
6344
|
}
|
|
5311
6345
|
// Inject extra sources into the style so they get patched for offline use
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
};
|
|
5322
|
-
regionLogger$1.debug(`Injected extra source into style: ${extra.id}`);
|
|
5323
|
-
}
|
|
5324
|
-
}
|
|
5325
|
-
}
|
|
5326
|
-
// Patch style for offline use with the region's maxZoom and tileExtension
|
|
5327
|
-
// Pass styleId for sprites since they're stored with the style ID, not region ID
|
|
5328
|
-
patchStyleForOffline(styleEntry.style, region.id, region.maxZoom, region.tileExtension, styleId);
|
|
6346
|
+
injectExtraSources(styleEntry.style, region.extraSources);
|
|
6347
|
+
stashOriginalModels(styleEntry);
|
|
6348
|
+
// Patch style for offline use. URLs are keyed by styleId (resources are
|
|
6349
|
+
// stored per style), and maxzoom is capped at the deepest region on the
|
|
6350
|
+
// style — not just this one — so adding a shallow region doesn't hide a
|
|
6351
|
+
// deeper sibling's tiles.
|
|
6352
|
+
const otherRegions = styleEntry.regions.filter((r) => r.id !== region.id && r.regionId !== region.id);
|
|
6353
|
+
patchStyleForOffline(styleEntry.style, region.id, Math.max(region.maxZoom, ...otherRegions.map(r => r.maxZoom)), region.tileExtension, styleId);
|
|
6354
|
+
restoreUnstoredModels(styleEntry, await storedModelNames(styleId));
|
|
5329
6355
|
// Add or update region metadata on the style entry.
|
|
5330
6356
|
// `expiry` is stored as an absolute timestamp (per the type doc and all
|
|
5331
6357
|
// readers in cleanupService/cleanupManagement/RegionList). If the caller
|
|
@@ -5353,6 +6379,8 @@ class RegionService {
|
|
|
5353
6379
|
styleEntry.regions.push(regionWithMeta);
|
|
5354
6380
|
}
|
|
5355
6381
|
await db.put('styles', styleEntry);
|
|
6382
|
+
// Drop in-memory region→style / tile caches so the fetch handler sees the new region.
|
|
6383
|
+
clearAllCaches();
|
|
5356
6384
|
}
|
|
5357
6385
|
async loadRegion(region, options = {}) {
|
|
5358
6386
|
return this.downloadRegion(region, options);
|
|
@@ -5366,17 +6394,22 @@ class RegionService {
|
|
|
5366
6394
|
if (!region.styleUrl) {
|
|
5367
6395
|
throw new Error('Region must have a styleUrl');
|
|
5368
6396
|
}
|
|
5369
|
-
|
|
6397
|
+
validateZoomLevels(region.minZoom, region.maxZoom);
|
|
6398
|
+
const { onProgress, provider = 'auto', accessToken, skipGlyphs = false, skipSprites = false, skipModels = false, glyphRanges, tileOptions, styleConfig, signal, } = options;
|
|
6399
|
+
let overallPercentage = 0;
|
|
5370
6400
|
const emit = (phase, completed, total, message) => {
|
|
5371
6401
|
if (!onProgress)
|
|
5372
6402
|
return;
|
|
5373
6403
|
const safeTotal = total > 0 ? total : 1;
|
|
5374
6404
|
const clamped = Math.min(completed, safeTotal);
|
|
6405
|
+
const [start, end] = PHASE_PROGRESS_RANGES[phase];
|
|
6406
|
+
overallPercentage = Math.max(overallPercentage, start + (end - start) * (clamped / safeTotal));
|
|
5375
6407
|
onProgress({
|
|
5376
6408
|
phase,
|
|
5377
6409
|
completed: clamped,
|
|
5378
|
-
total
|
|
6410
|
+
total,
|
|
5379
6411
|
percentage: (clamped / safeTotal) * 100,
|
|
6412
|
+
overallPercentage,
|
|
5380
6413
|
message,
|
|
5381
6414
|
});
|
|
5382
6415
|
};
|
|
@@ -5415,6 +6448,18 @@ class RegionService {
|
|
|
5415
6448
|
const originalSpriteUrl = (styleEntry.originalSpriteUrl ??
|
|
5416
6449
|
storedStyle.sprite);
|
|
5417
6450
|
const originalGlyphsUrl = styleEntry.originalGlyphsUrl ?? storedStyle.glyphs;
|
|
6451
|
+
// Applied before the resource phases, which all plan from the style's config.
|
|
6452
|
+
// Kept so a failed download can put the config back as it was.
|
|
6453
|
+
const configBefore = styleConfig
|
|
6454
|
+
? JSON.parse(JSON.stringify({ schema: storedStyle.schema, values: styleEntry.styleConfig }))
|
|
6455
|
+
: undefined;
|
|
6456
|
+
if (styleConfig) {
|
|
6457
|
+
applyStyleConfig(storedStyle, styleConfig);
|
|
6458
|
+
styleEntry.styleConfig = { ...styleEntry.styleConfig, ...styleConfig };
|
|
6459
|
+
}
|
|
6460
|
+
// A reused style may predate download-time sanitizing.
|
|
6461
|
+
sanitizeIndoorExpressions(storedStyle);
|
|
6462
|
+
await (await dbPromise).put('styles', styleEntry);
|
|
5418
6463
|
// 2. Sprites
|
|
5419
6464
|
const spriteResults = [];
|
|
5420
6465
|
if (!skipSprites) {
|
|
@@ -5425,16 +6470,17 @@ class RegionService {
|
|
|
5425
6470
|
// sibling is also served under the same /styles/v1/.../<hash>/ path
|
|
5426
6471
|
// — we detect that case per-source below and append it to the list.
|
|
5427
6472
|
const baseSuffixes = ['.json', '.png', '@2x.json', '@2x.png'];
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
6473
|
+
const resolveSpriteBase = (url) => isMapboxProtocol(url) && effectiveAccessToken
|
|
6474
|
+
? resolveMapboxUrl(url, effectiveAccessToken)
|
|
6475
|
+
: url;
|
|
6476
|
+
const totalFiles = spriteSources.reduce((sum, source) => {
|
|
6477
|
+
const base = resolveSpriteBase(source.url).split('?')[0];
|
|
6478
|
+
return sum + baseSuffixes.length + (hasMapboxIconset(base) ? 1 : 0);
|
|
6479
|
+
}, 0);
|
|
5431
6480
|
let completed = 0;
|
|
5432
6481
|
emit('sprites', 0, totalFiles, 'Downloading sprites');
|
|
5433
6482
|
for (const source of spriteSources) {
|
|
5434
|
-
|
|
5435
|
-
if (isMapboxProtocol(spriteBase) && effectiveAccessToken) {
|
|
5436
|
-
spriteBase = resolveMapboxUrl(spriteBase, effectiveAccessToken);
|
|
5437
|
-
}
|
|
6483
|
+
const spriteBase = resolveSpriteBase(source.url);
|
|
5438
6484
|
const qIndex = spriteBase.indexOf('?');
|
|
5439
6485
|
const suffixes = [...baseSuffixes];
|
|
5440
6486
|
// Mapbox Standard serves an iconset.pbf alongside the sprite under
|
|
@@ -5442,18 +6488,7 @@ class RegionService {
|
|
|
5442
6488
|
// /styles/v1/{owner}/{style}/{hash}/iconset.pbf. The last path
|
|
5443
6489
|
// segment is `sprite`, so replacing it with `iconset.pbf` works.
|
|
5444
6490
|
const pathWithoutQuery = qIndex !== -1 ? spriteBase.slice(0, qIndex) : spriteBase;
|
|
5445
|
-
|
|
5446
|
-
try {
|
|
5447
|
-
const parsed = new URL(pathWithoutQuery);
|
|
5448
|
-
isMapboxStandardSprite =
|
|
5449
|
-
parsed.hostname === 'api.mapbox.com' &&
|
|
5450
|
-
parsed.pathname.startsWith('/styles/v1/') &&
|
|
5451
|
-
parsed.pathname.endsWith('/sprite');
|
|
5452
|
-
}
|
|
5453
|
-
catch {
|
|
5454
|
-
// Non-URL sprite base (e.g. relative); not a Mapbox Standard sprite.
|
|
5455
|
-
}
|
|
5456
|
-
if (isMapboxStandardSprite) {
|
|
6491
|
+
if (hasMapboxIconset(pathWithoutQuery)) {
|
|
5457
6492
|
// The path-rewrite suffix replaces the trailing `sprite` segment.
|
|
5458
6493
|
suffixes.push('__ICONSET__');
|
|
5459
6494
|
}
|
|
@@ -5517,14 +6552,16 @@ class RegionService {
|
|
|
5517
6552
|
// (plain string or `{ uri }`) — we accept both.
|
|
5518
6553
|
let modelResult;
|
|
5519
6554
|
if (!skipModels && storedStyle.models) {
|
|
5520
|
-
|
|
6555
|
+
// Upstream URLs, from before patching, so models that failed last time are retried.
|
|
6556
|
+
const rawModels = (styleEntry.originalModels ?? storedStyle.models);
|
|
6557
|
+
const alreadyStored = await storedModelNames(styleId);
|
|
5521
6558
|
const resolved = {};
|
|
5522
6559
|
for (const [name, value] of Object.entries(rawModels)) {
|
|
5523
6560
|
const uri = typeof value === 'string' ? value : value?.uri;
|
|
5524
|
-
if (!uri)
|
|
6561
|
+
if (!uri || alreadyStored.has(name))
|
|
5525
6562
|
continue;
|
|
5526
6563
|
if (uri.startsWith('idb://'))
|
|
5527
|
-
continue; //
|
|
6564
|
+
continue; // patched before upstream URLs were kept
|
|
5528
6565
|
const httpUrl = isMapboxProtocol(uri) && effectiveAccessToken
|
|
5529
6566
|
? resolveMapboxUrl(uri, effectiveAccessToken)
|
|
5530
6567
|
: uri;
|
|
@@ -5547,29 +6584,56 @@ class RegionService {
|
|
|
5547
6584
|
}
|
|
5548
6585
|
}
|
|
5549
6586
|
}
|
|
6587
|
+
// Renderer code fetched from api.mapbox.com at render time (3D decoders,
|
|
6588
|
+
// RTL text, procedural buildings). Never throws; see cacheMapboxRuntimeAssets.
|
|
6589
|
+
let runtimeAssetsCached;
|
|
6590
|
+
if (/mapbox:\/\/|api\.mapbox\.com/.test(`${region.styleUrl ?? ''} ${originalGlyphsUrl ?? ''}`)) {
|
|
6591
|
+
const { cacheMapboxRuntimeAssets } = await Promise.resolve().then(function () { return runtimeAssetService; });
|
|
6592
|
+
runtimeAssetsCached = await cacheMapboxRuntimeAssets();
|
|
6593
|
+
}
|
|
5550
6594
|
// 5. Tiles — use the stored (source-embedded) style, which still has HTTP tile URLs
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
6595
|
+
let tileResult;
|
|
6596
|
+
try {
|
|
6597
|
+
signal?.throwIfAborted();
|
|
6598
|
+
const { downloadTiles } = await Promise.resolve().then(function () { return tileService$1; });
|
|
6599
|
+
const regionForTiles = { ...region, styleId };
|
|
6600
|
+
// The tile count isn't known until the plan is made, which can take a while.
|
|
6601
|
+
emit('tiles', 0, 0, 'Planning tiles');
|
|
6602
|
+
tileResult = await downloadTiles(regionForTiles, storedStyle, styleId, {
|
|
6603
|
+
skipExisting: false,
|
|
6604
|
+
batchSize: 20,
|
|
6605
|
+
...tileOptions,
|
|
6606
|
+
signal,
|
|
6607
|
+
onProgress: progress => {
|
|
6608
|
+
emit('tiles', progress.completed, progress.total, progress.message ?? 'Downloading tiles');
|
|
6609
|
+
tileOptions?.onProgress?.(progress);
|
|
6610
|
+
},
|
|
6611
|
+
});
|
|
6612
|
+
signal?.throwIfAborted();
|
|
6613
|
+
// A region with no tiles at all isn't worth saving as if it had worked, as happens
|
|
6614
|
+
// with an expired token. Partial failures still save: tileResult reports them.
|
|
6615
|
+
const { failedTiles, downloadedTiles, skippedTiles } = tileResult;
|
|
6616
|
+
if (failedTiles > 0 && downloadedTiles === 0 && skippedTiles === 0) {
|
|
6617
|
+
throw new Error(`None of the ${failedTiles} tiles could be downloaded. Check your connection and access token.`);
|
|
6618
|
+
}
|
|
6619
|
+
// 6. Metadata — must run last, since addRegion patches style URLs to idb://.
|
|
6620
|
+
// Do NOT auto-fill tileExtension from tileResult: that's only the first
|
|
6621
|
+
// source's extension, and addRegion feeds it to patchStyleForOffline which
|
|
6622
|
+
// would override ALL sources — breaking mixed raster+vector styles. The
|
|
6623
|
+
// patcher extracts per-source extensions from tile URL patterns when
|
|
6624
|
+
// tileExtension is undefined. Callers who need to force an extension can
|
|
6625
|
+
// still pass `region.tileExtension` explicitly.
|
|
6626
|
+
emit('metadata', 0, 1, 'Saving region metadata');
|
|
6627
|
+
if (!(await (await dbPromise).get('styles', styleId))) {
|
|
6628
|
+
throw new Error('The style was deleted while this region was downloading.');
|
|
6629
|
+
}
|
|
6630
|
+
await this.addRegion({ ...region, styleId });
|
|
6631
|
+
emit('metadata', 1, 1, 'Region ready');
|
|
6632
|
+
}
|
|
6633
|
+
catch (error) {
|
|
6634
|
+
await this.cleanUpFailedDownload(styleId, Boolean(styleResult), configBefore);
|
|
6635
|
+
throw error;
|
|
6636
|
+
}
|
|
5573
6637
|
return {
|
|
5574
6638
|
regionId: region.id,
|
|
5575
6639
|
styleId,
|
|
@@ -5577,6 +6641,7 @@ class RegionService {
|
|
|
5577
6641
|
spriteResults,
|
|
5578
6642
|
glyphResult,
|
|
5579
6643
|
modelResult,
|
|
6644
|
+
runtimeAssetsCached,
|
|
5580
6645
|
tileResult,
|
|
5581
6646
|
};
|
|
5582
6647
|
}
|
|
@@ -5601,12 +6666,20 @@ class RegionService {
|
|
|
5601
6666
|
}
|
|
5602
6667
|
return undefined;
|
|
5603
6668
|
}
|
|
5604
|
-
|
|
6669
|
+
/**
|
|
6670
|
+
* Delete a region and the tiles/resources only it needed.
|
|
6671
|
+
* @param styleId - The style holding the region. Region ids are only unique
|
|
6672
|
+
* within a style, so pass it whenever known; without it the first style
|
|
6673
|
+
* containing `regionId` is used.
|
|
6674
|
+
*/
|
|
6675
|
+
async deleteRegion(regionId, styleId) {
|
|
5605
6676
|
const db = await dbPromise;
|
|
5606
|
-
regionLogger$1.debug(`Deleting region: ${regionId}`);
|
|
6677
|
+
regionLogger$1.debug(`Deleting region: ${regionId}${styleId ? ` from style ${styleId}` : ''}`);
|
|
5607
6678
|
try {
|
|
5608
6679
|
// Find which style contains this region
|
|
5609
|
-
const allStyles =
|
|
6680
|
+
const allStyles = styleId
|
|
6681
|
+
? [await db.get('styles', styleId)].filter((entry) => entry !== undefined)
|
|
6682
|
+
: await db.getAll('styles');
|
|
5610
6683
|
regionLogger$1.debug(`Found ${allStyles.length} styles to search through`);
|
|
5611
6684
|
let foundStyle = null;
|
|
5612
6685
|
let foundRegion = null;
|
|
@@ -5625,7 +6698,7 @@ class RegionService {
|
|
|
5625
6698
|
}
|
|
5626
6699
|
}
|
|
5627
6700
|
if (!foundStyle || !foundRegion) {
|
|
5628
|
-
regionLogger$1.warn(`Region ${regionId} not found in any style`);
|
|
6701
|
+
regionLogger$1.warn(`Region ${regionId} not found in ${styleId ? `style ${styleId}` : 'any style'}`);
|
|
5629
6702
|
return;
|
|
5630
6703
|
}
|
|
5631
6704
|
regionLogger$1.debug(`Found region in style: ${foundStyle.key}`, foundRegion);
|
|
@@ -5636,11 +6709,7 @@ class RegionService {
|
|
|
5636
6709
|
// If this was the last region for the style, delete the entire style and all its resources
|
|
5637
6710
|
if (remainingRegions.length === 0) {
|
|
5638
6711
|
regionLogger$1.info(`Style ${foundStyle.key} has no remaining regions, deleting entire style and all resources`);
|
|
5639
|
-
|
|
5640
|
-
const deletedTileCount = await this.deleteAllStyleTiles(foundStyle.key);
|
|
5641
|
-
regionLogger$1.debug(`Deleted ${deletedTileCount} tiles`);
|
|
5642
|
-
// Delete all style resources (fonts, glyphs, sprites)
|
|
5643
|
-
await this.deleteStyleResources(foundStyle.key);
|
|
6712
|
+
await this.deleteStyleData(foundStyle.key);
|
|
5644
6713
|
// Delete the style entry itself
|
|
5645
6714
|
await db.delete('styles', foundStyle.key);
|
|
5646
6715
|
regionLogger$1.info(`Completely deleted style ${foundStyle.key} and all associated resources`);
|
|
@@ -5648,11 +6717,18 @@ class RegionService {
|
|
|
5648
6717
|
else {
|
|
5649
6718
|
regionLogger$1.debug(`Style ${foundStyle.key} has ${remainingRegions.length} remaining regions, cleaning up non-overlapping tiles`);
|
|
5650
6719
|
// Delete tiles that don't overlap with any remaining regions
|
|
5651
|
-
const deletedTileCount = await this.deleteNonOverlappingTiles(foundStyle.key, remainingRegions);
|
|
6720
|
+
const deletedTileCount = await this.deleteNonOverlappingTiles(foundStyle.key, remainingRegions, (foundStyle.style?.sources ?? {}));
|
|
6721
|
+
// Re-patch for the remaining regions: shrink maxzoom to the deepest
|
|
6722
|
+
// survivor, and re-key URLs by styleId. Styles patched before URLs
|
|
6723
|
+
// were style-keyed still point at `idb://{deletedRegionId}/...`,
|
|
6724
|
+
// which no longer resolves once the region is gone.
|
|
6725
|
+
patchStyleForOffline(foundStyle.style, foundStyle.key, Math.max(...remainingRegions.map((r) => r.maxZoom)), undefined, foundStyle.key);
|
|
6726
|
+
restoreUnstoredModels(foundStyle, await storedModelNames(foundStyle.key));
|
|
5652
6727
|
// Update the style entry with the remaining regions
|
|
5653
6728
|
await db.put('styles', foundStyle);
|
|
5654
6729
|
regionLogger$1.info(`Region ${regionId} deleted successfully from style ${foundStyle.key}, cleaned up ${deletedTileCount} non-overlapping tiles`);
|
|
5655
6730
|
}
|
|
6731
|
+
clearAllCaches();
|
|
5656
6732
|
regionLogger$1.info(`Region deletion completed successfully for: ${regionId}`);
|
|
5657
6733
|
}
|
|
5658
6734
|
catch (error) {
|
|
@@ -5703,6 +6779,13 @@ async function loadAllStoredRegions() {
|
|
|
5703
6779
|
}
|
|
5704
6780
|
}
|
|
5705
6781
|
|
|
6782
|
+
var regionService = /*#__PURE__*/Object.freeze({
|
|
6783
|
+
__proto__: null,
|
|
6784
|
+
RegionService: RegionService,
|
|
6785
|
+
loadAllStoredRegions: loadAllStoredRegions,
|
|
6786
|
+
resourceKeyBelongsToStyle: resourceKeyBelongsToStyle
|
|
6787
|
+
});
|
|
6788
|
+
|
|
5706
6789
|
const cleanupLogger = logger.scope('CleanupService');
|
|
5707
6790
|
class CleanupService {
|
|
5708
6791
|
db = dbPromise;
|
|
@@ -5801,9 +6884,9 @@ class CleanupService {
|
|
|
5801
6884
|
let deletedCount = 0;
|
|
5802
6885
|
for (const region of uniqueRegionsToDelete) {
|
|
5803
6886
|
try {
|
|
5804
|
-
const
|
|
6887
|
+
const bytesBefore = await this.getStyleStoredBytes(region.styleId);
|
|
5805
6888
|
await this.deleteRegionCallback(region.id, region.styleId);
|
|
5806
|
-
result.freedSpace +=
|
|
6889
|
+
result.freedSpace += Math.max(0, bytesBefore - (await this.getStyleStoredBytes(region.styleId)));
|
|
5807
6890
|
deletedCount++;
|
|
5808
6891
|
onProgress?.({
|
|
5809
6892
|
phase: 'cleaning',
|
|
@@ -5867,7 +6950,7 @@ class CleanupService {
|
|
|
5867
6950
|
neverExpiring: 0,
|
|
5868
6951
|
};
|
|
5869
6952
|
for (const region of regions) {
|
|
5870
|
-
const regionSize = await this.getRegionSize(region.id);
|
|
6953
|
+
const regionSize = await this.getRegionSize(region.id, region.styleId);
|
|
5871
6954
|
totalSize += regionSize;
|
|
5872
6955
|
// Track by style
|
|
5873
6956
|
const styleId = region.styleId || 'unknown';
|
|
@@ -5969,8 +7052,8 @@ class CleanupService {
|
|
|
5969
7052
|
async getExpiredResourceCount() {
|
|
5970
7053
|
const db = await this.db;
|
|
5971
7054
|
const now = Date.now();
|
|
5972
|
-
const counts = { tiles: 0, fonts: 0, sprites: 0, glyphs: 0, total: 0 };
|
|
5973
|
-
const stores = ['tiles', 'fonts', 'sprites', 'glyphs'];
|
|
7055
|
+
const counts = { tiles: 0, fonts: 0, sprites: 0, glyphs: 0, models: 0, total: 0 };
|
|
7056
|
+
const stores = ['tiles', 'fonts', 'sprites', 'glyphs', 'models'];
|
|
5974
7057
|
for (const storeName of stores) {
|
|
5975
7058
|
try {
|
|
5976
7059
|
const tx = db.transaction([storeName], 'readonly');
|
|
@@ -6016,46 +7099,77 @@ class CleanupService {
|
|
|
6016
7099
|
async getAllRegions() {
|
|
6017
7100
|
return loadAllStoredRegions();
|
|
6018
7101
|
}
|
|
7102
|
+
/**
|
|
7103
|
+
* Bytes of the tiles stored for a region: tiles on its style inside its
|
|
7104
|
+
* bounds, at the zooms the download pipeline fetched for each source. Tiles
|
|
7105
|
+
* are stored per style, so regions that overlap share (and both count)
|
|
7106
|
+
* their common tiles; shared style resources are not included.
|
|
7107
|
+
*/
|
|
6019
7108
|
async getRegionSize(regionId, styleIdParam) {
|
|
6020
7109
|
const db = await this.db;
|
|
6021
|
-
|
|
6022
|
-
|
|
6023
|
-
|
|
6024
|
-
let
|
|
6025
|
-
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
const found = styleEntry.regions.find(r => r.id === regionId);
|
|
6032
|
-
if (found) {
|
|
6033
|
-
styleId = styleEntry.key;
|
|
6034
|
-
regionMinZoom = found.minZoom ?? 0;
|
|
6035
|
-
regionMaxZoom = found.maxZoom ?? 22;
|
|
6036
|
-
break;
|
|
6037
|
-
}
|
|
6038
|
-
}
|
|
7110
|
+
const candidates = styleIdParam
|
|
7111
|
+
? [await db.get('styles', styleIdParam)]
|
|
7112
|
+
: await db.getAll('styles');
|
|
7113
|
+
let styleEntry;
|
|
7114
|
+
let region;
|
|
7115
|
+
for (const entry of candidates) {
|
|
7116
|
+
region = entry?.regions?.find((r) => r.id === regionId || r.regionId === regionId);
|
|
7117
|
+
if (region) {
|
|
7118
|
+
styleEntry = entry;
|
|
7119
|
+
break;
|
|
6039
7120
|
}
|
|
6040
7121
|
}
|
|
6041
|
-
if (!
|
|
7122
|
+
if (!styleEntry || !region) {
|
|
6042
7123
|
return 0;
|
|
6043
7124
|
}
|
|
6044
|
-
|
|
7125
|
+
const styleId = styleEntry.key;
|
|
7126
|
+
const sources = (styleEntry.style?.sources ?? {});
|
|
7127
|
+
let totalSize = 0;
|
|
6045
7128
|
const tx = db.transaction(['tiles'], 'readonly');
|
|
6046
7129
|
for await (const cursor of tx.objectStore('tiles')) {
|
|
6047
7130
|
const tile = cursor.value;
|
|
6048
7131
|
if (tile.styleId !== styleId)
|
|
6049
7132
|
continue;
|
|
6050
|
-
// Filter by zoom range when possible
|
|
6051
7133
|
const parsed = parseTileKey(tile.key);
|
|
6052
|
-
if (parsed
|
|
7134
|
+
if (!parsed)
|
|
6053
7135
|
continue;
|
|
7136
|
+
const coords = {
|
|
7137
|
+
sourceId: tile.sourceId ?? parsed.sourceId,
|
|
7138
|
+
z: parsed.z,
|
|
7139
|
+
x: parsed.x,
|
|
7140
|
+
y: parsed.y,
|
|
7141
|
+
};
|
|
7142
|
+
if (tileBelongsToRegion(coords, region, sources)) {
|
|
7143
|
+
totalSize += tile.size || 0;
|
|
6054
7144
|
}
|
|
6055
|
-
totalSize += tile.size || 0;
|
|
6056
7145
|
}
|
|
6057
7146
|
return totalSize;
|
|
6058
7147
|
}
|
|
7148
|
+
/**
|
|
7149
|
+
* Bytes stored for a style: its tiles plus the fonts, glyphs, sprites and
|
|
7150
|
+
* models keyed to it. Measured before and after a deletion to report the
|
|
7151
|
+
* space actually freed (a region's own size over-counts tiles it shares).
|
|
7152
|
+
*/
|
|
7153
|
+
async getStyleStoredBytes(styleId) {
|
|
7154
|
+
if (!styleId)
|
|
7155
|
+
return 0;
|
|
7156
|
+
const db = await this.db;
|
|
7157
|
+
let total = 0;
|
|
7158
|
+
const tx = db.transaction(['tiles'], 'readonly');
|
|
7159
|
+
for await (const cursor of tx.objectStore('tiles')) {
|
|
7160
|
+
if (cursor.value.styleId === styleId)
|
|
7161
|
+
total += cursor.value.size || 0;
|
|
7162
|
+
}
|
|
7163
|
+
for (const storeName of ['fonts', 'glyphs', 'sprites', 'models']) {
|
|
7164
|
+
const storeTx = db.transaction([storeName], 'readonly');
|
|
7165
|
+
for await (const cursor of storeTx.objectStore(storeName)) {
|
|
7166
|
+
const entry = cursor.value;
|
|
7167
|
+
if (resourceKeyBelongsToStyle(entry.key, styleId))
|
|
7168
|
+
total += entry.size || 0;
|
|
7169
|
+
}
|
|
7170
|
+
}
|
|
7171
|
+
return total;
|
|
7172
|
+
}
|
|
6059
7173
|
async calculateTotalStorageSize() {
|
|
6060
7174
|
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
|
6061
7175
|
const estimate = await navigator.storage.estimate();
|
|
@@ -6069,6 +7183,7 @@ class CleanupService {
|
|
|
6069
7183
|
'fonts',
|
|
6070
7184
|
'sprites',
|
|
6071
7185
|
'glyphs',
|
|
7186
|
+
'models',
|
|
6072
7187
|
'styles',
|
|
6073
7188
|
];
|
|
6074
7189
|
for (const storeName of stores) {
|
|
@@ -6110,7 +7225,7 @@ class CleanupService {
|
|
|
6110
7225
|
if (currentSize >= targetSize)
|
|
6111
7226
|
break;
|
|
6112
7227
|
selected.push(region);
|
|
6113
|
-
const regionSize = await this.getRegionSize(region.id);
|
|
7228
|
+
const regionSize = await this.getRegionSize(region.id, region.styleId);
|
|
6114
7229
|
currentSize += regionSize > 0 ? regionSize : 10 * 1024 * 1024; // Fallback to 10MB if size unknown
|
|
6115
7230
|
}
|
|
6116
7231
|
return selected;
|
|
@@ -6201,7 +7316,7 @@ class TileService {
|
|
|
6201
7316
|
*/
|
|
6202
7317
|
async downloadTiles(region, style, styleId, options = {}) {
|
|
6203
7318
|
const db = await this.db;
|
|
6204
|
-
const { onProgress, batchSize = 10, maxRetries = 3, skipExisting = true, timeout = 10000, retryDelay = 1000, priorityZoomLevels = [], storageQuotaCheck = true, validateTiles = false, compressTiles = false, bandwidthLimit, probeSourcesBeforeDownload = true, skipKnownSparseSources = true, } = options;
|
|
7319
|
+
const { onProgress, batchSize = 10, maxRetries = 3, skipExisting = true, timeout = 10000, retryDelay = 1000, priorityZoomLevels = [], storageQuotaCheck = true, validateTiles = false, compressTiles = false, bandwidthLimit, probeSourcesBeforeDownload = true, skipKnownSparseSources = true, skipInactiveSources = true, signal, } = options;
|
|
6205
7320
|
const startTime = Date.now();
|
|
6206
7321
|
let totalSize = 0;
|
|
6207
7322
|
let downloadedTiles = 0;
|
|
@@ -6242,31 +7357,31 @@ class TileService {
|
|
|
6242
7357
|
if (tileSources.size === 0) {
|
|
6243
7358
|
throw new Error('No valid tile sources found in style definition');
|
|
6244
7359
|
}
|
|
6245
|
-
//
|
|
6246
|
-
//
|
|
7360
|
+
// Features a config-driven style has switched off (Mapbox Standard's HD
|
|
7361
|
+
// roads, indoor, landmark icons...) don't need their tiles.
|
|
7362
|
+
const { active: activeSources, inactive: inactiveSources } = classifySources(style);
|
|
7363
|
+
if (skipInactiveSources) {
|
|
7364
|
+
for (const sourceId of inactiveSources) {
|
|
7365
|
+
if (tileSources.delete(sourceId)) {
|
|
7366
|
+
tileLogger.info(`Skipping source "${sourceId}" — nothing visible uses it under the style's config`);
|
|
7367
|
+
}
|
|
7368
|
+
}
|
|
7369
|
+
}
|
|
7370
|
+
// Some sources have zoom ranges entirely outside the user's region (e.g.
|
|
7371
|
+
// procedural-buildings at z15 when the user requested z0-z14, or a z14-capped
|
|
7372
|
+
// basemap for a z15-z17 region). Generate the extra coordinates they need so
|
|
6247
7373
|
// these sources aren't silently skipped.
|
|
7374
|
+
const generatedZooms = new Set();
|
|
7375
|
+
for (let z = region.minZoom; z <= region.maxZoom; z++)
|
|
7376
|
+
generatedZooms.add(z);
|
|
6248
7377
|
for (const [sourceId, sourceConfig] of tileSources) {
|
|
6249
|
-
const
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
if (extraMinZ !== null) {
|
|
6257
|
-
const upperBound = srcMax !== undefined ? srcMax : extraMinZ;
|
|
6258
|
-
tileLogger.debug(`Source ${sourceId} needs zoom ${extraMinZ}-${upperBound} beyond region max ${region.maxZoom}, generating extra tiles`);
|
|
6259
|
-
const extraRegion = { ...region, minZoom: extraMinZ, maxZoom: upperBound };
|
|
6260
|
-
const extraCoords = this.generateTileCoordinates(extraRegion);
|
|
6261
|
-
tileCoords.push(...extraCoords);
|
|
6262
|
-
}
|
|
6263
|
-
// Extend downward (lower zoom) — sources that need zooms below region.minZoom
|
|
6264
|
-
if (extraMaxZ !== null) {
|
|
6265
|
-
const lowerBound = srcMin !== undefined ? srcMin : extraMaxZ;
|
|
6266
|
-
tileLogger.debug(`Source ${sourceId} needs zoom ${lowerBound}-${extraMaxZ} below region min ${region.minZoom}, generating extra tiles`);
|
|
6267
|
-
const extraRegion = { ...region, minZoom: lowerBound, maxZoom: extraMaxZ };
|
|
6268
|
-
const extraCoords = this.generateTileCoordinates(extraRegion);
|
|
6269
|
-
tileCoords.push(...extraCoords);
|
|
7378
|
+
const { min, max } = sourceZoomRange(region, sourceConfig.minzoom, sourceConfig.maxzoom);
|
|
7379
|
+
for (let z = min; z <= max; z++) {
|
|
7380
|
+
if (generatedZooms.has(z))
|
|
7381
|
+
continue;
|
|
7382
|
+
generatedZooms.add(z);
|
|
7383
|
+
tileLogger.debug(`Source ${sourceId} needs zoom ${z} outside region ${region.minZoom}-${region.maxZoom}, generating extra tiles`);
|
|
7384
|
+
tileCoords.push(...this.generateTileCoordinates({ ...region, minZoom: z, maxZoom: z }));
|
|
6270
7385
|
}
|
|
6271
7386
|
}
|
|
6272
7387
|
// Sort by priority zoom levels if requested (lower zoom first)
|
|
@@ -6309,14 +7424,16 @@ class TileService {
|
|
|
6309
7424
|
// issuing any network request. They 404 for nearly every coord in a
|
|
6310
7425
|
// typical region; the downstream probe step would catch them, but the
|
|
6311
7426
|
// probe itself logs 404s in devtools. Matching here keeps the console
|
|
6312
|
-
// clean.
|
|
6313
|
-
|
|
6314
|
-
|
|
7427
|
+
// clean. A sparse source a switched-on feature needs is kept instead, and
|
|
7428
|
+
// not probed, because its tiles cluster and the three probe tiles could
|
|
7429
|
+
// easily all miss.
|
|
7430
|
+
const knownSparse = tiles.some(template => urlReferencesKnownSparseTileset(template));
|
|
7431
|
+
const neededSparseSource = knownSparse && activeSources.has(sourceId);
|
|
7432
|
+
if (skipKnownSparseSources && knownSparse && !neededSparseSource) {
|
|
6315
7433
|
tileLogger.info(`Skipping source "${sourceId}" — references a known sparse Mapbox Standard sub-tileset (no probe/download issued)`);
|
|
6316
7434
|
continue;
|
|
6317
7435
|
}
|
|
6318
|
-
const sourceMinZ =
|
|
6319
|
-
const sourceMaxZ = Math.floor(sourceConfig.maxzoom ?? region.maxZoom);
|
|
7436
|
+
const { min: sourceMinZ, max: sourceMaxZ } = sourceZoomRange(region, sourceConfig.minzoom, sourceConfig.maxzoom);
|
|
6320
7437
|
let coordsForSource = tileCoords.filter(coord => coord.z >= sourceMinZ && coord.z <= sourceMaxZ);
|
|
6321
7438
|
tileLogger.debug(`After zoom filter (${sourceMinZ}-${sourceMaxZ}): ${coordsForSource.length} tiles`);
|
|
6322
7439
|
if (coordsForSource.length === 0) {
|
|
@@ -6350,7 +7467,7 @@ class TileService {
|
|
|
6350
7467
|
// A single probe on the wrong coord would false-pass and cause a
|
|
6351
7468
|
// flood of 404s during the download. Probing start/middle/end of
|
|
6352
7469
|
// the plan catches this case.
|
|
6353
|
-
if (probeSourcesBeforeDownload) {
|
|
7470
|
+
if (probeSourcesBeforeDownload && !neededSparseSource) {
|
|
6354
7471
|
const picks = [
|
|
6355
7472
|
coordsForSource[0],
|
|
6356
7473
|
coordsForSource[Math.floor(coordsForSource.length / 2)],
|
|
@@ -6409,6 +7526,9 @@ class TileService {
|
|
|
6409
7526
|
.map(([z, count]) => `Z${z}: ${count} tiles`)
|
|
6410
7527
|
.join(', '));
|
|
6411
7528
|
await processBatch(plan.coords, async (coord) => {
|
|
7529
|
+
// Cancelled: let the remaining tiles go by without fetching them.
|
|
7530
|
+
if (signal?.aborted)
|
|
7531
|
+
return;
|
|
6412
7532
|
const { x, y, z } = coord;
|
|
6413
7533
|
const label = `${plan.sourceId}:${z}/${x}/${y}`;
|
|
6414
7534
|
let tileUrl = '';
|
|
@@ -6518,7 +7638,9 @@ class TileService {
|
|
|
6518
7638
|
const errorObject = _error;
|
|
6519
7639
|
errorMessage = errorObject instanceof Error ? errorObject.message : String(errorObject);
|
|
6520
7640
|
// 404s on sparse tilesets (landmarks, POIs) are expected — don't count as failures
|
|
6521
|
-
|
|
7641
|
+
// Match the start only: the message goes on to include the URL, whose
|
|
7642
|
+
// coordinates or token can contain "404".
|
|
7643
|
+
const is404 = /^(Resource not found \(404\)|HTTP 404\b)/.test(errorMessage);
|
|
6522
7644
|
const isExpected = errorMessage.includes('NonRetryableError');
|
|
6523
7645
|
if (is404) {
|
|
6524
7646
|
skippedTiles++;
|
|
@@ -6696,13 +7818,7 @@ class TileService {
|
|
|
6696
7818
|
tileLogger.debug(`Approximate area: ${areaApproxKm2.toFixed(2)} km² (improved calculation)`);
|
|
6697
7819
|
tileLogger.debug(`Region dimensions: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km`);
|
|
6698
7820
|
for (let z = region.minZoom; z <= region.maxZoom; z++) {
|
|
6699
|
-
const
|
|
6700
|
-
const minTile = tilebelt__namespace.pointToTile(bounds[0][0], bounds[0][1], z);
|
|
6701
|
-
const maxTile = tilebelt__namespace.pointToTile(bounds[1][0], bounds[1][1], z);
|
|
6702
|
-
const minX = Math.min(minTile[0], maxTile[0]);
|
|
6703
|
-
const maxX = Math.max(minTile[0], maxTile[0]);
|
|
6704
|
-
const minY = Math.min(minTile[1], maxTile[1]);
|
|
6705
|
-
const maxY = Math.max(minTile[1], maxTile[1]);
|
|
7821
|
+
const { minX, maxX, minY, maxY } = getTileRangeAtZoom(region.bounds, z);
|
|
6706
7822
|
const tilesAtZoom = (maxX - minX + 1) * (maxY - minY + 1);
|
|
6707
7823
|
tilesByZoom[z] = tilesAtZoom;
|
|
6708
7824
|
tileLogger.debug(`Zoom ${z}: ${tilesAtZoom} tiles (X: ${minX}-${maxX}, Y: ${minY}-${maxY})`);
|
|
@@ -6736,7 +7852,10 @@ class TileService {
|
|
|
6736
7852
|
}
|
|
6737
7853
|
tileLogger.debug('Processing sources in extractTileSources:', Object.keys(style.sources));
|
|
6738
7854
|
for (const [sourceId, sourceConfig] of Object.entries(style.sources)) {
|
|
6739
|
-
|
|
7855
|
+
// Shallow copy: the maxzoom override below must not mutate the caller's style.
|
|
7856
|
+
const config = {
|
|
7857
|
+
...sourceConfig,
|
|
7858
|
+
};
|
|
6740
7859
|
tileLogger.debug(`Source ${sourceId} RAW DATA:`, JSON.stringify(config, null, 2));
|
|
6741
7860
|
tileLogger.debug(`Processing source ${sourceId}:`, {
|
|
6742
7861
|
type: config.type,
|
|
@@ -6747,18 +7866,26 @@ class TileService {
|
|
|
6747
7866
|
});
|
|
6748
7867
|
// Handle tile-based sources (vector, raster, raster-dem, batched-model,
|
|
6749
7868
|
// raster-array). `raster-array` is used by Mapbox Standard for layers
|
|
6750
|
-
// like `mapbox-landmarks` (mapbox.mapbox-landmark-icons-v1)
|
|
6751
|
-
//
|
|
6752
|
-
// TileJSON
|
|
7869
|
+
// like `mapbox-landmarks` (mapbox.mapbox-landmark-icons-v1). Each type has
|
|
7870
|
+
// its own tile endpoint (/v4/, /raster/v1/, /rasterarrays/v1/, /3dtiles/v1/),
|
|
7871
|
+
// but TileJSON gives the URL for all of them, so the resolution path below
|
|
7872
|
+
// handles them uniformly.
|
|
6753
7873
|
if (config.type === 'vector' ||
|
|
6754
7874
|
config.type === 'raster' ||
|
|
6755
7875
|
config.type === 'raster-dem' ||
|
|
6756
7876
|
config.type === 'raster-array' ||
|
|
6757
7877
|
config.type === 'batched-model') {
|
|
7878
|
+
// A style already patched for offline (every region after the first)
|
|
7879
|
+
// has idb:// tiles and a maxzoom capped to earlier regions; plan from
|
|
7880
|
+
// the upstream values patchStyleForOffline stashed instead.
|
|
7881
|
+
const sourceTiles = config.__originalTiles ?? config.tiles;
|
|
7882
|
+
if (config.__originalMaxzoom !== undefined) {
|
|
7883
|
+
config.maxzoom = config.__originalMaxzoom ?? undefined;
|
|
7884
|
+
}
|
|
6758
7885
|
// Handle direct tile URLs in the source config
|
|
6759
|
-
if (
|
|
7886
|
+
if (sourceTiles && Array.isArray(sourceTiles) && sourceTiles.length > 0) {
|
|
6760
7887
|
// Resolve mapbox:// tile URLs to HTTPS, then filter for HTTP(S) URLs
|
|
6761
|
-
const resolvedTiles =
|
|
7888
|
+
const resolvedTiles = sourceTiles.map((tile) => {
|
|
6762
7889
|
if (isMapboxProtocol(tile)) {
|
|
6763
7890
|
// Try to find an access token from the style or source URL
|
|
6764
7891
|
const accessToken = this.extractAccessTokenFromStyle(style);
|
|
@@ -6830,7 +7957,9 @@ class TileService {
|
|
|
6830
7957
|
});
|
|
6831
7958
|
tileLogger.debug(`TileJSON fetch response type: ${response.type}`);
|
|
6832
7959
|
if (response.type === 'json') {
|
|
6833
|
-
const jsonData = response.data
|
|
7960
|
+
const jsonData = response.data
|
|
7961
|
+
? selectTileJsonVariant(response.data)
|
|
7962
|
+
: null;
|
|
6834
7963
|
tileLogger.debug(`TileJSON data keys: ${Object.keys(jsonData || {}).join(', ')}`);
|
|
6835
7964
|
if (jsonData &&
|
|
6836
7965
|
typeof jsonData === 'object' &&
|
|
@@ -6934,16 +8063,9 @@ class TileService {
|
|
|
6934
8063
|
}
|
|
6935
8064
|
}
|
|
6936
8065
|
if (tileSources.size === 0) {
|
|
8066
|
+
// No fabricated fallback: a relative '{z}/{x}/{y}.pbf' template resolves
|
|
8067
|
+
// against the page and stores whatever the app server returns as tiles.
|
|
6937
8068
|
tileLogger.warn('No valid tile sources found in style', Object.keys(style.sources));
|
|
6938
|
-
// As a last resort, try to use a common vector tile source pattern if we can't extract any
|
|
6939
|
-
if (style.sources && Object.keys(style.sources).length > 0) {
|
|
6940
|
-
const firstSourceId = Object.keys(style.sources)[0];
|
|
6941
|
-
tileLogger.debug(`Attempting to create fallback source from ${firstSourceId}`);
|
|
6942
|
-
tileSources.set(firstSourceId, {
|
|
6943
|
-
type: 'vector',
|
|
6944
|
-
tiles: ['{z}/{x}/{y}.pbf'],
|
|
6945
|
-
});
|
|
6946
|
-
}
|
|
6947
8069
|
}
|
|
6948
8070
|
return tileSources;
|
|
6949
8071
|
}
|
|
@@ -7351,341 +8473,146 @@ class GlyphService {
|
|
|
7351
8473
|
health: {
|
|
7352
8474
|
corruptedGlyphs: stats.corruptedGlyphs.length,
|
|
7353
8475
|
corruptionRate: stats.count > 0 ? (stats.corruptedGlyphs.length / stats.count) * 100 : 0,
|
|
7354
|
-
},
|
|
7355
|
-
temporal: {
|
|
7356
|
-
oldestGlyph: stats.oldestGlyph,
|
|
7357
|
-
newestGlyph: stats.newestGlyph,
|
|
7358
|
-
ageSpan: stats.oldestGlyph && stats.newestGlyph
|
|
7359
|
-
? stats.newestGlyph.lastModified - stats.oldestGlyph.lastModified
|
|
7360
|
-
: 0,
|
|
7361
|
-
},
|
|
7362
|
-
};
|
|
7363
|
-
}
|
|
7364
|
-
/**
|
|
7365
|
-
* Remove glyphs older than the specified age. When `options.styleId` is
|
|
7366
|
-
* provided, only glyphs belonging to that style (per
|
|
7367
|
-
* `resourceKeyBelongsToStyle`) are eligible.
|
|
7368
|
-
*/
|
|
7369
|
-
async cleanupOldGlyphs(maxAge = 30, options = {}) {
|
|
7370
|
-
const db = await this.db;
|
|
7371
|
-
const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
|
|
7372
|
-
const { styleId } = options;
|
|
7373
|
-
let deletedCount = 0;
|
|
7374
|
-
const tx = db.transaction('glyphs', 'readwrite');
|
|
7375
|
-
for await (const cursor of tx.store) {
|
|
7376
|
-
const glyphEntry = cursor.value;
|
|
7377
|
-
const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
|
|
7378
|
-
if (belongs && glyphEntry.lastModified < cutoffTime) {
|
|
7379
|
-
await cursor.delete();
|
|
7380
|
-
deletedCount++;
|
|
7381
|
-
}
|
|
7382
|
-
}
|
|
7383
|
-
return deletedCount;
|
|
7384
|
-
}
|
|
7385
|
-
async verifyAndRepairGlyphs() {
|
|
7386
|
-
const db = await this.db;
|
|
7387
|
-
let verified = 0;
|
|
7388
|
-
// eslint-disable-next-line prefer-const -- will be incremented when repair logic is added
|
|
7389
|
-
let repaired = 0;
|
|
7390
|
-
let removed = 0;
|
|
7391
|
-
const tx = db.transaction('glyphs', 'readwrite');
|
|
7392
|
-
for await (const cursor of tx.store) {
|
|
7393
|
-
const glyphEntry = cursor.value;
|
|
7394
|
-
try {
|
|
7395
|
-
// Basic validation
|
|
7396
|
-
if (!glyphEntry.data || glyphEntry.size === 0) {
|
|
7397
|
-
await cursor.delete();
|
|
7398
|
-
removed++;
|
|
7399
|
-
}
|
|
7400
|
-
else {
|
|
7401
|
-
this.validateGlyphData(glyphEntry.data);
|
|
7402
|
-
verified++;
|
|
7403
|
-
}
|
|
7404
|
-
}
|
|
7405
|
-
catch {
|
|
7406
|
-
// Remove corrupted glyphs (they can be re-downloaded)
|
|
7407
|
-
await cursor.delete();
|
|
7408
|
-
removed++;
|
|
7409
|
-
}
|
|
7410
|
-
}
|
|
7411
|
-
return { verified, repaired, removed };
|
|
7412
|
-
}
|
|
7413
|
-
validateGlyphData(data) {
|
|
7414
|
-
if (data.byteLength < 10) {
|
|
7415
|
-
throw new Error('Glyph data too short');
|
|
7416
|
-
}
|
|
7417
|
-
// Basic Protocol Buffer validation
|
|
7418
|
-
const view = new Uint8Array(data);
|
|
7419
|
-
// Check for valid protobuf structure (basic check)
|
|
7420
|
-
if (view[0] === 0x00 && view[1] === 0x00 && view[2] === 0x00 && view[3] === 0x00) {
|
|
7421
|
-
throw new Error('Invalid glyph data - appears to be empty');
|
|
7422
|
-
}
|
|
7423
|
-
}
|
|
7424
|
-
normalizeFontstack(fontstack) {
|
|
7425
|
-
try {
|
|
7426
|
-
return decodeURIComponent(fontstack);
|
|
7427
|
-
}
|
|
7428
|
-
catch {
|
|
7429
|
-
return fontstack;
|
|
7430
|
-
}
|
|
7431
|
-
}
|
|
7432
|
-
buildGlyphPath(fontstack, range) {
|
|
7433
|
-
const normalizedFontstack = this.normalizeFontstack(fontstack);
|
|
7434
|
-
return `${normalizedFontstack}/${range}.pbf`;
|
|
7435
|
-
}
|
|
7436
|
-
parseGlyphKey(key) {
|
|
7437
|
-
let remaining = key;
|
|
7438
|
-
let styleId;
|
|
7439
|
-
if (remaining.includes('::')) {
|
|
7440
|
-
const [prefix, ...rest] = remaining.split('::');
|
|
7441
|
-
styleId = prefix;
|
|
7442
|
-
remaining = rest.join('::');
|
|
7443
|
-
}
|
|
7444
|
-
else if (remaining.includes(':') && !remaining.includes('/')) {
|
|
7445
|
-
const [prefix, rest] = remaining.split(':');
|
|
7446
|
-
styleId = styleId ?? prefix;
|
|
7447
|
-
remaining = rest;
|
|
7448
|
-
}
|
|
7449
|
-
const trimmed = remaining.endsWith('.pbf') ? remaining.slice(0, -4) : remaining;
|
|
7450
|
-
if (trimmed.includes('/')) {
|
|
7451
|
-
const [fontstack, range] = trimmed.split('/');
|
|
7452
|
-
if (fontstack && range) {
|
|
7453
|
-
return { styleId, fontstack, range };
|
|
7454
|
-
}
|
|
7455
|
-
}
|
|
7456
|
-
if (trimmed.includes('_')) {
|
|
7457
|
-
const [fontstack, range] = trimmed.split('_');
|
|
7458
|
-
if (fontstack && range) {
|
|
7459
|
-
return { styleId, fontstack, range };
|
|
7460
|
-
}
|
|
7461
|
-
}
|
|
7462
|
-
return { styleId, fontstack: trimmed, range: '' };
|
|
7463
|
-
}
|
|
7464
|
-
createGlyphKey(fontstack, range, styleName) {
|
|
7465
|
-
// Create a consistent key from the style name, fontstack and range
|
|
7466
|
-
// Format: stylename::fontstack/range.pbf (if styleName provided) or fontstack/range.pbf
|
|
7467
|
-
const glyphPath = this.buildGlyphPath(fontstack, range);
|
|
7468
|
-
if (styleName) {
|
|
7469
|
-
return `${styleName}::${glyphPath}`;
|
|
7470
|
-
}
|
|
7471
|
-
// Fallback to original format for backward compatibility
|
|
7472
|
-
return glyphPath;
|
|
7473
|
-
}
|
|
7474
|
-
}
|
|
7475
|
-
// Export singleton instance and functions for backward compatibility
|
|
7476
|
-
const glyphService = new GlyphService();
|
|
7477
|
-
const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => glyphService.downloadGlyphs(glyphUrl, fontstacks, styleName, ranges, options);
|
|
7478
|
-
const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
|
|
7479
|
-
const getGlyphStats = () => glyphService.getGlyphStats();
|
|
7480
|
-
const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
|
|
7481
|
-
const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
|
|
7482
|
-
const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
|
|
7483
|
-
|
|
7484
|
-
var glyphService$1 = /*#__PURE__*/Object.freeze({
|
|
7485
|
-
__proto__: null,
|
|
7486
|
-
GlyphService: GlyphService,
|
|
7487
|
-
cleanupOldGlyphs: cleanupOldGlyphs,
|
|
7488
|
-
downloadGlyphs: downloadGlyphs,
|
|
7489
|
-
getGlyphAnalytics: getGlyphAnalytics,
|
|
7490
|
-
getGlyphStats: getGlyphStats,
|
|
7491
|
-
glyphService: glyphService,
|
|
7492
|
-
loadGlyphs: loadGlyphs,
|
|
7493
|
-
verifyAndRepairGlyphs: verifyAndRepairGlyphs
|
|
7494
|
-
});
|
|
7495
|
-
|
|
7496
|
-
const modelLogger = logger.scope('ModelService');
|
|
7497
|
-
/**
|
|
7498
|
-
* Build the storage key for a model. Kept consistent with sprite/glyph
|
|
7499
|
-
* conventions: `{styleId}::model::{modelName}`.
|
|
7500
|
-
*/
|
|
7501
|
-
function modelKey(styleId, modelName) {
|
|
7502
|
-
return `${styleId}::model::${modelName}`;
|
|
7503
|
-
}
|
|
7504
|
-
/** True when the given key belongs to the given styleId's model store prefix. */
|
|
7505
|
-
function modelKeyBelongsToStyle(key, styleId) {
|
|
7506
|
-
return key.startsWith(`${styleId}::model::`);
|
|
7507
|
-
}
|
|
7508
|
-
/**
|
|
7509
|
-
* Service for downloading, storing, and serving Mapbox 3D model (.glb) files.
|
|
7510
|
-
*
|
|
7511
|
-
* Mapbox Standard declares 32 models at the top of `style.models`:
|
|
7512
|
-
*
|
|
7513
|
-
* ```json
|
|
7514
|
-
* {
|
|
7515
|
-
* "maple1-lod1": "mapbox://models/mapbox/maple1-v4-lod1.glb",
|
|
7516
|
-
* ...
|
|
7517
|
-
* }
|
|
7518
|
-
* ```
|
|
7519
|
-
*
|
|
7520
|
-
* `model` layers (e.g. `trees`, `wind-turbine-towers`) pick one by name at
|
|
7521
|
-
* render time. For offline use each referenced URL is fetched and stored
|
|
7522
|
-
* here, and `patchStyleForOffline` rewrites the dictionary entries to
|
|
7523
|
-
* `idb://{styleId}/model/{name}` URLs.
|
|
7524
|
-
*/
|
|
7525
|
-
class ModelService {
|
|
7526
|
-
db = dbPromise;
|
|
7527
|
-
/**
|
|
7528
|
-
* Download the set of models referenced by `style.models` for one style.
|
|
7529
|
-
*
|
|
7530
|
-
* @param models `{ modelName: resolvedHttpUrl }` — URLs must already be
|
|
7531
|
-
* resolved (mapbox:// URLs should be resolved by the caller).
|
|
7532
|
-
* @param styleId The owning style's key.
|
|
7533
|
-
*/
|
|
7534
|
-
async downloadModels(models, styleId, options = {}) {
|
|
7535
|
-
const db = await this.db;
|
|
7536
|
-
const { onProgress, batchSize = 4, maxRetries = 3, skipExisting = true, timeoutMs = 30000, } = options;
|
|
7537
|
-
const entries = Object.entries(models);
|
|
7538
|
-
const progressTracker = createProgressTracker(entries.length);
|
|
7539
|
-
const result = {
|
|
7540
|
-
totalModels: entries.length,
|
|
7541
|
-
downloadedModels: 0,
|
|
7542
|
-
skippedModels: 0,
|
|
7543
|
-
failedModels: 0,
|
|
7544
|
-
totalSize: 0,
|
|
7545
|
-
errors: [],
|
|
7546
|
-
};
|
|
7547
|
-
const emit = () => onProgress?.(progressTracker.getProgress());
|
|
7548
|
-
emit();
|
|
7549
|
-
if (entries.length === 0)
|
|
7550
|
-
return result;
|
|
7551
|
-
// Pre-compute existing keys for skipExisting
|
|
7552
|
-
const existingKeys = new Set();
|
|
7553
|
-
if (skipExisting) {
|
|
7554
|
-
const tx = db.transaction('models', 'readonly');
|
|
7555
|
-
for await (const cursor of tx.store) {
|
|
7556
|
-
existingKeys.add(cursor.value.key);
|
|
7557
|
-
}
|
|
7558
|
-
}
|
|
7559
|
-
await processBatch(entries, async ([modelName, url]) => {
|
|
7560
|
-
const key = modelKey(styleId, modelName);
|
|
7561
|
-
const label = `${styleId}::${modelName}`;
|
|
7562
|
-
if (skipExisting && existingKeys.has(key)) {
|
|
7563
|
-
result.skippedModels++;
|
|
7564
|
-
progressTracker.update(1, label);
|
|
7565
|
-
emit();
|
|
7566
|
-
return;
|
|
7567
|
-
}
|
|
7568
|
-
try {
|
|
7569
|
-
const response = await fetchResourceWithRetry(url, {
|
|
7570
|
-
retries: maxRetries,
|
|
7571
|
-
timeout: timeoutMs,
|
|
7572
|
-
proxyType: 'tiles',
|
|
7573
|
-
});
|
|
7574
|
-
if (response.type === 'json') {
|
|
7575
|
-
throw new Error('Unexpected JSON response for model');
|
|
7576
|
-
}
|
|
7577
|
-
const data = response.data;
|
|
7578
|
-
const contentType = ('contentType' in response && response.contentType) || 'model/gltf-binary';
|
|
7579
|
-
const entry = {
|
|
7580
|
-
key,
|
|
7581
|
-
data,
|
|
7582
|
-
contentType,
|
|
7583
|
-
size: data.byteLength,
|
|
7584
|
-
url,
|
|
7585
|
-
styleId,
|
|
7586
|
-
modelName,
|
|
7587
|
-
lastModified: Date.now(),
|
|
7588
|
-
downloadedAt: new Date().toISOString(),
|
|
7589
|
-
expires: response.expires,
|
|
7590
|
-
};
|
|
7591
|
-
await db.put('models', entry);
|
|
7592
|
-
result.downloadedModels++;
|
|
7593
|
-
result.totalSize += data.byteLength;
|
|
7594
|
-
progressTracker.update(1, label);
|
|
7595
|
-
}
|
|
7596
|
-
catch (err) {
|
|
7597
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
7598
|
-
result.failedModels++;
|
|
7599
|
-
result.errors.push({ url, error: message });
|
|
7600
|
-
modelLogger.warn(`Failed to download model "${modelName}" from ${url}:`, err);
|
|
7601
|
-
progressTracker.update(1, label, message);
|
|
7602
|
-
}
|
|
7603
|
-
emit();
|
|
7604
|
-
}, { batchSize });
|
|
7605
|
-
modelLogger.info(`Models downloaded for style ${styleId}: ${result.downloadedModels} new, ${result.skippedModels} skipped, ${result.failedModels} failed`);
|
|
7606
|
-
return result;
|
|
7607
|
-
}
|
|
7608
|
-
/** Retrieve a single model by `{styleId, modelName}`. */
|
|
7609
|
-
async getModel(styleId, modelName) {
|
|
7610
|
-
const db = await this.db;
|
|
7611
|
-
return db.get('models', modelKey(styleId, modelName));
|
|
7612
|
-
}
|
|
7613
|
-
/** Aggregate stats across all stored models. */
|
|
7614
|
-
async getModelStats() {
|
|
7615
|
-
const db = await this.db;
|
|
7616
|
-
const stats = {
|
|
7617
|
-
count: 0,
|
|
7618
|
-
totalSize: 0,
|
|
7619
|
-
averageSize: 0,
|
|
7620
|
-
models: [],
|
|
7621
|
-
modelsByStyle: {},
|
|
8476
|
+
},
|
|
8477
|
+
temporal: {
|
|
8478
|
+
oldestGlyph: stats.oldestGlyph,
|
|
8479
|
+
newestGlyph: stats.newestGlyph,
|
|
8480
|
+
ageSpan: stats.oldestGlyph && stats.newestGlyph
|
|
8481
|
+
? stats.newestGlyph.lastModified - stats.oldestGlyph.lastModified
|
|
8482
|
+
: 0,
|
|
8483
|
+
},
|
|
7622
8484
|
};
|
|
7623
|
-
const tx = db.transaction('models', 'readonly');
|
|
7624
|
-
for await (const cursor of tx.store) {
|
|
7625
|
-
const m = cursor.value;
|
|
7626
|
-
stats.count++;
|
|
7627
|
-
stats.totalSize += m.size;
|
|
7628
|
-
stats.models.push({ name: m.modelName, size: m.size, lastModified: m.lastModified });
|
|
7629
|
-
stats.modelsByStyle[m.styleId] = (stats.modelsByStyle[m.styleId] ?? 0) + 1;
|
|
7630
|
-
}
|
|
7631
|
-
stats.averageSize = stats.count > 0 ? stats.totalSize / stats.count : 0;
|
|
7632
|
-
return stats;
|
|
7633
8485
|
}
|
|
7634
8486
|
/**
|
|
7635
|
-
*
|
|
8487
|
+
* Remove glyphs older than the specified age. When `options.styleId` is
|
|
8488
|
+
* provided, only glyphs belonging to that style (per
|
|
8489
|
+
* `resourceKeyBelongsToStyle`) are eligible.
|
|
7636
8490
|
*/
|
|
7637
|
-
async
|
|
8491
|
+
async cleanupOldGlyphs(maxAge = 30, options = {}) {
|
|
7638
8492
|
const db = await this.db;
|
|
7639
|
-
const
|
|
7640
|
-
|
|
7641
|
-
|
|
8493
|
+
const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
|
|
8494
|
+
const { styleId } = options;
|
|
8495
|
+
let deletedCount = 0;
|
|
8496
|
+
const tx = db.transaction('glyphs', 'readwrite');
|
|
7642
8497
|
for await (const cursor of tx.store) {
|
|
7643
|
-
|
|
8498
|
+
const glyphEntry = cursor.value;
|
|
8499
|
+
const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
|
|
8500
|
+
if (belongs && glyphEntry.lastModified < cutoffTime) {
|
|
7644
8501
|
await cursor.delete();
|
|
7645
|
-
|
|
8502
|
+
deletedCount++;
|
|
7646
8503
|
}
|
|
7647
8504
|
}
|
|
7648
|
-
return
|
|
8505
|
+
return deletedCount;
|
|
7649
8506
|
}
|
|
7650
|
-
|
|
7651
|
-
* Basic integrity check: remove entries with empty/missing data.
|
|
7652
|
-
*/
|
|
7653
|
-
async verifyAndRepairModels() {
|
|
8507
|
+
async verifyAndRepairGlyphs() {
|
|
7654
8508
|
const db = await this.db;
|
|
7655
8509
|
let verified = 0;
|
|
8510
|
+
// eslint-disable-next-line prefer-const -- will be incremented when repair logic is added
|
|
8511
|
+
let repaired = 0;
|
|
7656
8512
|
let removed = 0;
|
|
7657
|
-
const tx = db.transaction('
|
|
8513
|
+
const tx = db.transaction('glyphs', 'readwrite');
|
|
7658
8514
|
for await (const cursor of tx.store) {
|
|
7659
|
-
const
|
|
7660
|
-
|
|
8515
|
+
const glyphEntry = cursor.value;
|
|
8516
|
+
try {
|
|
8517
|
+
// Basic validation
|
|
8518
|
+
if (!glyphEntry.data || glyphEntry.size === 0) {
|
|
8519
|
+
await cursor.delete();
|
|
8520
|
+
removed++;
|
|
8521
|
+
}
|
|
8522
|
+
else {
|
|
8523
|
+
this.validateGlyphData(glyphEntry.data);
|
|
8524
|
+
verified++;
|
|
8525
|
+
}
|
|
8526
|
+
}
|
|
8527
|
+
catch {
|
|
8528
|
+
// Remove corrupted glyphs (they can be re-downloaded)
|
|
7661
8529
|
await cursor.delete();
|
|
7662
8530
|
removed++;
|
|
7663
8531
|
}
|
|
7664
|
-
|
|
7665
|
-
|
|
8532
|
+
}
|
|
8533
|
+
return { verified, repaired, removed };
|
|
8534
|
+
}
|
|
8535
|
+
validateGlyphData(data) {
|
|
8536
|
+
if (data.byteLength < 10) {
|
|
8537
|
+
throw new Error('Glyph data too short');
|
|
8538
|
+
}
|
|
8539
|
+
// Basic Protocol Buffer validation
|
|
8540
|
+
const view = new Uint8Array(data);
|
|
8541
|
+
// Check for valid protobuf structure (basic check)
|
|
8542
|
+
if (view[0] === 0x00 && view[1] === 0x00 && view[2] === 0x00 && view[3] === 0x00) {
|
|
8543
|
+
throw new Error('Invalid glyph data - appears to be empty');
|
|
8544
|
+
}
|
|
8545
|
+
}
|
|
8546
|
+
normalizeFontstack(fontstack) {
|
|
8547
|
+
try {
|
|
8548
|
+
return decodeURIComponent(fontstack);
|
|
8549
|
+
}
|
|
8550
|
+
catch {
|
|
8551
|
+
return fontstack;
|
|
8552
|
+
}
|
|
8553
|
+
}
|
|
8554
|
+
buildGlyphPath(fontstack, range) {
|
|
8555
|
+
const normalizedFontstack = this.normalizeFontstack(fontstack);
|
|
8556
|
+
return `${normalizedFontstack}/${range}.pbf`;
|
|
8557
|
+
}
|
|
8558
|
+
parseGlyphKey(key) {
|
|
8559
|
+
let remaining = key;
|
|
8560
|
+
let styleId;
|
|
8561
|
+
if (remaining.includes('::')) {
|
|
8562
|
+
const [prefix, ...rest] = remaining.split('::');
|
|
8563
|
+
styleId = prefix;
|
|
8564
|
+
remaining = rest.join('::');
|
|
8565
|
+
}
|
|
8566
|
+
else if (remaining.includes(':') && !remaining.includes('/')) {
|
|
8567
|
+
const [prefix, rest] = remaining.split(':');
|
|
8568
|
+
styleId = styleId ?? prefix;
|
|
8569
|
+
remaining = rest;
|
|
8570
|
+
}
|
|
8571
|
+
const trimmed = remaining.endsWith('.pbf') ? remaining.slice(0, -4) : remaining;
|
|
8572
|
+
if (trimmed.includes('/')) {
|
|
8573
|
+
const [fontstack, range] = trimmed.split('/');
|
|
8574
|
+
if (fontstack && range) {
|
|
8575
|
+
return { styleId, fontstack, range };
|
|
7666
8576
|
}
|
|
7667
8577
|
}
|
|
7668
|
-
|
|
8578
|
+
if (trimmed.includes('_')) {
|
|
8579
|
+
const [fontstack, range] = trimmed.split('_');
|
|
8580
|
+
if (fontstack && range) {
|
|
8581
|
+
return { styleId, fontstack, range };
|
|
8582
|
+
}
|
|
8583
|
+
}
|
|
8584
|
+
return { styleId, fontstack: trimmed, range: '' };
|
|
8585
|
+
}
|
|
8586
|
+
createGlyphKey(fontstack, range, styleName) {
|
|
8587
|
+
// Create a consistent key from the style name, fontstack and range
|
|
8588
|
+
// Format: stylename::fontstack/range.pbf (if styleName provided) or fontstack/range.pbf
|
|
8589
|
+
const glyphPath = this.buildGlyphPath(fontstack, range);
|
|
8590
|
+
if (styleName) {
|
|
8591
|
+
return `${styleName}::${glyphPath}`;
|
|
8592
|
+
}
|
|
8593
|
+
// Fallback to original format for backward compatibility
|
|
8594
|
+
return glyphPath;
|
|
7669
8595
|
}
|
|
7670
8596
|
}
|
|
7671
|
-
//
|
|
7672
|
-
const
|
|
7673
|
-
const
|
|
7674
|
-
const
|
|
7675
|
-
const
|
|
7676
|
-
const
|
|
7677
|
-
const
|
|
8597
|
+
// Export singleton instance and functions for backward compatibility
|
|
8598
|
+
const glyphService = new GlyphService();
|
|
8599
|
+
const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => glyphService.downloadGlyphs(glyphUrl, fontstacks, styleName, ranges, options);
|
|
8600
|
+
const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
|
|
8601
|
+
const getGlyphStats = () => glyphService.getGlyphStats();
|
|
8602
|
+
const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
|
|
8603
|
+
const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
|
|
8604
|
+
const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
|
|
7678
8605
|
|
|
7679
|
-
var
|
|
8606
|
+
var glyphService$1 = /*#__PURE__*/Object.freeze({
|
|
7680
8607
|
__proto__: null,
|
|
7681
|
-
|
|
7682
|
-
|
|
7683
|
-
|
|
7684
|
-
|
|
7685
|
-
|
|
7686
|
-
|
|
7687
|
-
|
|
7688
|
-
|
|
8608
|
+
GlyphService: GlyphService,
|
|
8609
|
+
cleanupOldGlyphs: cleanupOldGlyphs,
|
|
8610
|
+
downloadGlyphs: downloadGlyphs,
|
|
8611
|
+
getGlyphAnalytics: getGlyphAnalytics,
|
|
8612
|
+
getGlyphStats: getGlyphStats,
|
|
8613
|
+
glyphService: glyphService,
|
|
8614
|
+
loadGlyphs: loadGlyphs,
|
|
8615
|
+
verifyAndRepairGlyphs: verifyAndRepairGlyphs
|
|
7689
8616
|
});
|
|
7690
8617
|
|
|
7691
8618
|
class ResourceService {
|
|
@@ -7785,23 +8712,26 @@ class AnalyticsService {
|
|
|
7785
8712
|
return getGlyphStats();
|
|
7786
8713
|
}
|
|
7787
8714
|
async getComprehensiveStorageAnalytics(getRegionAnalytics) {
|
|
7788
|
-
const [tileStats, fontStats, spriteStats, glyphStats, regionAnalytics] = await Promise.all([
|
|
8715
|
+
const [tileStats, fontStats, spriteStats, glyphStats, modelStats, styleStats, regionAnalytics] = await Promise.all([
|
|
7789
8716
|
this.getAllTileStats(),
|
|
7790
8717
|
this.getAllFontStats(),
|
|
7791
8718
|
this.getAllSpriteStats(),
|
|
7792
8719
|
this.getAllGlyphStats(),
|
|
8720
|
+
getModelStats(),
|
|
8721
|
+
getStyleStats(),
|
|
7793
8722
|
getRegionAnalytics(),
|
|
7794
8723
|
]);
|
|
7795
|
-
const totalStorageSize = (tileStats.totalSize || 0) +
|
|
7796
|
-
(fontStats.totalSize || 0) +
|
|
7797
|
-
(spriteStats.totalSize || 0) +
|
|
7798
|
-
(glyphStats.totalSize || 0);
|
|
7799
8724
|
const storageByType = {
|
|
8725
|
+
// The style documents themselves, which the panel's per-style totals include.
|
|
8726
|
+
styles: styleStats.totalSize || 0,
|
|
7800
8727
|
tiles: tileStats.totalSize || 0,
|
|
7801
8728
|
fonts: fontStats.totalSize || 0,
|
|
7802
8729
|
sprites: spriteStats.totalSize || 0,
|
|
7803
8730
|
glyphs: glyphStats.totalSize || 0,
|
|
8731
|
+
// Mapbox Standard 3D models (.glb) — often several MB each.
|
|
8732
|
+
models: modelStats.totalSize || 0,
|
|
7804
8733
|
};
|
|
8734
|
+
const totalStorageSize = Object.values(storageByType).reduce((sum, size) => sum + size, 0);
|
|
7805
8735
|
const recommendations = this.generateStorageRecommendations({
|
|
7806
8736
|
tiles: tileStats,
|
|
7807
8737
|
fonts: fontStats,
|
|
@@ -7815,6 +8745,7 @@ class AnalyticsService {
|
|
|
7815
8745
|
fonts: fontStats,
|
|
7816
8746
|
sprites: spriteStats,
|
|
7817
8747
|
glyphs: glyphStats,
|
|
8748
|
+
models: modelStats,
|
|
7818
8749
|
regions: regionAnalytics,
|
|
7819
8750
|
totalStorageSize,
|
|
7820
8751
|
storageByType,
|
|
@@ -7946,6 +8877,53 @@ function buildVectorJsonMetadata(style, sourceIds) {
|
|
|
7946
8877
|
return JSON.stringify({ vector_layers: merged });
|
|
7947
8878
|
}
|
|
7948
8879
|
const serviceLogger = logger.scope('ImportExportService');
|
|
8880
|
+
/** Map stored tile extensions to MBTiles 1.3 `format` values (`pbf`, `jpg`, ...). */
|
|
8881
|
+
function normalizeMBTilesFormat(ext) {
|
|
8882
|
+
const lower = ext.toLowerCase();
|
|
8883
|
+
if (lower === 'mvt')
|
|
8884
|
+
return 'pbf';
|
|
8885
|
+
if (lower === 'jpeg')
|
|
8886
|
+
return 'jpg';
|
|
8887
|
+
return lower;
|
|
8888
|
+
}
|
|
8889
|
+
/**
|
|
8890
|
+
* MBTiles holds one tile per z/x/y, so tiles from several style sources can't
|
|
8891
|
+
* share a file — they'd overwrite each other at every shared coordinate.
|
|
8892
|
+
* Keep only `sourceId` when given, otherwise the source with the most tiles
|
|
8893
|
+
* (the basemap in practice), and warn about what was left out.
|
|
8894
|
+
*/
|
|
8895
|
+
function selectSingleSource(tiles, sourceId) {
|
|
8896
|
+
const bySource = new Map();
|
|
8897
|
+
for (const tile of tiles) {
|
|
8898
|
+
const group = bySource.get(tile.sourceId);
|
|
8899
|
+
if (group)
|
|
8900
|
+
group.push(tile);
|
|
8901
|
+
else
|
|
8902
|
+
bySource.set(tile.sourceId, [tile]);
|
|
8903
|
+
}
|
|
8904
|
+
if (sourceId !== undefined) {
|
|
8905
|
+
const selected = bySource.get(sourceId);
|
|
8906
|
+
if (!selected) {
|
|
8907
|
+
const available = [...bySource.keys()].join(', ') || 'none';
|
|
8908
|
+
throw new Error(`Source "${sourceId}" has no tiles in this region (available: ${available})`);
|
|
8909
|
+
}
|
|
8910
|
+
return selected;
|
|
8911
|
+
}
|
|
8912
|
+
if (bySource.size <= 1)
|
|
8913
|
+
return tiles;
|
|
8914
|
+
let chosen = '';
|
|
8915
|
+
let chosenTiles = [];
|
|
8916
|
+
for (const [id, group] of bySource) {
|
|
8917
|
+
if (group.length > chosenTiles.length) {
|
|
8918
|
+
chosen = id;
|
|
8919
|
+
chosenTiles = group;
|
|
8920
|
+
}
|
|
8921
|
+
}
|
|
8922
|
+
const skipped = [...bySource.keys()].filter(id => id !== chosen);
|
|
8923
|
+
serviceLogger.warn(`Region has tiles from ${bySource.size} sources; MBTiles holds one. Exporting "${chosen}", ` +
|
|
8924
|
+
`skipping ${skipped.join(', ')}. Pass { sourceId } to choose another.`);
|
|
8925
|
+
return chosenTiles;
|
|
8926
|
+
}
|
|
7949
8927
|
class ImportExportService {
|
|
7950
8928
|
db = dbPromise;
|
|
7951
8929
|
constructor() {
|
|
@@ -7970,10 +8948,11 @@ class ImportExportService {
|
|
|
7970
8948
|
if (!region) {
|
|
7971
8949
|
throw new Error(`Region ${regionId} not found`);
|
|
7972
8950
|
}
|
|
7973
|
-
const
|
|
7974
|
-
|
|
7975
|
-
//
|
|
7976
|
-
|
|
8951
|
+
const regionTiles = await this.exportTiles(region, onProgress);
|
|
8952
|
+
const tiles = selectSingleSource(regionTiles, options.sourceId);
|
|
8953
|
+
// Pick format: caller override → stored tile extension → region.tileExtension
|
|
8954
|
+
// → default pbf. Drives both the metadata row and whether tile bytes get gzipped.
|
|
8955
|
+
const format = normalizeMBTilesFormat(String(options.format || tiles[0]?.format || region.tileExtension || 'pbf'));
|
|
7977
8956
|
const isVector = VECTOR_FORMATS.has(format);
|
|
7978
8957
|
onProgress({
|
|
7979
8958
|
stage: 'processing',
|
|
@@ -8198,11 +9177,19 @@ class ImportExportService {
|
|
|
8198
9177
|
/**
|
|
8199
9178
|
* Export tiles data
|
|
8200
9179
|
*/
|
|
8201
|
-
async exportTiles(
|
|
9180
|
+
async exportTiles(region, onProgress) {
|
|
8202
9181
|
const db = await this.db;
|
|
8203
|
-
|
|
8204
|
-
|
|
8205
|
-
|
|
9182
|
+
const styleId = region.styleId || region.id;
|
|
9183
|
+
// Tiles are stored per style, not per region. When the style holds other
|
|
9184
|
+
// regions too, keep only tiles inside this region's bounds and the zooms
|
|
9185
|
+
// the download pipeline fetches for each source (which can fall outside
|
|
9186
|
+
// the region's own range — see sourceZoomRange), or sibling regions' tiles
|
|
9187
|
+
// leak into the export. A sole region owns all of its style's tiles — and
|
|
9188
|
+
// an imported region's bounds come from MBTiles metadata, which can be
|
|
9189
|
+
// tighter than the tiles — so don't filter then.
|
|
9190
|
+
const styleEntry = await db.get('styles', styleId);
|
|
9191
|
+
const sharesStyle = (styleEntry?.regions?.length ?? 0) > 1;
|
|
9192
|
+
const styleSources = (styleEntry?.style?.sources ?? {});
|
|
8206
9193
|
const transaction = db.transaction(['tiles'], 'readonly');
|
|
8207
9194
|
const store = transaction.objectStore('tiles');
|
|
8208
9195
|
const tiles = [];
|
|
@@ -8211,22 +9198,31 @@ class ImportExportService {
|
|
|
8211
9198
|
let processed = 0;
|
|
8212
9199
|
while (cursor) {
|
|
8213
9200
|
const tile = cursor.value;
|
|
8214
|
-
|
|
8215
|
-
|
|
9201
|
+
const parsed = parseTileKey(String(tile.key));
|
|
9202
|
+
const z = tile.z ?? parsed?.z;
|
|
9203
|
+
const x = tile.x ?? parsed?.x;
|
|
9204
|
+
const y = tile.y ?? parsed?.y;
|
|
9205
|
+
if (tile.styleId === styleId &&
|
|
9206
|
+
z !== undefined &&
|
|
9207
|
+
x !== undefined &&
|
|
9208
|
+
y !== undefined &&
|
|
9209
|
+
(!sharesStyle ||
|
|
9210
|
+
tileBelongsToRegion({ sourceId: tile.sourceId ?? parsed?.sourceId, z, x, y }, region, styleSources))) {
|
|
8216
9211
|
tiles.push({
|
|
8217
|
-
z
|
|
8218
|
-
x
|
|
8219
|
-
y
|
|
9212
|
+
z,
|
|
9213
|
+
x,
|
|
9214
|
+
y,
|
|
8220
9215
|
data: tile.data,
|
|
8221
|
-
format: 'pbf',
|
|
8222
|
-
sourceId: tile.sourceId ?? 'default',
|
|
9216
|
+
format: (parsed?.ext || 'pbf').toLowerCase(),
|
|
9217
|
+
sourceId: tile.sourceId ?? parsed?.sourceId ?? 'default',
|
|
8223
9218
|
});
|
|
8224
9219
|
}
|
|
8225
9220
|
processed++;
|
|
8226
9221
|
if (onProgress && processed % 100 === 0) {
|
|
8227
9222
|
onProgress({
|
|
8228
9223
|
stage: 'exporting',
|
|
8229
|
-
|
|
9224
|
+
// Total is unknown while walking the cursor; approach 70% asymptotically.
|
|
9225
|
+
percentage: 30 + 40 * (1 - 1 / (1 + processed / 1000)),
|
|
8230
9226
|
message: `Exported ${processed} tiles...`,
|
|
8231
9227
|
currentItem: `${tile.z ?? 0}/${tile.x ?? 0}/${tile.y ?? 0}`,
|
|
8232
9228
|
completedItems: processed,
|
|
@@ -8407,7 +9403,7 @@ class ImportExportService {
|
|
|
8407
9403
|
sourceId,
|
|
8408
9404
|
downloadedAt: new Date().toISOString(),
|
|
8409
9405
|
size: tile.data instanceof ArrayBuffer ? tile.data.byteLength : 0,
|
|
8410
|
-
type: 'vector',
|
|
9406
|
+
type: VECTOR_FORMATS.has(ext) ? 'vector' : 'raster',
|
|
8411
9407
|
url: `tile://${tile.z}/${tile.x}/${tile.y}`,
|
|
8412
9408
|
lastModified: Date.now(),
|
|
8413
9409
|
});
|
|
@@ -8451,7 +9447,7 @@ const createRegionManagement = (services) => ({
|
|
|
8451
9447
|
addRegion: async (region) => services.regionService.addRegion(region),
|
|
8452
9448
|
downloadRegion: async (region, options) => services.regionService.downloadRegion(region, options),
|
|
8453
9449
|
loadRegion: async (region, options) => services.regionService.loadRegion(region, options),
|
|
8454
|
-
deleteRegion: async (regionId) => services.regionService.deleteRegion(regionId),
|
|
9450
|
+
deleteRegion: async (regionId, styleId) => services.regionService.deleteRegion(regionId, styleId),
|
|
8455
9451
|
listRegions: async () => services.regionService.listRegions(),
|
|
8456
9452
|
listStoredRegions: async () => services.regionService.listStoredRegions(),
|
|
8457
9453
|
getStoredRegion: async (regionId) => {
|
|
@@ -8473,7 +9469,7 @@ const createCleanupManagement = (services) => ({
|
|
|
8473
9469
|
let deletedCount = 0;
|
|
8474
9470
|
for (const region of allRegions) {
|
|
8475
9471
|
if (region.expiry && region.expiry < now) {
|
|
8476
|
-
await services.regionService.deleteRegion(region.id);
|
|
9472
|
+
await services.regionService.deleteRegion(region.id, region.styleId);
|
|
8477
9473
|
deletedCount++;
|
|
8478
9474
|
}
|
|
8479
9475
|
}
|
|
@@ -8924,7 +9920,7 @@ const en = {
|
|
|
8924
9920
|
'language.ar': 'Arabic',
|
|
8925
9921
|
// Header
|
|
8926
9922
|
'header.title': 'Offline Manager',
|
|
8927
|
-
'header.subtitle': '{{
|
|
9923
|
+
'header.subtitle': '{{regions}} • {{size}} total',
|
|
8928
9924
|
// Actions
|
|
8929
9925
|
'actions.addRegion': 'Add Region',
|
|
8930
9926
|
'actions.refresh': 'Refresh',
|
|
@@ -8940,6 +9936,8 @@ const en = {
|
|
|
8940
9936
|
'regionForm.namePlaceholder': 'e.g., Downtown District',
|
|
8941
9937
|
'regionForm.minZoom': 'Min Zoom',
|
|
8942
9938
|
'regionForm.maxZoom': 'Max Zoom',
|
|
9939
|
+
'regionForm.buildingsHint': "Mapbox Standard's buildings start at zoom 15. Set Max Zoom to 15 or more to include them.",
|
|
9940
|
+
'regionForm.zoomRangeError': 'Max Zoom must be at least Min Zoom.',
|
|
8943
9941
|
'regionForm.styleUrl': 'Style URL',
|
|
8944
9942
|
'regionForm.provider': 'Style Provider',
|
|
8945
9943
|
'regionForm.providerAuto': 'Auto-detect',
|
|
@@ -8969,7 +9967,10 @@ const en = {
|
|
|
8969
9967
|
'regionList.downloaded': 'Downloaded',
|
|
8970
9968
|
'regionList.size': 'Size',
|
|
8971
9969
|
'regionList.tiles': 'Tiles',
|
|
9970
|
+
'regionList.resources': 'Fonts, icons and models',
|
|
8972
9971
|
'regionList.styleData': 'Style',
|
|
9972
|
+
'regionList.regionCountOne': '1 region',
|
|
9973
|
+
'regionList.regionCount': '{{count}} regions',
|
|
8973
9974
|
'regionList.totalSize': 'Total',
|
|
8974
9975
|
'regionList.styleId': 'Style ID',
|
|
8975
9976
|
// Region Details
|
|
@@ -9055,6 +10056,13 @@ const en = {
|
|
|
9055
10056
|
'mbtiles.importFailed': 'Import failed. Please try again.',
|
|
9056
10057
|
// Active Downloads
|
|
9057
10058
|
'download.activeCount': 'Active Downloads ({{count}})',
|
|
10059
|
+
'download.status.style': 'Preparing style',
|
|
10060
|
+
'download.status.sprites': 'Sprites: {{completed}} of {{total}}',
|
|
10061
|
+
'download.status.glyphs': 'Fonts: {{completed}} of {{total}}',
|
|
10062
|
+
'download.status.models': '3D models: {{completed}} of {{total}}',
|
|
10063
|
+
'download.status.tiles': 'Tiles: {{completed}} of {{total}}',
|
|
10064
|
+
'download.status.planning': 'Planning tiles',
|
|
10065
|
+
'download.status.saving': 'Saving region',
|
|
9058
10066
|
// Panel Manager additional strings
|
|
9059
10067
|
'panel.downloadedRegions': 'Downloaded Regions',
|
|
9060
10068
|
'panel.noExpiry': 'No expiry',
|
|
@@ -9076,6 +10084,8 @@ const en = {
|
|
|
9076
10084
|
'control.saveSelectedRegion': 'Save Selected Region',
|
|
9077
10085
|
// Alerts/Messages
|
|
9078
10086
|
'alert.unknownError': 'Unknown error occurred',
|
|
10087
|
+
'alert.downloadFailed': 'Download failed',
|
|
10088
|
+
'alert.needsMapboxGl': 'This style uses Mapbox GL JS features, such as 3D models or style config, so only a Mapbox GL map can show it.',
|
|
9079
10089
|
'alert.downloadNotImplemented': 'Download feature not implemented in this refactored version',
|
|
9080
10090
|
'alert.mapNotAvailable': 'Map not available',
|
|
9081
10091
|
'alert.invalidBounds': 'Invalid bounds for region',
|
|
@@ -9132,7 +10142,7 @@ const ar = {
|
|
|
9132
10142
|
'language.ar': 'العربية',
|
|
9133
10143
|
// Header - الرأس
|
|
9134
10144
|
'header.title': 'مدير الخرائط غير المتصلة',
|
|
9135
|
-
'header.subtitle': '{{
|
|
10145
|
+
'header.subtitle': '{{regions}} • {{size}} إجمالي',
|
|
9136
10146
|
// Actions - الإجراءات
|
|
9137
10147
|
'actions.addRegion': 'إضافة منطقة',
|
|
9138
10148
|
'actions.refresh': 'تحديث',
|
|
@@ -9148,6 +10158,8 @@ const ar = {
|
|
|
9148
10158
|
'regionForm.namePlaceholder': 'مثال: وسط المدينة',
|
|
9149
10159
|
'regionForm.minZoom': 'أدنى تكبير',
|
|
9150
10160
|
'regionForm.maxZoom': 'أقصى تكبير',
|
|
10161
|
+
'regionForm.buildingsHint': 'تبدأ مباني Mapbox Standard عند مستوى التكبير 15. اضبط أقصى تكبير على 15 أو أكثر لتضمينها.',
|
|
10162
|
+
'regionForm.zoomRangeError': 'يجب ألا يقل أقصى تكبير عن أدنى تكبير.',
|
|
9151
10163
|
'regionForm.styleUrl': 'رابط النمط',
|
|
9152
10164
|
'regionForm.provider': 'مزود النمط',
|
|
9153
10165
|
'regionForm.providerAuto': 'اكتشاف تلقائي',
|
|
@@ -9177,7 +10189,10 @@ const ar = {
|
|
|
9177
10189
|
'regionList.downloaded': 'تم التحميل',
|
|
9178
10190
|
'regionList.size': 'الحجم',
|
|
9179
10191
|
'regionList.tiles': 'البلاطات',
|
|
10192
|
+
'regionList.resources': 'الخطوط والرموز والنماذج',
|
|
9180
10193
|
'regionList.styleData': 'النمط',
|
|
10194
|
+
'regionList.regionCountOne': 'منطقة واحدة',
|
|
10195
|
+
'regionList.regionCount': '{{count}} منطقة',
|
|
9181
10196
|
'regionList.totalSize': 'الإجمالي',
|
|
9182
10197
|
'regionList.styleId': 'معرف النمط',
|
|
9183
10198
|
// Region Details - تفاصيل المنطقة
|
|
@@ -9263,6 +10278,13 @@ const ar = {
|
|
|
9263
10278
|
'mbtiles.importFailed': 'فشل الاستيراد. يرجى المحاولة مرة أخرى.',
|
|
9264
10279
|
// Active Downloads - التحميلات النشطة
|
|
9265
10280
|
'download.activeCount': 'التحميلات النشطة ({{count}})',
|
|
10281
|
+
'download.status.style': 'جارٍ تجهيز النمط',
|
|
10282
|
+
'download.status.sprites': 'الرموز: {{completed}} من {{total}}',
|
|
10283
|
+
'download.status.glyphs': 'الخطوط: {{completed}} من {{total}}',
|
|
10284
|
+
'download.status.models': 'النماذج ثلاثية الأبعاد: {{completed}} من {{total}}',
|
|
10285
|
+
'download.status.tiles': 'البلاطات: {{completed}} من {{total}}',
|
|
10286
|
+
'download.status.planning': 'جارٍ تخطيط البلاطات',
|
|
10287
|
+
'download.status.saving': 'جارٍ حفظ المنطقة',
|
|
9266
10288
|
// Panel Manager additional strings - سلاسل إضافية للوحة
|
|
9267
10289
|
'panel.downloadedRegions': 'المناطق المحملة',
|
|
9268
10290
|
'panel.noExpiry': 'بدون انتهاء',
|
|
@@ -9284,6 +10306,8 @@ const ar = {
|
|
|
9284
10306
|
'control.saveSelectedRegion': 'حفظ المنطقة المحددة',
|
|
9285
10307
|
// Alerts/Messages - التنبيهات/الرسائل
|
|
9286
10308
|
'alert.unknownError': 'حدث خطأ غير معروف',
|
|
10309
|
+
'alert.downloadFailed': 'فشل التحميل',
|
|
10310
|
+
'alert.needsMapboxGl': 'يستخدم هذا النمط ميزات Mapbox GL JS، مثل النماذج ثلاثية الأبعاد أو إعدادات النمط، لذا لا يمكن عرضه إلا على خريطة Mapbox GL.',
|
|
9287
10311
|
'alert.downloadNotImplemented': 'ميزة التحميل غير مطبقة في هذه النسخة',
|
|
9288
10312
|
'alert.mapNotAvailable': 'الخريطة غير متاحة',
|
|
9289
10313
|
'alert.invalidBounds': 'حدود غير صالحة للمنطقة',
|
|
@@ -9418,6 +10442,9 @@ const i18n = new I18nManager();
|
|
|
9418
10442
|
const t = (key, replacements) => {
|
|
9419
10443
|
return i18n.t(key, replacements);
|
|
9420
10444
|
};
|
|
10445
|
+
// Re-export i18next for advanced usage
|
|
10446
|
+
/** "1 region" or "3 regions", in the current language. */
|
|
10447
|
+
const regionCountLabel = (count) => count === 1 ? t('regionList.regionCountOne') : t('regionList.regionCount', { count });
|
|
9421
10448
|
|
|
9422
10449
|
/**
|
|
9423
10450
|
* Base Component Class
|
|
@@ -9638,7 +10665,7 @@ class Button extends BaseComponent {
|
|
|
9638
10665
|
createProgressBadge() {
|
|
9639
10666
|
this.progressBadge = document.createElement('span');
|
|
9640
10667
|
this.progressBadge.className =
|
|
9641
|
-
'absolute
|
|
10668
|
+
'progress-badge absolute top-1/2 right-full mr-1.5 -translate-y-1/2 whitespace-nowrap bg-blue-600 text-white rounded-md px-1.5 py-0.5 text-xs font-semibold shadow-md pointer-events-none hidden';
|
|
9642
10669
|
// Accessibility: badge ID for aria-describedby
|
|
9643
10670
|
const badgeId = `btn-badge-${Date.now()}`;
|
|
9644
10671
|
this.progressBadge.id = badgeId;
|
|
@@ -9655,6 +10682,20 @@ class Button extends BaseComponent {
|
|
|
9655
10682
|
else {
|
|
9656
10683
|
this.element.textContent = text;
|
|
9657
10684
|
}
|
|
10685
|
+
this.restoreProgressBadge();
|
|
10686
|
+
}
|
|
10687
|
+
/**
|
|
10688
|
+
* Replace the button's content, keeping its progress badge.
|
|
10689
|
+
*/
|
|
10690
|
+
setContent(html) {
|
|
10691
|
+
this.element.innerHTML = html;
|
|
10692
|
+
this.restoreProgressBadge();
|
|
10693
|
+
}
|
|
10694
|
+
/** Replacing the button's content removes the badge, so put it back. */
|
|
10695
|
+
restoreProgressBadge() {
|
|
10696
|
+
if (this.progressBadge) {
|
|
10697
|
+
this.element.appendChild(this.progressBadge);
|
|
10698
|
+
}
|
|
9658
10699
|
}
|
|
9659
10700
|
/**
|
|
9660
10701
|
* Update button state
|
|
@@ -9750,7 +10791,7 @@ class ButtonManager {
|
|
|
9750
10791
|
* Reset button to default state
|
|
9751
10792
|
*/
|
|
9752
10793
|
resetToDefault() {
|
|
9753
|
-
this.button.
|
|
10794
|
+
this.button.setContent(icons.cloud({ size: 20, color: 'black' }));
|
|
9754
10795
|
this.button.setDisabled(false);
|
|
9755
10796
|
this.button.getElement().title = 'Offline Map Manager';
|
|
9756
10797
|
this.button.hideProgressBadge();
|
|
@@ -9863,8 +10904,7 @@ class Modal extends BaseComponent {
|
|
|
9863
10904
|
titleSection.className = 'flex flex-col';
|
|
9864
10905
|
if (this.config.title) {
|
|
9865
10906
|
const title = document.createElement('h2');
|
|
9866
|
-
title.className =
|
|
9867
|
-
'text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-300';
|
|
10907
|
+
title.className = 'text-xl font-semibold text-gray-900 dark:text-white';
|
|
9868
10908
|
title.textContent = this.config.title;
|
|
9869
10909
|
titleSection.appendChild(title);
|
|
9870
10910
|
}
|
|
@@ -10043,14 +11083,14 @@ class RegionDetailsModal {
|
|
|
10043
11083
|
}
|
|
10044
11084
|
content.innerHTML = `
|
|
10045
11085
|
<div class="glass-input p-4 rounded-xl border-0 bg-gray-50/50 dark:bg-gray-800/50">
|
|
10046
|
-
<h3 class="m-0 text-xl font-
|
|
11086
|
+
<h3 class="m-0 text-xl font-semibold text-gray-900 dark:text-white">
|
|
10047
11087
|
${region.name}
|
|
10048
11088
|
</h3>
|
|
10049
11089
|
</div>
|
|
10050
11090
|
|
|
10051
11091
|
<div class="grid grid-cols-2 gap-4">
|
|
10052
11092
|
<div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
|
|
10053
|
-
<label class="block mb-1.5 text-
|
|
11093
|
+
<label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
|
|
10054
11094
|
${t('regionDetails.bounds')}
|
|
10055
11095
|
</label>
|
|
10056
11096
|
<div class="text-sm font-mono text-gray-700 dark:text-gray-300 leading-relaxed">
|
|
@@ -10060,7 +11100,7 @@ class RegionDetailsModal {
|
|
|
10060
11100
|
</div>
|
|
10061
11101
|
|
|
10062
11102
|
<div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
|
|
10063
|
-
<label class="block mb-1.5 text-
|
|
11103
|
+
<label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
|
|
10064
11104
|
${t('regionDetails.zoomRange')}
|
|
10065
11105
|
</label>
|
|
10066
11106
|
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
@@ -10072,7 +11112,7 @@ class RegionDetailsModal {
|
|
|
10072
11112
|
${region.created
|
|
10073
11113
|
? `
|
|
10074
11114
|
<div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
|
|
10075
|
-
<label class="block mb-1.5 text-
|
|
11115
|
+
<label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
|
|
10076
11116
|
${t('regionDetails.created')}
|
|
10077
11117
|
</label>
|
|
10078
11118
|
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
@@ -10827,11 +11867,13 @@ class PanelRenderer extends BaseComponent {
|
|
|
10827
11867
|
headerContainer;
|
|
10828
11868
|
actionButtonsContainer;
|
|
10829
11869
|
regionsList;
|
|
11870
|
+
awaitingProgressRender = false;
|
|
10830
11871
|
downloadProgressContainer;
|
|
10831
11872
|
languageSelector;
|
|
10832
11873
|
// Debounce mechanism
|
|
10833
11874
|
refreshTimeout;
|
|
10834
11875
|
isRefreshing = false;
|
|
11876
|
+
refreshQueued = false;
|
|
10835
11877
|
// Language change unsubscribe function
|
|
10836
11878
|
unsubscribeLanguage;
|
|
10837
11879
|
// Stored event handler for cleanup
|
|
@@ -10900,7 +11942,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
10900
11942
|
titleSection.innerHTML = `
|
|
10901
11943
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">${t('header.title')}</h2>
|
|
10902
11944
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
10903
|
-
${t('header.subtitle', {
|
|
11945
|
+
${t('header.subtitle', { regions: regionCountLabel(regions.length), size: formatBytes(analytics.totalStorageSize) })}
|
|
10904
11946
|
</p>
|
|
10905
11947
|
`;
|
|
10906
11948
|
// Actions section
|
|
@@ -10974,6 +12016,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
10974
12016
|
this.element.removeChild(this.downloadProgressContainer);
|
|
10975
12017
|
this.downloadProgressContainer = undefined;
|
|
10976
12018
|
}
|
|
12019
|
+
this.awaitingProgressRender = false;
|
|
10977
12020
|
// Only show if there are active downloads
|
|
10978
12021
|
if (this.downloadManager?.hasActiveDownloads()) {
|
|
10979
12022
|
this.downloadProgressContainer = document.createElement('div');
|
|
@@ -10996,9 +12039,10 @@ class PanelRenderer extends BaseComponent {
|
|
|
10996
12039
|
}
|
|
10997
12040
|
try {
|
|
10998
12041
|
// Load styles from IndexedDB and get stats
|
|
10999
|
-
const { loadStyles, getStyleStats } = await Promise.resolve().then(function () { return styleService; });
|
|
12042
|
+
const { loadStyles, getStyleStats, getStyleStorageSizes } = await Promise.resolve().then(function () { return styleService; });
|
|
11000
12043
|
const styles = await loadStyles();
|
|
11001
12044
|
const statsResult = await getStyleStats();
|
|
12045
|
+
const storageSizes = await getStyleStorageSizes();
|
|
11002
12046
|
panelLogger.debug('Loaded styles:', styles);
|
|
11003
12047
|
panelLogger.debug('Style stats:', statsResult);
|
|
11004
12048
|
// Build style size map from stats
|
|
@@ -11021,12 +12065,14 @@ class PanelRenderer extends BaseComponent {
|
|
|
11021
12065
|
for (const style of styles) {
|
|
11022
12066
|
const styleRegions = regionsByStyle[style.key] || [];
|
|
11023
12067
|
const styleJsonSize = styleSizeMap[style.key] || 0;
|
|
11024
|
-
const regionTilesSize =
|
|
11025
|
-
const
|
|
12068
|
+
const regionTilesSize = storageSizes[style.key]?.tiles ?? 0;
|
|
12069
|
+
const resourcesSize = storageSizes[style.key]?.resources ?? 0;
|
|
12070
|
+
const totalSize = styleJsonSize + regionTilesSize + resourcesSize;
|
|
11026
12071
|
// Add style item with computed sizes
|
|
11027
12072
|
const styleWithSizes = {
|
|
11028
12073
|
...style,
|
|
11029
12074
|
styleJsonSize,
|
|
12075
|
+
resourcesSize,
|
|
11030
12076
|
regionTilesSize,
|
|
11031
12077
|
totalSize,
|
|
11032
12078
|
};
|
|
@@ -11142,7 +12188,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
11142
12188
|
<div class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
|
11143
12189
|
${region.bounds ? `<span class="font-medium">SW:</span> ${region.bounds[0][1].toFixed(4)}, ${region.bounds[0][0].toFixed(4)} <span class="mx-1">→</span> <span class="font-medium">NE:</span> ${region.bounds[1][1].toFixed(4)}, ${region.bounds[1][0].toFixed(4)}` : t('panel.noBounds')}
|
|
11144
12190
|
</div>
|
|
11145
|
-
<div class="flex items-center gap-4 text-xs text-slate-
|
|
12191
|
+
<div class="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400 mt-2">
|
|
11146
12192
|
<span>${t('regionList.zoom')}: ${region.minZoom}-${region.maxZoom}</span>
|
|
11147
12193
|
${region.downloadedAt ? `<span>${t('regionList.downloaded')}: ${new Date(region.downloadedAt).toLocaleDateString()}</span>` : ''}
|
|
11148
12194
|
${region.size ? `<span>${t('regionList.size')}: ${formatBytes(region.size)}</span>` : ''}
|
|
@@ -11165,16 +12211,16 @@ class PanelRenderer extends BaseComponent {
|
|
|
11165
12211
|
<div class="flex items-center justify-between">
|
|
11166
12212
|
<div class="flex-1">
|
|
11167
12213
|
<h3 class="font-semibold text-slate-800 dark:text-slate-100 text-base">
|
|
11168
|
-
${escapeHtml$1(
|
|
12214
|
+
${escapeHtml$1(styleDisplayName(style))}
|
|
11169
12215
|
</h3>
|
|
11170
12216
|
<p class="text-sm text-slate-600 dark:text-slate-300 mt-1">
|
|
11171
12217
|
${t('regionList.styleId')}: ${escapeHtml$1(style.key)}
|
|
11172
12218
|
</p>
|
|
11173
12219
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
|
11174
|
-
${regionCount}
|
|
12220
|
+
${regionCountLabel(regionCount)} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
|
|
11175
12221
|
</p>
|
|
11176
|
-
<p class="text-xs text-slate-
|
|
11177
|
-
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
12222
|
+
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
|
12223
|
+
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.resources')}: ${formatBytes(style.resourcesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
11178
12224
|
</p>
|
|
11179
12225
|
</div>
|
|
11180
12226
|
<div class="flex items-center gap-2">
|
|
@@ -11208,7 +12254,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
11208
12254
|
<div class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
|
11209
12255
|
${region.bounds ? `<span class="font-medium">SW:</span> ${region.bounds[0][1].toFixed(4)}, ${region.bounds[0][0].toFixed(4)} <span class="mx-1">→</span> <span class="font-medium">NE:</span> ${region.bounds[1][1].toFixed(4)}, ${region.bounds[1][0].toFixed(4)}` : t('panel.noBounds')}
|
|
11210
12256
|
</div>
|
|
11211
|
-
<div class="flex items-center gap-4 text-xs text-slate-
|
|
12257
|
+
<div class="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400 mt-2">
|
|
11212
12258
|
<span>${t('regionList.zoom')}: ${region.minZoom}-${region.maxZoom}</span>
|
|
11213
12259
|
${region.downloadedAt ? `<span>${t('regionList.downloaded')}: ${new Date(region.downloadedAt).toLocaleDateString()}</span>` : ''}
|
|
11214
12260
|
${region.tileSize ? `<span>${t('regionList.tiles')}: ${formatBytes(region.tileSize)}</span>` : ''}
|
|
@@ -11247,16 +12293,16 @@ class PanelRenderer extends BaseComponent {
|
|
|
11247
12293
|
<div class="flex items-center justify-between">
|
|
11248
12294
|
<div class="flex-1">
|
|
11249
12295
|
<h3 class="font-semibold text-blue-900 dark:text-blue-100 text-base">
|
|
11250
|
-
${escapeHtml$1(
|
|
12296
|
+
${escapeHtml$1(styleDisplayName(style))}
|
|
11251
12297
|
</h3>
|
|
11252
12298
|
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
|
11253
12299
|
${t('regionList.styleId')}: ${escapeHtml$1(style.key)}
|
|
11254
12300
|
</p>
|
|
11255
12301
|
<p class="text-sm text-blue-600 dark:text-blue-400 mt-1">
|
|
11256
|
-
${regionCount}
|
|
12302
|
+
${regionCountLabel(regionCount)} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
|
|
11257
12303
|
</p>
|
|
11258
12304
|
<p class="text-xs text-blue-500 dark:text-blue-500 mt-1">
|
|
11259
|
-
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
12305
|
+
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.resources')}: ${formatBytes(style.resourcesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
11260
12306
|
</p>
|
|
11261
12307
|
</div>
|
|
11262
12308
|
<div class="flex items-center gap-2">
|
|
@@ -11296,6 +12342,39 @@ class PanelRenderer extends BaseComponent {
|
|
|
11296
12342
|
</div>
|
|
11297
12343
|
`;
|
|
11298
12344
|
}
|
|
12345
|
+
/**
|
|
12346
|
+
* Show the latest download progress. Called on every update, so it only
|
|
12347
|
+
* redraws the progress section rather than the whole panel.
|
|
12348
|
+
*/
|
|
12349
|
+
updateDownloadProgress(downloads) {
|
|
12350
|
+
if (downloads.size === 0)
|
|
12351
|
+
return;
|
|
12352
|
+
const container = this.downloadProgressContainer;
|
|
12353
|
+
if (container?.isConnected) {
|
|
12354
|
+
container.innerHTML = this.createDownloadProgressHTML(downloads);
|
|
12355
|
+
return;
|
|
12356
|
+
}
|
|
12357
|
+
// The download started while the panel was open, so it has no section yet.
|
|
12358
|
+
if (this.element.isConnected && !this.awaitingProgressRender) {
|
|
12359
|
+
this.awaitingProgressRender = true;
|
|
12360
|
+
void this.refresh();
|
|
12361
|
+
}
|
|
12362
|
+
}
|
|
12363
|
+
/** What the current phase is doing, such as "Tiles: 64 of 128". */
|
|
12364
|
+
describeDownloadPhase(download) {
|
|
12365
|
+
const phase = download.phase;
|
|
12366
|
+
if (phase === 'style')
|
|
12367
|
+
return t('download.status.style');
|
|
12368
|
+
if (phase === 'tiles' && !download.total)
|
|
12369
|
+
return t('download.status.planning');
|
|
12370
|
+
if (phase === 'sprites' || phase === 'glyphs' || phase === 'models' || phase === 'tiles') {
|
|
12371
|
+
return t(`download.status.${phase}`, {
|
|
12372
|
+
completed: Number(download.completed) || 0,
|
|
12373
|
+
total: Number(download.total) || 0,
|
|
12374
|
+
});
|
|
12375
|
+
}
|
|
12376
|
+
return t('download.status.saving');
|
|
12377
|
+
}
|
|
11299
12378
|
/**
|
|
11300
12379
|
* Create download progress HTML
|
|
11301
12380
|
*/
|
|
@@ -11309,11 +12388,14 @@ class PanelRenderer extends BaseComponent {
|
|
|
11309
12388
|
<div class="space-y-2">
|
|
11310
12389
|
${downloadArray
|
|
11311
12390
|
.map(download => `
|
|
11312
|
-
<div class="flex items-center justify-between text-sm">
|
|
11313
|
-
<
|
|
11314
|
-
|
|
11315
|
-
<div class="
|
|
11316
|
-
|
|
12391
|
+
<div class="flex items-center justify-between gap-4 text-sm">
|
|
12392
|
+
<div class="min-w-0">
|
|
12393
|
+
<div class="truncate text-blue-800 dark:text-blue-200">${escapeHtml$1(download.regionName || download.regionId)}</div>
|
|
12394
|
+
<div class="text-xs text-gray-600 dark:text-gray-300">${escapeHtml$1(this.describeDownloadPhase(download))}</div>
|
|
12395
|
+
</div>
|
|
12396
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
12397
|
+
<div class="w-24 h-2 bg-blue-200 dark:bg-blue-800 rounded-full overflow-hidden" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(download.percentage || 0)}">
|
|
12398
|
+
<div class="h-full bg-blue-600" style="width: ${download.percentage || 0}%"></div>
|
|
11317
12399
|
</div>
|
|
11318
12400
|
<span class="text-blue-700 dark:text-blue-300 min-w-12">${Math.round(download.percentage || 0)}%</span>
|
|
11319
12401
|
</div>
|
|
@@ -11390,7 +12472,15 @@ class PanelRenderer extends BaseComponent {
|
|
|
11390
12472
|
cancelText: t('app.cancel'),
|
|
11391
12473
|
onConfirm: async () => {
|
|
11392
12474
|
try {
|
|
11393
|
-
|
|
12475
|
+
// Deleting a style's last region deletes the style too.
|
|
12476
|
+
const { loadStyleById } = await Promise.resolve().then(function () { return styleService; });
|
|
12477
|
+
const shownEntry = this.isStyleShown(region.styleId)
|
|
12478
|
+
? await loadStyleById(region.styleId)
|
|
12479
|
+
: null;
|
|
12480
|
+
await this.offlineManager.deleteRegion(regionId, region.styleId);
|
|
12481
|
+
if (shownEntry && !(await loadStyleById(region.styleId))) {
|
|
12482
|
+
this.showOnlineStyle(shownEntry);
|
|
12483
|
+
}
|
|
11394
12484
|
this.modalManager.close();
|
|
11395
12485
|
// Refresh the panel to show updated regions
|
|
11396
12486
|
await this.refresh();
|
|
@@ -11443,7 +12533,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
11443
12533
|
}
|
|
11444
12534
|
// Delete the existing region (and its tiles/resources)
|
|
11445
12535
|
panelLogger.debug('Deleting region before re-download:', regionId);
|
|
11446
|
-
await this.offlineManager.deleteRegion(regionId);
|
|
12536
|
+
await this.offlineManager.deleteRegion(regionId, region.styleId);
|
|
11447
12537
|
// Prepare form data for re-download
|
|
11448
12538
|
const formData = {
|
|
11449
12539
|
name: region.name,
|
|
@@ -11664,7 +12754,8 @@ class PanelRenderer extends BaseComponent {
|
|
|
11664
12754
|
clearTimeout(this.refreshTimeout);
|
|
11665
12755
|
}
|
|
11666
12756
|
if (this.isRefreshing) {
|
|
11667
|
-
|
|
12757
|
+
// Run again once this one finishes, or the panel misses what changed meanwhile.
|
|
12758
|
+
this.refreshQueued = true;
|
|
11668
12759
|
return;
|
|
11669
12760
|
}
|
|
11670
12761
|
this.refreshTimeout = setTimeout(async () => {
|
|
@@ -11708,6 +12799,10 @@ class PanelRenderer extends BaseComponent {
|
|
|
11708
12799
|
}
|
|
11709
12800
|
finally {
|
|
11710
12801
|
this.isRefreshing = false;
|
|
12802
|
+
if (this.refreshQueued) {
|
|
12803
|
+
this.refreshQueued = false;
|
|
12804
|
+
void this.refresh();
|
|
12805
|
+
}
|
|
11711
12806
|
}
|
|
11712
12807
|
}
|
|
11713
12808
|
/**
|
|
@@ -11845,6 +12940,20 @@ class PanelRenderer extends BaseComponent {
|
|
|
11845
12940
|
/**
|
|
11846
12941
|
* Handle loading a style to the map
|
|
11847
12942
|
*/
|
|
12943
|
+
/** Whether the map is drawing this stored style, through `idb://` or the Service Worker. */
|
|
12944
|
+
isStyleShown(styleKey) {
|
|
12945
|
+
const shown = this.map?.getStyle?.();
|
|
12946
|
+
if (!shown)
|
|
12947
|
+
return false;
|
|
12948
|
+
const text = JSON.stringify(shown);
|
|
12949
|
+
return text.includes(`idb://${styleKey}/`) || text.includes(`/__offline__/${styleKey}/`);
|
|
12950
|
+
}
|
|
12951
|
+
/** After its stored copy is deleted, show a style from its original URL, not a blank map. */
|
|
12952
|
+
showOnlineStyle(entry) {
|
|
12953
|
+
if (!entry.originalUrl)
|
|
12954
|
+
return;
|
|
12955
|
+
this.map?.setStyle?.(entry.originalUrl);
|
|
12956
|
+
}
|
|
11848
12957
|
async handleLoadStyle(styleData) {
|
|
11849
12958
|
if (!this.map) {
|
|
11850
12959
|
panelLogger.warn('Map not available for loading style');
|
|
@@ -11863,6 +12972,10 @@ class PanelRenderer extends BaseComponent {
|
|
|
11863
12972
|
alert(t('error.invalidStyleData'));
|
|
11864
12973
|
return;
|
|
11865
12974
|
}
|
|
12975
|
+
if (needsMapboxGl(styleEntry.style) && !isMapboxGlMap(this.map)) {
|
|
12976
|
+
alert(t('alert.needsMapboxGl'));
|
|
12977
|
+
return;
|
|
12978
|
+
}
|
|
11866
12979
|
panelLogger.debug('Loading style from IndexedDB:', {
|
|
11867
12980
|
key: styleEntry.key,
|
|
11868
12981
|
provider: styleEntry.provider,
|
|
@@ -12047,13 +13160,16 @@ class PanelRenderer extends BaseComponent {
|
|
|
12047
13160
|
const style = styleData;
|
|
12048
13161
|
const confirmModal = new ConfirmationModal({
|
|
12049
13162
|
title: t('delete.styleTitle'),
|
|
12050
|
-
message: t('delete.styleMessage', { name: style
|
|
13163
|
+
message: t('delete.styleMessage', { name: styleDisplayName(style) }),
|
|
12051
13164
|
confirmText: t('actions.deleteStyle'),
|
|
12052
13165
|
cancelText: t('app.cancel'),
|
|
12053
13166
|
onConfirm: async () => {
|
|
12054
13167
|
try {
|
|
12055
13168
|
const { deleteStyleById } = await Promise.resolve().then(function () { return styleService; });
|
|
13169
|
+
const wasShown = this.isStyleShown(style.key);
|
|
12056
13170
|
await deleteStyleById(style.key);
|
|
13171
|
+
if (wasShown)
|
|
13172
|
+
this.showOnlineStyle(styleData);
|
|
12057
13173
|
this.modalManager.close();
|
|
12058
13174
|
// Refresh the panel
|
|
12059
13175
|
await this.refresh();
|
|
@@ -12158,7 +13274,7 @@ class PolygonControl {
|
|
|
12158
13274
|
this.saveButton = document.createElement('button');
|
|
12159
13275
|
this.saveButton.type = 'button';
|
|
12160
13276
|
const prefix = this.options.cssPrefix || 'maplibregl';
|
|
12161
|
-
this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-
|
|
13277
|
+
this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 !bg-green-600 border border-green-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:!bg-green-700 transition-all duration-200`;
|
|
12162
13278
|
this.saveButton.innerHTML = icons.check({ size: 16, color: 'white' });
|
|
12163
13279
|
this.saveButton.title = 'Save Selected Region';
|
|
12164
13280
|
this.saveButton.addEventListener('click', () => this.handleSave());
|
|
@@ -12225,6 +13341,33 @@ class PolygonControl {
|
|
|
12225
13341
|
handleMapMove = () => {
|
|
12226
13342
|
this.updateVisualization();
|
|
12227
13343
|
};
|
|
13344
|
+
/**
|
|
13345
|
+
* The area to select: the middle of what's on screen. On a tilted or rotated map
|
|
13346
|
+
* `getBounds()` reaches far past the view, so insetting it would miss the part
|
|
13347
|
+
* nearest the camera. There, measure from the map's centre to the screen's edges.
|
|
13348
|
+
*/
|
|
13349
|
+
getSelectionBbox(minLng, minLat, maxLng, maxLat) {
|
|
13350
|
+
if (!this.map.getPitch() && !this.map.getBearing()) {
|
|
13351
|
+
const lngInset = (maxLng - minLng) * 0.2;
|
|
13352
|
+
const latInset = (maxLat - minLat) * 0.2;
|
|
13353
|
+
return [minLng + lngInset, minLat + latInset, maxLng - lngInset, maxLat - latInset];
|
|
13354
|
+
}
|
|
13355
|
+
const center = this.map.getCenter();
|
|
13356
|
+
const { clientWidth, clientHeight } = this.map.getContainer();
|
|
13357
|
+
const lngScale = Math.cos((center.lat * Math.PI) / 180);
|
|
13358
|
+
const degreesFromCenter = (point) => {
|
|
13359
|
+
const { lng, lat } = this.map.unproject(point);
|
|
13360
|
+
return Math.hypot((lng - center.lng) * lngScale, lat - center.lat);
|
|
13361
|
+
};
|
|
13362
|
+
const halfSize = 0.6 *
|
|
13363
|
+
Math.min(degreesFromCenter([0, clientHeight / 2]), degreesFromCenter([clientWidth / 2, clientHeight]));
|
|
13364
|
+
return [
|
|
13365
|
+
center.lng - halfSize / lngScale,
|
|
13366
|
+
center.lat - halfSize,
|
|
13367
|
+
center.lng + halfSize / lngScale,
|
|
13368
|
+
center.lat + halfSize,
|
|
13369
|
+
];
|
|
13370
|
+
}
|
|
12228
13371
|
/**
|
|
12229
13372
|
* Update polygon visualization based on current map bounds
|
|
12230
13373
|
*/
|
|
@@ -12234,9 +13377,7 @@ class PolygonControl {
|
|
|
12234
13377
|
return;
|
|
12235
13378
|
const [minLng, minLat] = bboxArray[0];
|
|
12236
13379
|
const [maxLng, maxLat] = bboxArray[1];
|
|
12237
|
-
const
|
|
12238
|
-
const latDiff = (maxLat - minLat) * 0.2;
|
|
12239
|
-
const clippedBbox = [minLng + lngDiff, minLat + latDiff, maxLng - lngDiff, maxLat - latDiff];
|
|
13380
|
+
const clippedBbox = this.getSelectionBbox(minLng, minLat, maxLng, maxLat);
|
|
12240
13381
|
// Store current bounds for later use
|
|
12241
13382
|
this.currentBounds = clippedBbox;
|
|
12242
13383
|
const clippedPolygon = bboxPolygon.bboxPolygon([
|
|
@@ -12303,7 +13444,7 @@ class PolygonControl {
|
|
|
12303
13444
|
source: 'selectionPolygon',
|
|
12304
13445
|
layout: {},
|
|
12305
13446
|
paint: {
|
|
12306
|
-
'line-color': '#
|
|
13447
|
+
'line-color': '#A8116F', // Revision magenta, the UI's accent
|
|
12307
13448
|
'line-width': 3,
|
|
12308
13449
|
'line-dasharray': [2, 2],
|
|
12309
13450
|
},
|
|
@@ -12337,6 +13478,30 @@ class PolygonControl {
|
|
|
12337
13478
|
*/
|
|
12338
13479
|
// import { MaplibreMap } from 'maplibre-gl';
|
|
12339
13480
|
const formLogger = logger.scope('RegionFormModal');
|
|
13481
|
+
/** Mapbox Standard's buildings start at this zoom. */
|
|
13482
|
+
const STANDARD_BUILDINGS_MIN_ZOOM = 15;
|
|
13483
|
+
let fieldCount = 0;
|
|
13484
|
+
/**
|
|
13485
|
+
* Point each label at the one field beside it, so screen readers name the field
|
|
13486
|
+
* and clicking the label focuses it. Groups with several fields are left alone.
|
|
13487
|
+
*/
|
|
13488
|
+
function linkLabelsToFields(form) {
|
|
13489
|
+
for (const label of Array.from(form.querySelectorAll('label'))) {
|
|
13490
|
+
const fields = label.parentElement?.querySelectorAll('input, select, textarea');
|
|
13491
|
+
if (label.htmlFor || fields?.length !== 1)
|
|
13492
|
+
continue;
|
|
13493
|
+
const field = fields[0];
|
|
13494
|
+
field.id ||= `map-gl-offline-field-${++fieldCount}`;
|
|
13495
|
+
label.htmlFor = field.id;
|
|
13496
|
+
}
|
|
13497
|
+
}
|
|
13498
|
+
/**
|
|
13499
|
+
* True when a region of Mapbox Standard stops below the zoom where its buildings
|
|
13500
|
+
* start. Standard Satellite has no buildings, so it doesn't count.
|
|
13501
|
+
*/
|
|
13502
|
+
function regionMissesBuildings(styleUrl, maxZoom) {
|
|
13503
|
+
return /mapbox\/standard(?![\w-])/.test(styleUrl) && maxZoom < STANDARD_BUILDINGS_MIN_ZOOM;
|
|
13504
|
+
}
|
|
12340
13505
|
class RegionFormModal {
|
|
12341
13506
|
options;
|
|
12342
13507
|
modal;
|
|
@@ -12344,6 +13509,7 @@ class RegionFormModal {
|
|
|
12344
13509
|
minZoomInput;
|
|
12345
13510
|
maxZoomInput;
|
|
12346
13511
|
styleUrlInput;
|
|
13512
|
+
buildingsHint;
|
|
12347
13513
|
// Enhanced Mapbox GL support
|
|
12348
13514
|
providerSelect;
|
|
12349
13515
|
accessTokenInput;
|
|
@@ -12394,13 +13560,13 @@ class RegionFormModal {
|
|
|
12394
13560
|
'grid grid-cols-2 gap-4 p-4 rounded-xl glass-input border border-gray-100/50 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/50';
|
|
12395
13561
|
infoGroup.innerHTML = `
|
|
12396
13562
|
<div class="flex flex-col justify-center">
|
|
12397
|
-
<strong class="text-
|
|
12398
|
-
<div class="text-xl font-
|
|
13563
|
+
<strong class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.area')}</strong>
|
|
13564
|
+
<div class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
12399
13565
|
${area.toLocaleString()} <span class="text-sm font-medium text-gray-500">km²</span>
|
|
12400
13566
|
</div>
|
|
12401
13567
|
</div>
|
|
12402
13568
|
<div>
|
|
12403
|
-
<strong class="text-
|
|
13569
|
+
<strong class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.bounds')}</strong>
|
|
12404
13570
|
<div class="text-xs font-mono text-gray-600 dark:text-gray-300 leading-relaxed bg-white/50 dark:bg-black/20 p-2 rounded-lg">
|
|
12405
13571
|
${west.toFixed(4)}, ${south.toFixed(4)}<br>
|
|
12406
13572
|
${east.toFixed(4)}, ${north.toFixed(4)}
|
|
@@ -12456,6 +13622,12 @@ class RegionFormModal {
|
|
|
12456
13622
|
zoomGroup.appendChild(minZoomDiv);
|
|
12457
13623
|
zoomGroup.appendChild(maxZoomDiv);
|
|
12458
13624
|
form.appendChild(zoomGroup);
|
|
13625
|
+
this.buildingsHint = document.createElement('p');
|
|
13626
|
+
this.buildingsHint.className = 'm-0 text-sm text-amber-700 dark:text-amber-400 hidden';
|
|
13627
|
+
this.buildingsHint.setAttribute('role', 'status');
|
|
13628
|
+
this.buildingsHint.textContent = t('regionForm.buildingsHint');
|
|
13629
|
+
form.appendChild(this.buildingsHint);
|
|
13630
|
+
this.maxZoomInput.addEventListener('input', () => this.updateBuildingsHint());
|
|
12459
13631
|
// Style URL input
|
|
12460
13632
|
const styleGroup = document.createElement('div');
|
|
12461
13633
|
styleGroup.innerHTML = `
|
|
@@ -12468,9 +13640,13 @@ class RegionFormModal {
|
|
|
12468
13640
|
this.styleUrlInput.value = styleUrl;
|
|
12469
13641
|
this.styleUrlInput.className =
|
|
12470
13642
|
'w-full px-4 py-3 rounded-xl text-base glass-input text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500/50 transition-all shadow-sm';
|
|
12471
|
-
this.styleUrlInput.addEventListener('input', () =>
|
|
13643
|
+
this.styleUrlInput.addEventListener('input', () => {
|
|
13644
|
+
this.handleStyleUrlChange();
|
|
13645
|
+
this.updateBuildingsHint();
|
|
13646
|
+
});
|
|
12472
13647
|
styleGroup.appendChild(this.styleUrlInput);
|
|
12473
13648
|
form.appendChild(styleGroup);
|
|
13649
|
+
this.updateBuildingsHint();
|
|
12474
13650
|
// Provider selection
|
|
12475
13651
|
const providerGroup = document.createElement('div');
|
|
12476
13652
|
providerGroup.innerHTML = `
|
|
@@ -12528,6 +13704,7 @@ class RegionFormModal {
|
|
|
12528
13704
|
}
|
|
12529
13705
|
// Initialize provider detection
|
|
12530
13706
|
this.detectProviderFromUrl();
|
|
13707
|
+
linkLabelsToFields(form);
|
|
12531
13708
|
return form;
|
|
12532
13709
|
}
|
|
12533
13710
|
/**
|
|
@@ -12642,6 +13819,11 @@ class RegionFormModal {
|
|
|
12642
13819
|
/**
|
|
12643
13820
|
* Handle style URL changes to auto-detect provider
|
|
12644
13821
|
*/
|
|
13822
|
+
/** Warn when a region would be saved without the style's buildings. */
|
|
13823
|
+
updateBuildingsHint() {
|
|
13824
|
+
const missesBuildings = regionMissesBuildings(this.styleUrlInput?.value ?? '', Number(this.maxZoomInput?.value));
|
|
13825
|
+
this.buildingsHint?.classList.toggle('hidden', !missesBuildings);
|
|
13826
|
+
}
|
|
12645
13827
|
handleStyleUrlChange() {
|
|
12646
13828
|
this.detectProviderFromUrl();
|
|
12647
13829
|
}
|
|
@@ -12718,10 +13900,21 @@ class RegionFormModal {
|
|
|
12718
13900
|
footer.appendChild(saveButton.getElement());
|
|
12719
13901
|
return footer;
|
|
12720
13902
|
}
|
|
13903
|
+
/** Check the zoom fields, showing the browser's message on the first bad one. */
|
|
13904
|
+
zoomRangeIsValid() {
|
|
13905
|
+
const { minZoomInput: min, maxZoomInput: max } = this;
|
|
13906
|
+
if (!min || !max)
|
|
13907
|
+
return true;
|
|
13908
|
+
const reversed = min.value !== '' && max.value !== '' && Number(min.value) > Number(max.value);
|
|
13909
|
+
max.setCustomValidity(reversed ? t('regionForm.zoomRangeError') : '');
|
|
13910
|
+
return min.reportValidity() && max.reportValidity();
|
|
13911
|
+
}
|
|
12721
13912
|
/**
|
|
12722
13913
|
* Handle form save
|
|
12723
13914
|
*/
|
|
12724
13915
|
async handleSave() {
|
|
13916
|
+
if (!this.zoomRangeIsValid())
|
|
13917
|
+
return;
|
|
12725
13918
|
try {
|
|
12726
13919
|
const selectedSources = this.getSelectedExtraSources();
|
|
12727
13920
|
const formData = {
|
|
@@ -12838,14 +14031,14 @@ class RegionControl {
|
|
|
12838
14031
|
this.cancelButton = document.createElement('button');
|
|
12839
14032
|
this.cancelButton.type = 'button';
|
|
12840
14033
|
const prefix = this.options.cssPrefix || 'maplibregl';
|
|
12841
|
-
this.cancelButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-
|
|
14034
|
+
this.cancelButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 !bg-red-600 border border-red-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:!bg-red-700 transition-all duration-200`;
|
|
12842
14035
|
this.cancelButton.innerHTML = icons.x({ size: 16, color: 'white' });
|
|
12843
14036
|
this.cancelButton.title = 'Cancel Selection';
|
|
12844
14037
|
this.cancelButton.addEventListener('click', () => this.cancelSelection());
|
|
12845
14038
|
// Create save button (green checkmark)
|
|
12846
14039
|
this.saveButton = document.createElement('button');
|
|
12847
14040
|
this.saveButton.type = 'button';
|
|
12848
|
-
this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-
|
|
14041
|
+
this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 !bg-green-600 border border-green-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:!bg-green-700 transition-all duration-200`;
|
|
12849
14042
|
this.saveButton.innerHTML = icons.check({ size: 16, color: 'white' });
|
|
12850
14043
|
this.saveButton.title = 'Save Selected Region';
|
|
12851
14044
|
this.saveButton.addEventListener('click', () => this.handleSaveClick());
|
|
@@ -13075,6 +14268,8 @@ class DownloadManager {
|
|
|
13075
14268
|
offlineManager;
|
|
13076
14269
|
/** Map of region IDs to their download progress */
|
|
13077
14270
|
currentDownloads = new Map();
|
|
14271
|
+
/** Aborts each running download, by region id. */
|
|
14272
|
+
controllers = new Map();
|
|
13078
14273
|
options;
|
|
13079
14274
|
/**
|
|
13080
14275
|
* Create a new DownloadManager instance.
|
|
@@ -13089,14 +14284,16 @@ class DownloadManager {
|
|
|
13089
14284
|
* Calculates percentage and notifies listeners via the onProgressUpdate callback.
|
|
13090
14285
|
* @internal
|
|
13091
14286
|
*/
|
|
13092
|
-
updateProgress(regionId, regionName, phase, completed, total, currentResource) {
|
|
13093
|
-
const percentage =
|
|
14287
|
+
updateProgress(regionId, regionName, phase, completed, total, currentResource, overallPercentage) {
|
|
14288
|
+
const percentage = Math.round(overallPercentage);
|
|
14289
|
+
const phasePercentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
13094
14290
|
this.currentDownloads.set(regionId, {
|
|
13095
14291
|
regionId,
|
|
13096
14292
|
regionName,
|
|
13097
14293
|
completed,
|
|
13098
14294
|
total,
|
|
13099
14295
|
percentage,
|
|
14296
|
+
phasePercentage,
|
|
13100
14297
|
currentResource,
|
|
13101
14298
|
phase,
|
|
13102
14299
|
});
|
|
@@ -13131,14 +14328,20 @@ class DownloadManager {
|
|
|
13131
14328
|
extraSources: formData.extraSources,
|
|
13132
14329
|
};
|
|
13133
14330
|
const regionId = regionConfig.id;
|
|
14331
|
+
const controller = new AbortController();
|
|
14332
|
+
this.controllers.set(regionId, controller);
|
|
13134
14333
|
this.updateUIForDownloadStart();
|
|
13135
14334
|
try {
|
|
13136
14335
|
await this.offlineManager.downloadRegion(regionConfig, {
|
|
13137
14336
|
provider: formData.provider || 'auto',
|
|
13138
14337
|
accessToken: formData.accessToken,
|
|
14338
|
+
signal: controller.signal,
|
|
13139
14339
|
onProgress: progress => {
|
|
14340
|
+
// A cancelled download keeps reporting until it stops; don't show it again.
|
|
14341
|
+
if (controller.signal.aborted)
|
|
14342
|
+
return;
|
|
13140
14343
|
const uiPhase = progress.phase === 'metadata' ? undefined : progress.phase;
|
|
13141
|
-
this.updateProgress(regionId, regionConfig.name, uiPhase, progress.completed, progress.total, progress.message ?? `Downloading ${progress.phase}
|
|
14344
|
+
this.updateProgress(regionId, regionConfig.name, uiPhase, progress.completed, progress.total, progress.message ?? `Downloading ${progress.phase}`, progress.overallPercentage);
|
|
13142
14345
|
},
|
|
13143
14346
|
tileOptions: {
|
|
13144
14347
|
maxConcurrency: 10,
|
|
@@ -13147,8 +14350,16 @@ class DownloadManager {
|
|
|
13147
14350
|
this.handleDownloadComplete(regionId);
|
|
13148
14351
|
}
|
|
13149
14352
|
catch (error) {
|
|
13150
|
-
|
|
13151
|
-
|
|
14353
|
+
if (controller.signal.aborted) {
|
|
14354
|
+
downloadLogger.debug(`Download of ${regionId} was cancelled`);
|
|
14355
|
+
}
|
|
14356
|
+
else {
|
|
14357
|
+
downloadLogger.error('Error downloading region:', error);
|
|
14358
|
+
this.handleDownloadError(regionId, error);
|
|
14359
|
+
}
|
|
14360
|
+
}
|
|
14361
|
+
finally {
|
|
14362
|
+
this.controllers.delete(regionId);
|
|
13152
14363
|
}
|
|
13153
14364
|
}
|
|
13154
14365
|
/**
|
|
@@ -13171,6 +14382,8 @@ class DownloadManager {
|
|
|
13171
14382
|
* @param regionId - The ID of the region download to cancel
|
|
13172
14383
|
*/
|
|
13173
14384
|
cancelDownload(regionId) {
|
|
14385
|
+
// Stops the download; downloadRegion then saves nothing and removes its partial tiles.
|
|
14386
|
+
this.controllers.get(regionId)?.abort();
|
|
13174
14387
|
this.currentDownloads.delete(regionId);
|
|
13175
14388
|
if (this.currentDownloads.size === 0) {
|
|
13176
14389
|
this.resetUI();
|
|
@@ -13182,6 +14395,8 @@ class DownloadManager {
|
|
|
13182
14395
|
* Clears all download tracking and resets the UI.
|
|
13183
14396
|
*/
|
|
13184
14397
|
cancelAllDownloads() {
|
|
14398
|
+
for (const controller of this.controllers.values())
|
|
14399
|
+
controller.abort();
|
|
13185
14400
|
this.currentDownloads.clear();
|
|
13186
14401
|
this.resetUI();
|
|
13187
14402
|
this.options.onProgressUpdate?.(this.currentDownloads);
|
|
@@ -13331,6 +14546,9 @@ class OfflineManagerControl {
|
|
|
13331
14546
|
regionControl;
|
|
13332
14547
|
downloadManager;
|
|
13333
14548
|
modalManager = new ModalManager();
|
|
14549
|
+
/** Percentages of this batch's downloads, finished ones at 100, for the badge. */
|
|
14550
|
+
badgeBatch = new Map();
|
|
14551
|
+
badgePercentage = 0;
|
|
13334
14552
|
/** Whether the bounding box visualization layer has been added to the map */
|
|
13335
14553
|
bboxLayerAdded = false;
|
|
13336
14554
|
/** Map library module for protocol registration (null if not provided) */
|
|
@@ -13385,8 +14603,11 @@ class OfflineManagerControl {
|
|
|
13385
14603
|
this.mapLib.addProtocol('idb', async (params) => {
|
|
13386
14604
|
const response = await idbFetchHandler(params.url);
|
|
13387
14605
|
if (!response.ok) {
|
|
13388
|
-
|
|
13389
|
-
|
|
14606
|
+
// MapLibre treats a 404 as "no tile here", which is normal once the map
|
|
14607
|
+
// moves outside a saved region. Any other error gets logged for every tile.
|
|
14608
|
+
throw Object.assign(new Error(`IDB fetch failed (${response.status}): ${params.url}`), {
|
|
14609
|
+
status: response.status,
|
|
14610
|
+
});
|
|
13390
14611
|
}
|
|
13391
14612
|
// MapLibre's makeRequest bypasses JSON parsing for protocol handlers,
|
|
13392
14613
|
// so we must parse JSON responses ourselves (e.g. sprite atlas, TileJSON).
|
|
@@ -13642,43 +14863,27 @@ class OfflineManagerControl {
|
|
|
13642
14863
|
* Handle progress updates from download manager
|
|
13643
14864
|
*/
|
|
13644
14865
|
handleProgressUpdate(downloads) {
|
|
13645
|
-
//
|
|
13646
|
-
|
|
13647
|
-
|
|
13648
|
-
|
|
13649
|
-
|
|
13650
|
-
|
|
13651
|
-
|
|
13652
|
-
|
|
13653
|
-
|
|
13654
|
-
|
|
13655
|
-
|
|
13656
|
-
|
|
13657
|
-
|
|
13658
|
-
|
|
13659
|
-
|
|
13660
|
-
|
|
13661
|
-
|
|
13662
|
-
|
|
13663
|
-
|
|
13664
|
-
case 'tiles':
|
|
13665
|
-
displayText = `${percentage}%`;
|
|
13666
|
-
break;
|
|
13667
|
-
default:
|
|
13668
|
-
displayText = `${percentage}%`;
|
|
13669
|
-
}
|
|
13670
|
-
}
|
|
13671
|
-
else {
|
|
13672
|
-
// Multiple downloads - show average
|
|
13673
|
-
let totalPercentage = 0;
|
|
13674
|
-
for (const download of downloads.values()) {
|
|
13675
|
-
totalPercentage += download.percentage;
|
|
13676
|
-
}
|
|
13677
|
-
const avgPercentage = Math.round(totalPercentage / downloads.size);
|
|
13678
|
-
displayText = `${avgPercentage}%`;
|
|
13679
|
-
}
|
|
13680
|
-
this.updateProgressBadge(displayText, true);
|
|
14866
|
+
// The panel has the detail for each phase; the badge only has room for one
|
|
14867
|
+
// figure. With several downloads it averages all of this batch's, counting
|
|
14868
|
+
// finished ones as 100, so one finishing doesn't drop the figure, and never
|
|
14869
|
+
// goes back: a download added mid-batch holds it until the average catches up.
|
|
14870
|
+
for (const [regionId, download] of downloads) {
|
|
14871
|
+
this.badgeBatch.set(regionId, download.percentage);
|
|
14872
|
+
}
|
|
14873
|
+
for (const regionId of this.badgeBatch.keys()) {
|
|
14874
|
+
if (!downloads.has(regionId))
|
|
14875
|
+
this.badgeBatch.set(regionId, 100);
|
|
14876
|
+
}
|
|
14877
|
+
if (downloads.size === 0) {
|
|
14878
|
+
this.badgeBatch.clear();
|
|
14879
|
+
this.badgePercentage = 0;
|
|
14880
|
+
}
|
|
14881
|
+
else {
|
|
14882
|
+
const total = [...this.badgeBatch.values()].reduce((sum, value) => sum + value, 0);
|
|
14883
|
+
this.badgePercentage = Math.max(this.badgePercentage, Math.round(total / this.badgeBatch.size));
|
|
14884
|
+
this.updateProgressBadge(`${this.badgePercentage}%`, true);
|
|
13681
14885
|
}
|
|
14886
|
+
this.panelRenderer?.updateDownloadProgress(downloads);
|
|
13682
14887
|
}
|
|
13683
14888
|
/**
|
|
13684
14889
|
* Handle download completion
|
|
@@ -13694,6 +14899,7 @@ class OfflineManagerControl {
|
|
|
13694
14899
|
*/
|
|
13695
14900
|
handleDownloadError(regionId, error) {
|
|
13696
14901
|
controlLogger.error(`Download error for region ${regionId}:`, error);
|
|
14902
|
+
alert(`${t('alert.downloadFailed')}: ${error instanceof Error ? error.message : t('alert.unknownError')}`);
|
|
13697
14903
|
// Use the panel's refresh method instead of renderPanel to avoid conflicts
|
|
13698
14904
|
if (this.panelRenderer) {
|
|
13699
14905
|
this.panelRenderer.refresh();
|
|
@@ -13837,7 +15043,7 @@ class OfflineManagerControl {
|
|
|
13837
15043
|
type: 'line',
|
|
13838
15044
|
source: sourceId,
|
|
13839
15045
|
paint: {
|
|
13840
|
-
'line-color': '#
|
|
15046
|
+
'line-color': '#A8116F', // Revision magenta, the UI's accent
|
|
13841
15047
|
'line-width': 3,
|
|
13842
15048
|
'line-opacity': 0.8,
|
|
13843
15049
|
},
|
|
@@ -13890,6 +15096,10 @@ class OfflineManagerControl {
|
|
|
13890
15096
|
controlLogger.error(`Style ${styleId} not found in IndexedDB`);
|
|
13891
15097
|
return;
|
|
13892
15098
|
}
|
|
15099
|
+
if (needsMapboxGl(styleEntry.style) && !isMapboxGlMap(this.map)) {
|
|
15100
|
+
controlLogger.error(`Style ${styleId} needs Mapbox GL JS, so this map can't show it`);
|
|
15101
|
+
return;
|
|
15102
|
+
}
|
|
13893
15103
|
// The style is already patched with idb:// URLs by addRegion() in regionService.
|
|
13894
15104
|
// DO NOT call patchStyleForOffline() again — double-patching rewrites the downloadId
|
|
13895
15105
|
// in URLs and can break tile/glyph lookups.
|
|
@@ -13948,9 +15158,9 @@ class OfflineManagerControl {
|
|
|
13948
15158
|
${styles
|
|
13949
15159
|
.map(style => `
|
|
13950
15160
|
<button class="w-full p-3 text-left border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" data-style-id="${escapeHtml$1(style.key)}">
|
|
13951
|
-
<div class="font-medium text-gray-900 dark:text-white">${escapeHtml$1(style
|
|
15161
|
+
<div class="font-medium text-gray-900 dark:text-white">${escapeHtml$1(styleDisplayName(style))}</div>
|
|
13952
15162
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
13953
|
-
${style.regions?.length || 0}
|
|
15163
|
+
${regionCountLabel(style.regions?.length || 0)} •
|
|
13954
15164
|
${Object.keys(style.style.sources || {}).length} sources
|
|
13955
15165
|
</div>
|
|
13956
15166
|
</button>
|
|
@@ -14048,6 +15258,52 @@ class OfflineManagerControl {
|
|
|
14048
15258
|
}
|
|
14049
15259
|
}
|
|
14050
15260
|
|
|
15261
|
+
const assetLogger = logger.scope('RuntimeAssets');
|
|
15262
|
+
const ASSET_TIMEOUT_MS = 30000;
|
|
15263
|
+
/**
|
|
15264
|
+
* Cache the code Mapbox GL fetches from api.mapbox.com while rendering: the
|
|
15265
|
+
* Draco and meshopt decoders 3D models need, plus the RTL text and procedural
|
|
15266
|
+
* building modules. The Service Worker serves them back from this cache. They
|
|
15267
|
+
* live in the Cache API rather than IndexedDB because they belong to the
|
|
15268
|
+
* renderer, not to any style or region.
|
|
15269
|
+
*
|
|
15270
|
+
* Failures only log: the rest of a region is still usable without these.
|
|
15271
|
+
*
|
|
15272
|
+
* @returns how many assets are now cached
|
|
15273
|
+
*/
|
|
15274
|
+
async function cacheMapboxRuntimeAssets() {
|
|
15275
|
+
if (typeof caches === 'undefined')
|
|
15276
|
+
return 0;
|
|
15277
|
+
let cache;
|
|
15278
|
+
try {
|
|
15279
|
+
cache = await caches.open(MAPBOX_RUNTIME_CACHE);
|
|
15280
|
+
}
|
|
15281
|
+
catch (error) {
|
|
15282
|
+
assetLogger.warn('Cache API unavailable, skipping runtime assets:', error);
|
|
15283
|
+
return 0;
|
|
15284
|
+
}
|
|
15285
|
+
const results = await Promise.all(MAPBOX_RUNTIME_ASSETS.map(async (path) => {
|
|
15286
|
+
const url = `${MAPBOX_RUNTIME_ORIGIN}${path}`;
|
|
15287
|
+
try {
|
|
15288
|
+
if (await cache.match(url))
|
|
15289
|
+
return true;
|
|
15290
|
+
const response = await fetchWithRetry(url, { retries: 0, timeout: ASSET_TIMEOUT_MS });
|
|
15291
|
+
await cache.put(url, response);
|
|
15292
|
+
return true;
|
|
15293
|
+
}
|
|
15294
|
+
catch (error) {
|
|
15295
|
+
assetLogger.warn(`Failed to cache ${url}:`, error);
|
|
15296
|
+
return false;
|
|
15297
|
+
}
|
|
15298
|
+
}));
|
|
15299
|
+
return results.filter(Boolean).length;
|
|
15300
|
+
}
|
|
15301
|
+
|
|
15302
|
+
var runtimeAssetService = /*#__PURE__*/Object.freeze({
|
|
15303
|
+
__proto__: null,
|
|
15304
|
+
cacheMapboxRuntimeAssets: cacheMapboxRuntimeAssets
|
|
15305
|
+
});
|
|
15306
|
+
|
|
14051
15307
|
exports.AnalyticsService = AnalyticsService;
|
|
14052
15308
|
exports.CONTENT_TYPES = CONTENT_TYPES;
|
|
14053
15309
|
exports.CategorizedError = CategorizedError;
|
|
@@ -14143,6 +15399,7 @@ exports.getSpriteAnalytics = getSpriteAnalytics;
|
|
|
14143
15399
|
exports.getSpriteStats = getSpriteStats;
|
|
14144
15400
|
exports.getSqlJs = getSqlJs;
|
|
14145
15401
|
exports.getStyleStats = getStyleStats;
|
|
15402
|
+
exports.getStyleStorageSizes = getStyleStorageSizes;
|
|
14146
15403
|
exports.getTileAnalytics = getTileAnalytics;
|
|
14147
15404
|
exports.getTileStats = getTileStats;
|
|
14148
15405
|
exports.getUrlHostname = getUrlHostname;
|
|
@@ -14153,6 +15410,8 @@ exports.hostMatches = hostMatches;
|
|
|
14153
15410
|
exports.i18n = i18n;
|
|
14154
15411
|
exports.icons = icons;
|
|
14155
15412
|
exports.idbFetchHandler = idbFetchHandler;
|
|
15413
|
+
exports.injectExtraSources = injectExtraSources;
|
|
15414
|
+
exports.isMapboxGlMap = isMapboxGlMap;
|
|
14156
15415
|
exports.isMapboxHost = isMapboxHost;
|
|
14157
15416
|
exports.isMapboxProtocol = isMapboxProtocol;
|
|
14158
15417
|
exports.isStyleDownloaded = isStyleDownloaded;
|
|
@@ -14163,6 +15422,7 @@ exports.loadStyles = loadStyles;
|
|
|
14163
15422
|
exports.logger = logger;
|
|
14164
15423
|
exports.modelKeyBelongsToStyle = modelKeyBelongsToStyle;
|
|
14165
15424
|
exports.modelService = modelService;
|
|
15425
|
+
exports.needsMapboxGl = needsMapboxGl;
|
|
14166
15426
|
exports.normalizeSpriteProperty = normalizeSpriteProperty;
|
|
14167
15427
|
exports.normalizeStyleUrl = normalizeStyleUrl;
|
|
14168
15428
|
exports.optimizeStorage = optimizeStorage;
|
|
@@ -14177,12 +15437,17 @@ exports.resetOfflineMapDB = resetOfflineMapDB;
|
|
|
14177
15437
|
exports.resolveImports = resolveImports;
|
|
14178
15438
|
exports.resolveMapboxUrl = resolveMapboxUrl;
|
|
14179
15439
|
exports.resourceKeyBelongsToStyle = resourceKeyBelongsToStyle;
|
|
15440
|
+
exports.restoreUnstoredModels = restoreUnstoredModels;
|
|
14180
15441
|
exports.rewriteMapboxCdnTileUrl = rewriteMapboxCdnTileUrl;
|
|
14181
15442
|
exports.safeExecute = safeExecute;
|
|
14182
15443
|
exports.sanitizeIndoorExpressions = sanitizeIndoorExpressions;
|
|
15444
|
+
exports.selectTileJsonVariant = selectTileJsonVariant;
|
|
14183
15445
|
exports.setupAutoCleanup = setupAutoCleanup;
|
|
14184
15446
|
exports.spriteService = spriteService;
|
|
15447
|
+
exports.stashOriginalModels = stashOriginalModels;
|
|
14185
15448
|
exports.stopAutoCleanup = stopAutoCleanup;
|
|
15449
|
+
exports.storedModelNames = storedModelNames;
|
|
15450
|
+
exports.styleDisplayName = styleDisplayName;
|
|
14186
15451
|
exports.t = t;
|
|
14187
15452
|
exports.tileService = tileService;
|
|
14188
15453
|
exports.unregisterOfflineServiceWorker = unregisterOfflineServiceWorker;
|